Initial commit
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/auth"
|
||||
"carcontrol/api/internal/models"
|
||||
)
|
||||
|
||||
// deletionCooldown is how long an account-deletion request sits before it can
|
||||
// be finalized, giving the user a window to change their mind.
|
||||
const deletionCooldown = 3 * 24 * time.Hour
|
||||
|
||||
// userRecord is the PocketBase-facing shape of a user (mixes PocketBase's own
|
||||
// camelCase system fields with our snake_case custom ones).
|
||||
type userRecord struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Verified bool `json:"verified"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
Theme string `json:"theme"`
|
||||
Locale string `json:"locale"`
|
||||
DateFormat string `json:"date_format"`
|
||||
FontSize string `json:"font_size"`
|
||||
DeletionRequestedAt string `json:"deletion_requested_at"`
|
||||
Role string `json:"role"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
func (rec userRecord) toModel() models.User {
|
||||
u := models.User{
|
||||
ID: rec.ID,
|
||||
Email: rec.Email,
|
||||
Verified: rec.Verified,
|
||||
Name: rec.Name,
|
||||
Bio: rec.Bio,
|
||||
HasAvatar: rec.Avatar != "",
|
||||
Theme: orDefault(rec.Theme, "system"),
|
||||
Locale: orDefault(rec.Locale, "en-US"),
|
||||
DateFormat: orDefault(rec.DateFormat, "YMD"),
|
||||
FontSize: orDefault(rec.FontSize, "medium"),
|
||||
Role: orDefault(rec.Role, "user"),
|
||||
Created: rec.Created,
|
||||
}
|
||||
if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
|
||||
u.DeletionRequestedAt = &t
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func orDefault(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) fetchUser(r *http.Request, id string) (*userRecord, error) {
|
||||
var rec userRecord
|
||||
if err := s.pb.GetOne(r.Context(), s.usersCollection, id, &rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
rec, err := s.fetchUser(r, claims.Sub)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec.toModel())
|
||||
}
|
||||
|
||||
type updateMeRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Bio *string `json:"bio"`
|
||||
Theme *string `json:"theme"`
|
||||
Locale *string `json:"locale"`
|
||||
DateFormat *string `json:"dateFormat"`
|
||||
FontSize *string `json:"fontSize"`
|
||||
}
|
||||
|
||||
var validThemes = map[string]bool{"light": true, "dark": true, "system": true}
|
||||
var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true, "MDY": true}
|
||||
var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true}
|
||||
|
||||
// handleUpdateMe applies a partial update — only fields present in the request
|
||||
// body are touched, so the Account/Profile/Appearance sections of the settings
|
||||
// panel can each save independently without clobbering the others.
|
||||
func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
var in updateMeRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{}
|
||||
if in.Name != nil {
|
||||
payload["name"] = *in.Name
|
||||
}
|
||||
if in.Bio != nil {
|
||||
payload["bio"] = *in.Bio
|
||||
}
|
||||
if in.Theme != nil {
|
||||
if !validThemes[*in.Theme] {
|
||||
writeError(w, http.StatusBadRequest, "theme must be light, dark, or system")
|
||||
return
|
||||
}
|
||||
payload["theme"] = *in.Theme
|
||||
}
|
||||
if in.Locale != nil {
|
||||
payload["locale"] = *in.Locale
|
||||
}
|
||||
if in.DateFormat != nil {
|
||||
if !validDateFormats[*in.DateFormat] {
|
||||
writeError(w, http.StatusBadRequest, "dateFormat must be YMD, DMY, or MDY")
|
||||
return
|
||||
}
|
||||
payload["date_format"] = *in.DateFormat
|
||||
}
|
||||
if in.FontSize != nil {
|
||||
if !validFontSizes[*in.FontSize] {
|
||||
writeError(w, http.StatusBadRequest, "fontSize must be small, medium, or large")
|
||||
return
|
||||
}
|
||||
payload["font_size"] = *in.FontSize
|
||||
}
|
||||
|
||||
var rec userRecord
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec.toModel())
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
OldPassword string `json:"oldPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
var in changePasswordRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if in.OldPassword == "" {
|
||||
writeError(w, http.StatusBadRequest, "current password is required")
|
||||
return
|
||||
}
|
||||
if len(in.NewPassword) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "new password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the current password the same way login does, since the API
|
||||
// Server otherwise only ever talks to PocketBase as a superuser.
|
||||
if _, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection, claims.Email, in.OldPassword); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "current password is incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{"password": in.NewPassword, "passwordConfirm": in.NewPassword}
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(5 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "avatar upload must be under 5MB")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("avatar")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "missing avatar file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not read upload")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection, claims.Sub, nil, "avatar", header.Filename, data); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
rec, err := s.fetchUser(r, claims.Sub)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec.toModel())
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, map[string]any{"avatar": ""}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
rec, err := s.fetchUser(r, claims.Sub)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if rec.Avatar == "" {
|
||||
writeError(w, http.StatusNotFound, "no avatar set")
|
||||
return
|
||||
}
|
||||
data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection, rec.ID, rec.Avatar)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if err := s.pb.RequestVerification(r.Context(), s.usersCollection, claims.Email); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "requested"})
|
||||
}
|
||||
|
||||
// handleExportData bundles the account profile plus every car the user owns
|
||||
// (with its service records and parts) into one downloadable JSON file. Cars
|
||||
// merely shared with the user are not exported — only cars they own.
|
||||
func (s *Server) handleExportData(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
user, err := s.fetchUser(r, claims.Sub)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
carsRes, err := s.pb.List(r.Context(), colCars, url.Values{
|
||||
"filter": {fmt.Sprintf("owner='%s'", claims.Sub)},
|
||||
"sort": {"name"},
|
||||
"perPage": {"200"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var carRecs []carRecord
|
||||
if err := json.Unmarshal(carsRes.Items, &carRecs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
type carExport struct {
|
||||
models.Car
|
||||
ServiceRecords []models.ServiceRecord `json:"serviceRecords"`
|
||||
Parts []models.Part `json:"parts"`
|
||||
}
|
||||
exportCars := make([]carExport, 0, len(carRecs))
|
||||
for _, cr := range carRecs {
|
||||
car := cr.toModel()
|
||||
|
||||
svcRes, err := s.pb.List(r.Context(), colServices, url.Values{
|
||||
"filter": {fmt.Sprintf("car='%s'", car.ID)}, "sort": {"-date"}, "perPage": {"500"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var svcRecs []serviceRecord
|
||||
if err := json.Unmarshal(svcRes.Items, &svcRecs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
services := make([]models.ServiceRecord, 0, len(svcRecs))
|
||||
for _, sr := range svcRecs {
|
||||
m := sr.toModel()
|
||||
m.ComputeDerived(&car)
|
||||
services = append(services, m)
|
||||
}
|
||||
|
||||
partsRes, err := s.pb.List(r.Context(), colParts, url.Values{
|
||||
"filter": {fmt.Sprintf("car='%s'", car.ID)}, "perPage": {"500"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var partRecs []partRecord
|
||||
if err := json.Unmarshal(partsRes.Items, &partRecs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
parts := make([]models.Part, 0, len(partRecs))
|
||||
for _, p := range partRecs {
|
||||
parts = append(parts, p.toModel())
|
||||
}
|
||||
|
||||
exportCars = append(exportCars, carExport{Car: car, ServiceRecords: services, Parts: parts})
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"exportedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
"account": user.toModel(),
|
||||
"cars": exportCars,
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("car-control-export-%s.json", time.Now().UTC().Format("2006-01-02"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
// importCar mirrors handleExportData's per-car shape. The "id" and the
|
||||
// service records'/parts' "car" fields in the uploaded file are ignored —
|
||||
// this always creates brand-new records, remapped to the newly created car,
|
||||
// rather than trying to match or overwrite anything that already exists.
|
||||
type importCar struct {
|
||||
models.Car
|
||||
ServiceRecords []models.ServiceRecord `json:"serviceRecords"`
|
||||
Parts []models.Part `json:"parts"`
|
||||
}
|
||||
|
||||
type importRequest struct {
|
||||
Cars []importCar `json:"cars"`
|
||||
}
|
||||
|
||||
type importResult struct {
|
||||
CarsImported int `json:"carsImported"`
|
||||
ServicesImported int `json:"servicesImported"`
|
||||
PartsImported int `json:"partsImported"`
|
||||
}
|
||||
|
||||
// handleImportData adds cars/service-records/parts from a previously exported
|
||||
// JSON file (or a hand-built one in the same shape). It only ever creates new
|
||||
// records — it does not merge with or overwrite existing shared household
|
||||
// data. Deliberately lenient about unknown top-level fields (e.g. the
|
||||
// export's "account"/"exportedAt"), since round-tripping the exact export
|
||||
// file is the main use case.
|
||||
func (s *Server) handleImportData(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var in importRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid import file: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(in.Cars) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "import file has no cars")
|
||||
return
|
||||
}
|
||||
|
||||
var result importResult
|
||||
for _, ic := range in.Cars {
|
||||
car := ic.Car
|
||||
if car.Name == "" {
|
||||
continue // skip malformed entries rather than failing the whole import
|
||||
}
|
||||
applyCarDefaults(&car)
|
||||
|
||||
// Imported cars are owned by the importing user, regardless of any
|
||||
// owner in the file.
|
||||
payload := carPayload(car)
|
||||
payload["owner"] = claims.Sub
|
||||
|
||||
var rec carRecord
|
||||
if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
result.CarsImported++
|
||||
|
||||
for _, svc := range ic.ServiceRecords {
|
||||
svc.Car = rec.ID
|
||||
if err := s.pb.Create(r.Context(), colServices, servicePayload(svc), nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
result.ServicesImported++
|
||||
}
|
||||
for _, p := range ic.Parts {
|
||||
p.Car = rec.ID
|
||||
if err := s.pb.Create(r.Context(), colParts, partPayload(p), nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
result.PartsImported++
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
type deleteAccountRequest struct {
|
||||
ConfirmEmail string `json:"confirmEmail"`
|
||||
}
|
||||
|
||||
// handleRequestDeletion starts the cooldown. The account is not touched yet —
|
||||
// handleFinalizeDeletion is a separate, later call once the cooldown elapses.
|
||||
func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
var in deleteAccountRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(in.ConfirmEmail), claims.Email) {
|
||||
writeError(w, http.StatusBadRequest, "typed email does not match your account email")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
payload := map[string]any{"deletion_requested_at": formatPBDate(now)}
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"deletionRequestedAt": now.Format(time.RFC3339),
|
||||
"eligibleAt": now.Add(deletionCooldown).Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
payload := map[string]any{"deletion_requested_at": ""}
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
|
||||
}
|
||||
|
||||
// handleFinalizeDeletion actually deletes the account, but only once the
|
||||
// cooldown started by handleRequestDeletion has elapsed. Deleting the user
|
||||
// record cascades to their "sessions" rows (cascadeDelete relation); the
|
||||
// shared cars/service-records/parts data is untouched, since it belongs to
|
||||
// the household, not to one account.
|
||||
func (s *Server) handleFinalizeDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
rec, err := s.fetchUser(r, claims.Sub)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
requestedAt := parsePBDate(rec.DeletionRequestedAt)
|
||||
if requestedAt.IsZero() {
|
||||
writeError(w, http.StatusBadRequest, "no deletion request is pending")
|
||||
return
|
||||
}
|
||||
if time.Now().UTC().Before(requestedAt.Add(deletionCooldown)) {
|
||||
writeError(w, http.StatusForbidden, "the cooldown period has not elapsed yet")
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), s.usersCollection, claims.Sub); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
Reference in New Issue
Block a user