package api import ( "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strings" "time" "drivervault/apiserver/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"` Currency string `json:"currency"` FontSize string `json:"font_size"` DragLocked bool `json:"drag_locked"` DeletionRequestedAt string `json:"deletion_requested_at"` Role string `json:"role"` Organization string `json:"organization"` Created string `json:"created"` // Garage arrangement. Raw because PocketBase hands back whatever a json field // holds — null on a record that has never been arranged, and "" on one // PocketBase stored as an empty value — neither of which is a []string. CarOrder json.RawMessage `json:"car_order"` // The charging page's tab arrangement, stored the same way and for the same // reason. ChargerTabOrder json.RawMessage `json:"charger_tab_order"` ChargerCardOrder json.RawMessage `json:"charger_card_order"` // Which tab each tabbed page opens on. Raw for the same reason as the // arrangements: never-set records come back as null or "". DefaultTabs json.RawMessage `json:"default_tabs"` } // carOrder decodes the stored garage arrangement, treating anything unexpected // as "not arranged yet" rather than failing the whole profile read. func (rec userRecord) carOrder() []string { return decodeStringList(rec.CarOrder) } func (rec userRecord) chargerTabOrder() []string { return decodeStringList(rec.ChargerTabOrder) } func (rec userRecord) chargerCardOrder() []string { return decodeStringList(rec.ChargerCardOrder) } func (rec userRecord) defaultTabs() map[string]string { return decodeStringMap(rec.DefaultTabs) } // decodeStringList reads a PocketBase json field that holds a list of strings, // treating anything unexpected as empty rather than failing the whole read. func decodeStringList(raw json.RawMessage) []string { if len(raw) == 0 { return nil } var out []string if err := json.Unmarshal(raw, &out); err != nil { return nil } return out } // decodeStringMap is decodeStringList for a json field holding a flat // string->string map, and is just as forgiving about what it finds. func decodeStringMap(raw json.RawMessage) map[string]string { if len(raw) == 0 { return nil } var out map[string]string if err := json.Unmarshal(raw, &out); err != nil { return nil } return out } 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"), Currency: orDefault(rec.Currency, "USD"), FontSize: orDefault(rec.FontSize, "medium"), DragLocked: rec.DragLocked, Role: orDefault(rec.Role, "user"), Created: rec.Created, Organization: rec.Organization, CarOrder: rec.carOrder(), ChargerTabOrder: rec.chargerTabOrder(), ChargerCardOrder: rec.chargerCardOrder(), DefaultTabs: rec.defaultTabs(), } if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() { u.DeletionRequestedAt = &t } return u } func orDefault(v, fallback string) string { if v == "" { return fallback } return v } // profileOf builds the profile payload for a user record, resolving their // organization's name so the clients can label the membership without a second // round trip. The lookup is best effort — an unresolvable name leaves the id. func (s *Server) profileOf(r *http.Request, rec *userRecord) models.User { u := rec.toModel() if u.Organization != "" { u.OrganizationName = s.orgName(r.Context(), u.Organization) } return u } 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 := caller(r) if claims == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } rec, err := s.fetchUser(r, claims.ID) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, s.profileOf(r, rec)) } type updateMeRequest struct { Name *string `json:"name"` Bio *string `json:"bio"` Theme *string `json:"theme"` Locale *string `json:"locale"` DateFormat *string `json:"dateFormat"` Currency *string `json:"currency"` FontSize *string `json:"fontSize"` DragLocked *bool `json:"dragLocked"` CarOrder *[]string `json:"carOrder"` ChargerTabOrder *[]string `json:"chargerTabOrder"` ChargerCardOrder *[]string `json:"chargerCardOrder"` // The whole map every time — the clients send it back with one page changed, // so a partial merge here would only make "no default" impossible to express. DefaultTabs *map[string]string `json:"defaultTabs"` } // maxCarOrder bounds the stored arrangement. listCars fetches at most 200 owned // plus 200 shared cars, so this leaves room without letting a client park an // unbounded blob on the record. const maxCarOrder = 500 // normalizeCarOrder cleans a client-supplied garage arrangement: blanks out, // duplicates dropped (first position wins), length capped. The ids are not // checked against real cars — that would cost a lookup per entry, and an id for // a car the user no longer has is harmless: listCars ignores what it can't // match, and the next drag rewrites the list anyway. func normalizeCarOrder(in []string) ([]string, error) { return normalizeOrder("carOrder", in, maxCarOrder) } // maxChargerTabOrder bounds the charging page's tab arrangement. The page has // two tabs; the room is for tabs a later release adds, not for a client to park // a blob on the record. const maxChargerTabOrder = 20 func normalizeChargerTabOrder(in []string) ([]string, error) { return normalizeOrder("chargerTabOrder", in, maxChargerTabOrder) } func normalizeChargerCardOrder(in []string) ([]string, error) { return normalizeOrder("chargerCardOrder", in, maxChargerTabOrder) } // The tabbed pages a default tab can be set for, and the tabs each one has. // Kept in step with the web app's src/lib/tabs.js. A key the page does not have // would simply never match a button and the page would open on the front of its // bar anyway — this is here to keep junk off the record, not to protect the // clients from themselves. var validDefaultTabs = map[string]map[string]bool{ "charging": {"public": true, "home": true}, "car": { "provider": true, "info": true, "services": true, "technical": true, "maintenance": true, "fuel": true, "charging": true, "documents": true, "parts": true, "reminders": true, }, "settings": {"personal": true, "users": true, "organization": true, "integrations": true}, } // normalizeDefaultTabs cleans a client-supplied default-tab map. An empty value // is how a client says "no default, open on the front of the bar", and is stored // as an absent key rather than an empty string so the two cannot drift apart. func normalizeDefaultTabs(in map[string]string) (map[string]string, error) { out := make(map[string]string, len(in)) for page, tab := range in { tabs, ok := validDefaultTabs[page] if !ok { return nil, fmt.Errorf("defaultTabs: %q is not a tabbed page", page) } tab = strings.TrimSpace(tab) if tab == "" { continue } if !tabs[tab] { return nil, fmt.Errorf("defaultTabs: %q is not a tab on the %s page", tab, page) } out[page] = tab } return out, nil } func normalizeOrder(field string, in []string, max int) ([]string, error) { if len(in) > max { return nil, fmt.Errorf("%s is too long (max %d)", field, max) } out := make([]string, 0, len(in)) seen := make(map[string]bool, len(in)) for _, id := range in { id = strings.TrimSpace(id) if id == "" || seen[id] { continue } seen[id] = true out = append(out, id) } return out, nil } 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} // Kept in step with the users.currency select options in setup-pocketbase.mjs: // PocketBase rejects anything outside its own list, so accepting a wider set // here would only turn a clear 400 into a confusing upstream error. var validCurrencies = map[string]bool{ "EUR": true, "GBP": true, "CHF": true, "PLN": true, "CZK": true, "HUF": true, "RON": true, "BGN": true, "DKK": true, "SEK": true, "NOK": true, "ISK": true, "ALL": true, "AMD": true, "AZN": true, "BAM": true, "BYN": true, "GEL": true, "MDL": true, "MKD": true, "RSD": true, "RUB": true, "TRY": true, "UAH": true, "USD": true, "CAD": true, "AUD": true, "JPY": true, } // The clients pick language and region separately and join them into this tag, // so the stored value is only ever language-REGION. Enforcing that shape here // keeps a bad tag out of the record: the web app feeds the locale straight to // Intl, which throws on a malformed one rather than falling back. var localePattern = regexp.MustCompile(`^[a-z]{2}-[A-Z]{2}$`) // 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 := caller(r) if claims == nil { 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 { if !localePattern.MatchString(*in.Locale) { writeError(w, http.StatusBadRequest, "locale must look like en-US") return } 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.Currency != nil { if !validCurrencies[*in.Currency] { writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code") return } payload["currency"] = *in.Currency } 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 } if in.DragLocked != nil { // No value to validate — either the arrangements on this account's pages // are held still or they are not. payload["drag_locked"] = *in.DragLocked } if in.CarOrder != nil { ids, err := normalizeCarOrder(*in.CarOrder) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } payload["car_order"] = ids } if in.ChargerTabOrder != nil { keys, err := normalizeChargerTabOrder(*in.ChargerTabOrder) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } payload["charger_tab_order"] = keys } if in.ChargerCardOrder != nil { keys, err := normalizeChargerCardOrder(*in.ChargerCardOrder) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } payload["charger_card_order"] = keys } if in.DefaultTabs != nil { tabs, err := normalizeDefaultTabs(*in.DefaultTabs) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } payload["default_tabs"] = tabs } var rec userRecord if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, s.profileOf(r, &rec)) } type changePasswordRequest struct { OldPassword string `json:"oldPassword"` NewPassword string `json:"newPassword"` } func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { claims := caller(r) if claims == nil { 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.ID, 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 := caller(r) if claims == nil { 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.ID, nil, "avatar", header.Filename, data); err != nil { writePBError(w, err) return } rec, err := s.fetchUser(r, claims.ID) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, s.profileOf(r, rec)) } func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) { claims := caller(r) if claims == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, 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 := caller(r) if claims == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } rec, err := s.fetchUser(r, claims.ID) 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 := caller(r) if claims == nil { 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 := caller(r) if claims == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } user, err := s.fetchUser(r, claims.ID) if err != nil { writePBError(w, err) return } carsRes, err := s.pb.List(r.Context(), colCars, url.Values{ "filter": {fmt.Sprintf("owner='%s'", claims.ID)}, "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 := caller(r) if claims == nil { 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.ID 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 := caller(r) if claims == nil { 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.ID, 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 := caller(r) if claims == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } payload := map[string]any{"deletion_requested_at": ""} if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, 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 := caller(r) if claims == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } rec, err := s.fetchUser(r, claims.ID) 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.ID); err != nil { writePBError(w, err) return } w.WriteHeader(http.StatusNoContent) }