Initial commit
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// adminUser is the API shape returned by the admin user-management endpoints.
|
||||
type adminUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
func (rec userRecord) toAdminUser() adminUser {
|
||||
return adminUser{
|
||||
ID: rec.ID,
|
||||
Email: rec.Email,
|
||||
Name: rec.Name,
|
||||
Role: orDefault(rec.Role, "user"),
|
||||
Verified: rec.Verified,
|
||||
Created: rec.Created,
|
||||
}
|
||||
}
|
||||
|
||||
// requireAdmin ensures the current request is from an admin. It re-reads the
|
||||
// user's role from PocketBase (rather than trusting the token) so a demotion
|
||||
// takes effect immediately. On failure it writes the response and returns false.
|
||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
me, err := s.fetchUser(r, s.currentUserID(r))
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return false
|
||||
}
|
||||
if orDefault(me.Role, "user") != "admin" {
|
||||
writeError(w, http.StatusForbidden, "admin access required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// countAdmins returns how many users currently hold the admin role. Used to
|
||||
// prevent removing the last admin (which would lock everyone out of admin).
|
||||
func (s *Server) countAdmins(ctx context.Context) (int, error) {
|
||||
res, err := s.pb.List(ctx, s.usersCollection, url.Values{
|
||||
"filter": {"role='admin'"},
|
||||
"perPage": {"1"},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.TotalItems, nil
|
||||
}
|
||||
|
||||
// handleListUsers serves GET /api/admin/users.
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
res, err := s.pb.List(r.Context(), s.usersCollection, url.Values{
|
||||
"sort": {"email"},
|
||||
"perPage": {"500"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []userRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]adminUser, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, rec.toAdminUser())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleCreateUser serves POST /api/admin/users.
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var in createUserRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
in.Email = strings.TrimSpace(strings.ToLower(in.Email))
|
||||
if in.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "email is required")
|
||||
return
|
||||
}
|
||||
if len(in.Password) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
role := orDefault(in.Role, "user")
|
||||
if role != "user" && role != "admin" {
|
||||
writeError(w, http.StatusBadRequest, "role must be 'user' or 'admin'")
|
||||
return
|
||||
}
|
||||
name := in.Name
|
||||
if name == "" {
|
||||
name = strings.SplitN(in.Email, "@", 2)[0]
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"email": in.Email,
|
||||
"password": in.Password,
|
||||
"passwordConfirm": in.Password,
|
||||
"name": name,
|
||||
"role": role,
|
||||
"emailVisibility": true,
|
||||
"verified": true,
|
||||
}
|
||||
var rec userRecord
|
||||
if err := s.pb.Create(r.Context(), s.usersCollection, payload, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, rec.toAdminUser())
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Role *string `json:"role"`
|
||||
}
|
||||
|
||||
// handleUpdateUser serves PATCH /api/admin/users/{id} (name and/or role).
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
var in updateUserRequest
|
||||
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.Role != nil {
|
||||
role := *in.Role
|
||||
if role != "user" && role != "admin" {
|
||||
writeError(w, http.StatusBadRequest, "role must be 'user' or 'admin'")
|
||||
return
|
||||
}
|
||||
// Guard: don't demote the last remaining admin.
|
||||
if role != "admin" {
|
||||
if blocked, err := s.wouldRemoveLastAdmin(r, id); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
} else if blocked {
|
||||
writeError(w, http.StatusBadRequest, "cannot demote the last admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
payload["role"] = role
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "nothing to update")
|
||||
return
|
||||
}
|
||||
|
||||
var rec userRecord
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, id, payload, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec.toAdminUser())
|
||||
}
|
||||
|
||||
type setPasswordRequest struct {
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
// handleSetUserPassword serves POST /api/admin/users/{id}/password.
|
||||
func (s *Server) handleSetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var in setPasswordRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(in.NewPassword) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"password": in.NewPassword,
|
||||
"passwordConfirm": in.NewPassword,
|
||||
}
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, r.PathValue("id"), payload, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDeleteUser serves DELETE /api/admin/users/{id}.
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if id == s.currentUserID(r) {
|
||||
writeError(w, http.StatusBadRequest, "you cannot delete your own account here")
|
||||
return
|
||||
}
|
||||
// Guard: don't delete the last remaining admin.
|
||||
if blocked, err := s.wouldRemoveLastAdmin(r, id); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
} else if blocked {
|
||||
writeError(w, http.StatusBadRequest, "cannot delete the last admin")
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), s.usersCollection, id); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// wouldRemoveLastAdmin reports whether removing/demoting user `id` would leave
|
||||
// zero admins (i.e. `id` is currently an admin and is the only one).
|
||||
func (s *Server) wouldRemoveLastAdmin(r *http.Request, id string) (bool, error) {
|
||||
target, err := s.fetchUser(r, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if orDefault(target.Role, "user") != "admin" {
|
||||
return false, nil // not an admin; removing them changes nothing
|
||||
}
|
||||
count, err := s.countAdmins(r.Context())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count <= 1, nil
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/auth"
|
||||
)
|
||||
|
||||
// tokenTTL is how long an issued login token stays valid.
|
||||
const tokenTTL = 7 * 24 * time.Hour
|
||||
|
||||
// publicPaths bypass authentication. Everything else requires a valid token.
|
||||
var publicPaths = map[string]bool{
|
||||
"/api/health": true,
|
||||
"/api/auth/login": true,
|
||||
}
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const claimsKey ctxKey = "claims"
|
||||
|
||||
// withAuth rejects requests to non-public paths that lack a valid bearer token.
|
||||
func (s *Server) withAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Public API paths, plus everything outside /api/ (the embedded web
|
||||
// panel and its static assets), bypass authentication.
|
||||
if publicPaths[r.URL.Path] || !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing bearer token")
|
||||
return
|
||||
}
|
||||
claims, err := auth.Verify(s.authSecret, token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if s.sessionRevoked(r.Context(), claims.Jti) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), claimsKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// sessionRevoked reports whether the token's backing session is missing or
|
||||
// revoked (e.g. via "log out this device" from the settings panel). Tokens
|
||||
// minted before session-tracking existed have no jti and are rejected too,
|
||||
// which simply forces one fresh login.
|
||||
func (s *Server) sessionRevoked(ctx context.Context, jti string) bool {
|
||||
if jti == "" {
|
||||
return true
|
||||
}
|
||||
res, err := s.pb.List(ctx, colSessions, url.Values{
|
||||
"filter": {fmt.Sprintf("jti='%s'", jti)},
|
||||
"perPage": {"1"},
|
||||
})
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
var recs []sessionRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 {
|
||||
return true
|
||||
}
|
||||
return recs[0].Revoked
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
// Accept both "Bearer <token>" and a raw token.
|
||||
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
return strings.TrimSpace(h)
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type userInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User userInfo `json:"user"`
|
||||
}
|
||||
|
||||
// handleLogin verifies credentials against the PocketBase users collection and,
|
||||
// on success, mints an API Server JWT.
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var in loginRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Email == "" || in.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "email and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection, in.Email, in.Password)
|
||||
if err != nil {
|
||||
// Don't leak whether it was the email or the password.
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// The auth record doesn't carry the role field; fetch it so the token and
|
||||
// login response reflect the user's access role. Default to "user".
|
||||
role := "user"
|
||||
if u, err := s.fetchUser(r, rec.ID); err == nil {
|
||||
role = orDefault(u.Role, "user")
|
||||
}
|
||||
|
||||
jti, err := auth.NewJTI()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not issue token")
|
||||
return
|
||||
}
|
||||
if err := s.createSession(r.Context(), rec.ID, jti, r); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create session")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.Sign(s.authSecret, auth.Claims{
|
||||
Sub: rec.ID,
|
||||
Email: rec.Email,
|
||||
Name: rec.Name,
|
||||
Role: role,
|
||||
Jti: jti,
|
||||
}, tokenTTL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not issue token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: token,
|
||||
User: userInfo{ID: rec.ID, Email: rec.Email, Name: rec.Name, Role: role},
|
||||
})
|
||||
}
|
||||
|
||||
// sessionRecord is one row of the "sessions" collection — a login token's
|
||||
// device/revocation record, used to power "active sessions" in the settings
|
||||
// panel and to let a user log a device out remotely.
|
||||
type sessionRecord struct {
|
||||
ID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Jti string `json:"jti"`
|
||||
DeviceLabel string `json:"device_label"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Revoked bool `json:"revoked"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (s *Server) createSession(ctx context.Context, userID, jti string, r *http.Request) error {
|
||||
payload := map[string]any{
|
||||
"user": userID,
|
||||
"jti": jti,
|
||||
"device_label": deviceLabelFromUA(r.UserAgent()),
|
||||
"ip": clientIP(r),
|
||||
"user_agent": r.UserAgent(),
|
||||
"revoked": false,
|
||||
"expires_at": time.Now().Add(tokenTTL).UTC().Format(time.RFC3339),
|
||||
}
|
||||
return s.pb.Create(ctx, colSessions, payload, nil)
|
||||
}
|
||||
|
||||
// clientIP prefers a forwarding header (in case of a future reverse proxy)
|
||||
// and otherwise strips the port from the raw remote address.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
|
||||
return strings.TrimSpace(strings.Split(xf, ",")[0])
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// deviceLabelFromUA turns a User-Agent header into a short human label like
|
||||
// "Chrome on Windows" for the active-sessions list. Best-effort only.
|
||||
func deviceLabelFromUA(ua string) string {
|
||||
if ua == "" {
|
||||
return "Unknown device"
|
||||
}
|
||||
|
||||
var os string
|
||||
switch {
|
||||
case strings.Contains(ua, "Android"):
|
||||
os = "Android"
|
||||
case strings.Contains(ua, "iPhone"), strings.Contains(ua, "iPad"):
|
||||
os = "iOS"
|
||||
case strings.Contains(ua, "Windows"):
|
||||
os = "Windows"
|
||||
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
|
||||
os = "macOS"
|
||||
case strings.Contains(ua, "Linux"):
|
||||
os = "Linux"
|
||||
}
|
||||
|
||||
var app string
|
||||
switch {
|
||||
case strings.Contains(ua, "Edg/"):
|
||||
app = "Edge"
|
||||
case strings.Contains(ua, "Chrome/"):
|
||||
app = "Chrome"
|
||||
case strings.Contains(ua, "Firefox/"):
|
||||
app = "Firefox"
|
||||
case strings.Contains(ua, "Safari/") && !strings.Contains(ua, "Chrome"):
|
||||
app = "Safari"
|
||||
case strings.Contains(ua, "Dart") || strings.Contains(ua, "okhttp"):
|
||||
app = "Car Control app"
|
||||
}
|
||||
|
||||
switch {
|
||||
case app != "" && os != "":
|
||||
return app + " on " + os
|
||||
case app != "":
|
||||
return app
|
||||
case os != "":
|
||||
return os
|
||||
case len(ua) > 60:
|
||||
return ua[:60]
|
||||
default:
|
||||
return ua
|
||||
}
|
||||
}
|
||||
|
||||
// currentUserID returns the authenticated user's id from the request context.
|
||||
// Returns "" only if called on an unauthenticated request (withAuth already
|
||||
// guards every non-public path, so handlers can treat "" as "not authenticated").
|
||||
func (s *Server) currentUserID(r *http.Request) string {
|
||||
if claims, ok := r.Context().Value(claimsKey).(*auth.Claims); ok {
|
||||
return claims.Sub
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleMe returns the authenticated user from the token claims.
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, userInfo{ID: claims.Sub, Email: claims.Email, Name: claims.Name, Role: claims.Role})
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"carcontrol/api/internal/models"
|
||||
)
|
||||
|
||||
// Access levels a user can have on a car. accessNone means no access at all.
|
||||
const (
|
||||
accessNone = ""
|
||||
accessRead = "read"
|
||||
accessWrite = "write"
|
||||
accessOwner = "owner"
|
||||
)
|
||||
|
||||
// carAccessLevel reports the requesting user's permission on a car: "owner" if
|
||||
// they own it, otherwise the permission from any car_shares grant ("read"/
|
||||
// "write"), otherwise "" (no access). It also returns the car record so callers
|
||||
// that already need it avoid a second fetch.
|
||||
func (s *Server) carAccessLevel(ctx context.Context, userID, carID string) (string, *carRecord, error) {
|
||||
var rec carRecord
|
||||
if err := s.pb.GetOne(ctx, colCars, carID, &rec); err != nil {
|
||||
return accessNone, nil, err
|
||||
}
|
||||
if rec.Owner == userID {
|
||||
return accessOwner, &rec, nil
|
||||
}
|
||||
perm, err := s.sharePermission(ctx, carID, userID)
|
||||
if err != nil {
|
||||
return accessNone, &rec, err
|
||||
}
|
||||
return perm, &rec, nil
|
||||
}
|
||||
|
||||
// sharePermission returns the permission ("read"/"write") granted to userID on
|
||||
// carID via car_shares, or "" if there is no grant.
|
||||
func (s *Server) sharePermission(ctx context.Context, carID, userID string) (string, error) {
|
||||
res, err := s.pb.List(ctx, colShares, url.Values{
|
||||
"filter": {fmt.Sprintf("car='%s' && user='%s'", carID, userID)},
|
||||
"perPage": {"1"},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var recs []shareRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 {
|
||||
return "", err
|
||||
}
|
||||
return recs[0].Permission, nil
|
||||
}
|
||||
|
||||
func canWrite(level string) bool { return level == accessOwner || level == accessWrite }
|
||||
|
||||
// requireCarAccess enforces that the current user's access to carID meets the
|
||||
// minimum `need` (accessRead = any access, accessWrite = write or owner,
|
||||
// accessOwner = owner only). On failure it writes the HTTP response and returns
|
||||
// false, so callers can `if !s.requireCarAccess(...) { return }`.
|
||||
func (s *Server) requireCarAccess(w http.ResponseWriter, r *http.Request, carID, need string) bool {
|
||||
if carID == "" {
|
||||
writeError(w, http.StatusBadRequest, "car is required")
|
||||
return false
|
||||
}
|
||||
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), carID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return false
|
||||
}
|
||||
var ok bool
|
||||
switch need {
|
||||
case accessWrite:
|
||||
ok = canWrite(level)
|
||||
case accessOwner:
|
||||
ok = level == accessOwner
|
||||
default: // accessRead / any
|
||||
ok = level != accessNone
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusForbidden, "you do not have access to this car")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) listCars(w http.ResponseWriter, r *http.Request) {
|
||||
me := s.currentUserID(r)
|
||||
|
||||
// Cars the user owns.
|
||||
ownedRes, err := s.pb.List(r.Context(), colCars, url.Values{
|
||||
"filter": {fmt.Sprintf("owner='%s'", me)},
|
||||
"sort": {"name"},
|
||||
"perPage": {"200"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var owned []carRecord
|
||||
if err := json.Unmarshal(ownedRes.Items, &owned); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]models.Car, 0, len(owned))
|
||||
for _, rec := range owned {
|
||||
m := rec.toModel()
|
||||
m.Access = accessOwner
|
||||
out = append(out, m)
|
||||
}
|
||||
|
||||
// Cars shared with the user (each grant → fetch the car, annotate access).
|
||||
sharesRes, err := s.pb.List(r.Context(), colShares, url.Values{
|
||||
"filter": {fmt.Sprintf("user='%s'", me)},
|
||||
"perPage": {"200"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var shares []shareRecord
|
||||
if err := json.Unmarshal(sharesRes.Items, &shares); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, sh := range shares {
|
||||
var rec carRecord
|
||||
if err := s.pb.GetOne(r.Context(), colCars, sh.Car, &rec); err != nil {
|
||||
continue // grant points at a deleted car; skip defensively
|
||||
}
|
||||
m := rec.toModel()
|
||||
m.Access = sh.Permission
|
||||
out = append(out, m)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) getCar(w http.ResponseWriter, r *http.Request) {
|
||||
level, rec, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if level == accessNone {
|
||||
writeError(w, http.StatusForbidden, "you do not have access to this car")
|
||||
return
|
||||
}
|
||||
m := rec.toModel()
|
||||
m.Access = level
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (s *Server) createCar(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.Car
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
applyCarDefaults(&in)
|
||||
|
||||
// Owner is always the authenticated user; ignore any client-supplied owner.
|
||||
payload := carPayload(in)
|
||||
payload["owner"] = s.currentUserID(r)
|
||||
|
||||
var rec carRecord
|
||||
if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
m := rec.toModel()
|
||||
m.Access = accessOwner
|
||||
writeJSON(w, http.StatusCreated, m)
|
||||
}
|
||||
|
||||
func (s *Server) updateCar(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.Car
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !canWrite(level) {
|
||||
writeError(w, http.StatusForbidden, "you cannot edit this car")
|
||||
return
|
||||
}
|
||||
// carPayload deliberately omits owner, so a PATCH never reassigns ownership.
|
||||
var rec carRecord
|
||||
if err := s.pb.Update(r.Context(), colCars, r.PathValue("id"), carPayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
m := rec.toModel()
|
||||
m.Access = level
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (s *Server) deleteCar(w http.ResponseWriter, r *http.Request) {
|
||||
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if level != accessOwner {
|
||||
writeError(w, http.StatusForbidden, "only the owner can delete this car")
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), colCars, r.PathValue("id")); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// applyCarDefaults fills the spreadsheet's default maintenance intervals when
|
||||
// the client didn't specify them.
|
||||
func applyCarDefaults(c *models.Car) {
|
||||
if c.ServiceIntervalDays <= 0 {
|
||||
c.ServiceIntervalDays = 365
|
||||
}
|
||||
if c.ServiceIntervalKm <= 0 {
|
||||
c.ServiceIntervalKm = 15000
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<rect width="256" height="256" rx="56" fill="#1E40AF"></rect>
|
||||
<svg x="53" y="53" width="150" height="150" viewBox="0 0 48 48">
|
||||
<g transform="translate(7 0) skewX(-13)">
|
||||
<rect x="9" y="16" width="6" height="16" rx="3" fill="#ffffff" opacity="0.55"></rect>
|
||||
<rect x="19" y="12" width="6" height="24" rx="3" fill="#ffffff" opacity="0.8"></rect>
|
||||
<rect x="29" y="8" width="6" height="32" rx="3" fill="#ffffff"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 550 B |
+15
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<title>DriverVault · API Server</title>
|
||||
<script type="module" crossorigin src="/assets/index-o_I931vi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DfVJ8vbT.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The DriverVault API panel: a Vue 3 + Tailwind app (source in panel/, built with
|
||||
// `npm run build` into internal/api/dist) embedded at compile time and served
|
||||
// at the server root. It shows a live health status and the REST reference.
|
||||
//
|
||||
//go:embed all:dist
|
||||
var panelFS embed.FS
|
||||
|
||||
// panelHandler serves the built panel assets from the embedded dist directory.
|
||||
func panelHandler() http.Handler {
|
||||
sub, err := fs.Sub(panelFS, "dist")
|
||||
if err != nil {
|
||||
panic(err) // embedded dist is malformed; unreachable in a valid build
|
||||
}
|
||||
return http.FileServerFS(sub)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"carcontrol/api/internal/models"
|
||||
)
|
||||
|
||||
func (s *Server) listParts(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.URL.Query().Get("car")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sort", "name")
|
||||
q.Set("perPage", "500")
|
||||
q.Set("filter", fmt.Sprintf("car='%s'", carID))
|
||||
s.respondPartList(w, r, q)
|
||||
}
|
||||
|
||||
// listCarParts serves GET /api/cars/{id}/parts.
|
||||
func (s *Server) listCarParts(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.PathValue("id")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sort", "name")
|
||||
q.Set("perPage", "500")
|
||||
q.Set("filter", fmt.Sprintf("car='%s'", carID))
|
||||
s.respondPartList(w, r, q)
|
||||
}
|
||||
|
||||
func (s *Server) respondPartList(w http.ResponseWriter, r *http.Request, q url.Values) {
|
||||
res, err := s.pb.List(r.Context(), colParts, q)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []partRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]models.Part, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, rec.toModel())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) getPart(w http.ResponseWriter, r *http.Request) {
|
||||
var rec partRecord
|
||||
if err := s.pb.GetOne(r.Context(), colParts, r.PathValue("id"), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, rec.Car, accessRead) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec.toModel())
|
||||
}
|
||||
|
||||
func (s *Server) createPart(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.Part
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Car == "" || in.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "car and name are required")
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
var rec partRecord
|
||||
if err := s.pb.Create(r.Context(), colParts, partPayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, rec.toModel())
|
||||
}
|
||||
|
||||
func (s *Server) updatePart(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.Part
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var existing partRecord
|
||||
if err := s.pb.GetOne(r.Context(), colParts, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
var rec partRecord
|
||||
if err := s.pb.Update(r.Context(), colParts, r.PathValue("id"), partPayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec.toModel())
|
||||
}
|
||||
|
||||
func (s *Server) deletePart(w http.ResponseWriter, r *http.Request) {
|
||||
var existing partRecord
|
||||
if err := s.pb.GetOne(r.Context(), colParts, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), colParts, r.PathValue("id")); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/models"
|
||||
)
|
||||
|
||||
// PocketBase stores datetimes as e.g. "2015-06-12 00:00:00.000Z". These layouts
|
||||
// are tried (in order) when parsing values coming back from PocketBase.
|
||||
var pbDateLayouts = []string{
|
||||
"2006-01-02 15:04:05.000Z",
|
||||
"2006-01-02 15:04:05Z",
|
||||
time.RFC3339,
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
func parsePBDate(s string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, l := range pbDateLayouts {
|
||||
if t, err := time.Parse(l, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// formatPBDate renders a date in the format PocketBase expects on write.
|
||||
func formatPBDate(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04:05.000Z")
|
||||
}
|
||||
|
||||
// --- cars ---
|
||||
|
||||
// carRecord is the PocketBase-facing shape of a car (snake_case fields).
|
||||
type carRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Make string `json:"make"`
|
||||
Model string `json:"model"`
|
||||
Year int `json:"year"`
|
||||
Registration string `json:"registration"`
|
||||
RegistrationCountry string `json:"registration_country"`
|
||||
VIN string `json:"vin"`
|
||||
ServiceIntervalDays int `json:"service_interval_days"`
|
||||
ServiceIntervalKm int `json:"service_interval_km"`
|
||||
OilSpec string `json:"oil_spec"`
|
||||
TransmissionOilSpec string `json:"transmission_oil_spec"`
|
||||
DifferentialOilSpec string `json:"differential_oil_spec"`
|
||||
BrakeFluidSpec string `json:"brake_fluid_spec"`
|
||||
CoolantSpec string `json:"coolant_spec"`
|
||||
CurrentKm int `json:"current_km"`
|
||||
FuelType string `json:"fuel_type"`
|
||||
BuildDate string `json:"build_date"`
|
||||
FirstRegistrationDate string `json:"first_registration_date"`
|
||||
Owner string `json:"owner"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (rec carRecord) toModel() models.Car {
|
||||
return models.Car{
|
||||
ID: rec.ID,
|
||||
Name: rec.Name,
|
||||
Make: rec.Make,
|
||||
Model: rec.Model,
|
||||
Year: rec.Year,
|
||||
Registration: rec.Registration,
|
||||
RegistrationCountry: rec.RegistrationCountry,
|
||||
VIN: rec.VIN,
|
||||
ServiceIntervalDays: rec.ServiceIntervalDays,
|
||||
ServiceIntervalKm: rec.ServiceIntervalKm,
|
||||
OilSpec: rec.OilSpec,
|
||||
TransmissionOilSpec: rec.TransmissionOilSpec,
|
||||
DifferentialOilSpec: rec.DifferentialOilSpec,
|
||||
BrakeFluidSpec: rec.BrakeFluidSpec,
|
||||
CoolantSpec: rec.CoolantSpec,
|
||||
CurrentKm: rec.CurrentKm,
|
||||
FuelType: rec.FuelType,
|
||||
BuildDate: rec.BuildDate,
|
||||
FirstRegistrationDate: rec.FirstRegistrationDate,
|
||||
Owner: rec.Owner,
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
// carPayload builds the write payload for create/update from a domain Car.
|
||||
func carPayload(c models.Car) map[string]any {
|
||||
return map[string]any{
|
||||
"name": c.Name,
|
||||
"make": c.Make,
|
||||
"model": c.Model,
|
||||
"year": c.Year,
|
||||
"registration": c.Registration,
|
||||
"registration_country": c.RegistrationCountry,
|
||||
"vin": c.VIN,
|
||||
"service_interval_days": c.ServiceIntervalDays,
|
||||
"service_interval_km": c.ServiceIntervalKm,
|
||||
"oil_spec": c.OilSpec,
|
||||
"transmission_oil_spec": c.TransmissionOilSpec,
|
||||
"differential_oil_spec": c.DifferentialOilSpec,
|
||||
"brake_fluid_spec": c.BrakeFluidSpec,
|
||||
"coolant_spec": c.CoolantSpec,
|
||||
"current_km": c.CurrentKm,
|
||||
"fuel_type": c.FuelType,
|
||||
"build_date": c.BuildDate,
|
||||
"first_registration_date": c.FirstRegistrationDate,
|
||||
}
|
||||
}
|
||||
|
||||
// --- service records ---
|
||||
|
||||
type serviceRecord struct {
|
||||
ID string `json:"id"`
|
||||
Car string `json:"car"`
|
||||
Date string `json:"date"`
|
||||
Km int `json:"km"`
|
||||
ChangedOil bool `json:"changed_oil"`
|
||||
ChangedEngineAirFilter bool `json:"changed_engine_air_filter"`
|
||||
ChangedCabinAirFilter bool `json:"changed_cabin_air_filter"`
|
||||
Notes string `json:"notes"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (rec serviceRecord) toModel() models.ServiceRecord {
|
||||
return models.ServiceRecord{
|
||||
ID: rec.ID,
|
||||
Car: rec.Car,
|
||||
Date: parsePBDate(rec.Date),
|
||||
Km: rec.Km,
|
||||
ChangedOil: rec.ChangedOil,
|
||||
ChangedEngineAirFilter: rec.ChangedEngineAirFilter,
|
||||
ChangedCabinAirFilter: rec.ChangedCabinAirFilter,
|
||||
Notes: rec.Notes,
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
func servicePayload(r models.ServiceRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"car": r.Car,
|
||||
"date": formatPBDate(r.Date),
|
||||
"km": r.Km,
|
||||
"changed_oil": r.ChangedOil,
|
||||
"changed_engine_air_filter": r.ChangedEngineAirFilter,
|
||||
"changed_cabin_air_filter": r.ChangedCabinAirFilter,
|
||||
"notes": r.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// --- parts ---
|
||||
|
||||
type partRecord struct {
|
||||
ID string `json:"id"`
|
||||
Car string `json:"car"`
|
||||
Name string `json:"name"`
|
||||
PartNumber string `json:"part_number"`
|
||||
Category string `json:"category"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (rec partRecord) toModel() models.Part {
|
||||
return models.Part{
|
||||
ID: rec.ID,
|
||||
Car: rec.Car,
|
||||
Name: rec.Name,
|
||||
PartNumber: rec.PartNumber,
|
||||
Category: rec.Category,
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
func partPayload(p models.Part) map[string]any {
|
||||
return map[string]any{
|
||||
"car": p.Car,
|
||||
"name": p.Name,
|
||||
"part_number": p.PartNumber,
|
||||
"category": p.Category,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Package api exposes the HTTP REST surface of the car-control API Server.
|
||||
//
|
||||
// Clients (web app, phone app, ...) talk only to this server; this server is
|
||||
// the only thing that talks to PocketBase. Endpoints:
|
||||
//
|
||||
// GET /api/health
|
||||
// GET /api/cars
|
||||
// POST /api/cars
|
||||
// GET /api/cars/{id}
|
||||
// PATCH /api/cars/{id}
|
||||
// DELETE /api/cars/{id}
|
||||
// GET /api/cars/{id}/service-records
|
||||
// GET /api/cars/{id}/parts
|
||||
// GET /api/cars/{id}/shares
|
||||
// POST /api/cars/{id}/shares
|
||||
// DELETE /api/cars/{id}/shares/{userId}
|
||||
// GET /api/service-records
|
||||
// POST /api/service-records
|
||||
// GET /api/service-records/{id}
|
||||
// PATCH /api/service-records/{id}
|
||||
// DELETE /api/service-records/{id}
|
||||
// GET /api/parts
|
||||
// POST /api/parts
|
||||
// GET /api/parts/{id}
|
||||
// PATCH /api/parts/{id}
|
||||
// DELETE /api/parts/{id}
|
||||
// GET /api/me
|
||||
// PATCH /api/me
|
||||
// DELETE /api/me
|
||||
// POST /api/me/password
|
||||
// POST /api/me/avatar
|
||||
// GET /api/me/avatar
|
||||
// DELETE /api/me/avatar
|
||||
// POST /api/me/verify/request
|
||||
// GET /api/me/export
|
||||
// POST /api/me/import
|
||||
// POST /api/me/delete
|
||||
// POST /api/me/delete/cancel
|
||||
// GET /api/sessions
|
||||
// DELETE /api/sessions/{id}
|
||||
// DELETE /api/sessions
|
||||
// GET /api/admin/users
|
||||
// POST /api/admin/users
|
||||
// PATCH /api/admin/users/{id}
|
||||
// POST /api/admin/users/{id}/password
|
||||
// DELETE /api/admin/users/{id}
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/pb"
|
||||
)
|
||||
|
||||
// PocketBase collection names.
|
||||
const (
|
||||
colCars = "cars"
|
||||
colServices = "service_records"
|
||||
colParts = "parts"
|
||||
colSessions = "sessions"
|
||||
colShares = "car_shares"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
pb *pb.Client
|
||||
corsOrigins map[string]bool
|
||||
authSecret string
|
||||
usersCollection string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
CORSOrigins []string
|
||||
AuthSecret string
|
||||
UsersCollection string
|
||||
}
|
||||
|
||||
func NewServer(client *pb.Client, opts Options) *Server {
|
||||
set := make(map[string]bool, len(opts.CORSOrigins))
|
||||
for _, o := range opts.CORSOrigins {
|
||||
set[o] = true
|
||||
}
|
||||
return &Server{
|
||||
pb: client,
|
||||
corsOrigins: set,
|
||||
authSecret: opts.AuthSecret,
|
||||
usersCollection: opts.UsersCollection,
|
||||
}
|
||||
}
|
||||
|
||||
// Handler builds the routed, CORS-wrapped HTTP handler (Go 1.22 ServeMux).
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// DriverVault web panel (public) — embedded Vue + Tailwind app served at the
|
||||
// root. Only explicit panel paths are routed to it, so unknown /api/* paths
|
||||
// still 404 as JSON rather than serving the SPA shell.
|
||||
panel := panelHandler()
|
||||
mux.Handle("GET /{$}", panel)
|
||||
mux.Handle("GET /assets/", panel)
|
||||
mux.Handle("GET /favicon.svg", panel)
|
||||
|
||||
mux.HandleFunc("GET /api/health", s.handleHealth)
|
||||
|
||||
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
||||
mux.HandleFunc("GET /api/auth/me", s.handleMe)
|
||||
|
||||
mux.HandleFunc("GET /api/me", s.handleGetMe)
|
||||
mux.HandleFunc("PATCH /api/me", s.handleUpdateMe)
|
||||
mux.HandleFunc("POST /api/me/password", s.handleChangePassword)
|
||||
mux.HandleFunc("POST /api/me/avatar", s.handleUploadAvatar)
|
||||
mux.HandleFunc("GET /api/me/avatar", s.handleGetAvatar)
|
||||
mux.HandleFunc("DELETE /api/me/avatar", s.handleDeleteAvatar)
|
||||
mux.HandleFunc("POST /api/me/verify/request", s.handleRequestVerification)
|
||||
mux.HandleFunc("GET /api/me/export", s.handleExportData)
|
||||
mux.HandleFunc("POST /api/me/import", s.handleImportData)
|
||||
mux.HandleFunc("POST /api/me/delete", s.handleRequestDeletion)
|
||||
mux.HandleFunc("POST /api/me/delete/cancel", s.handleCancelDeletion)
|
||||
mux.HandleFunc("DELETE /api/me", s.handleFinalizeDeletion)
|
||||
|
||||
mux.HandleFunc("GET /api/sessions", s.handleListSessions)
|
||||
mux.HandleFunc("DELETE /api/sessions/{id}", s.handleRevokeSession)
|
||||
mux.HandleFunc("DELETE /api/sessions", s.handleRevokeOtherSessions)
|
||||
|
||||
mux.HandleFunc("GET /api/admin/users", s.handleListUsers)
|
||||
mux.HandleFunc("POST /api/admin/users", s.handleCreateUser)
|
||||
mux.HandleFunc("PATCH /api/admin/users/{id}", s.handleUpdateUser)
|
||||
mux.HandleFunc("POST /api/admin/users/{id}/password", s.handleSetUserPassword)
|
||||
mux.HandleFunc("DELETE /api/admin/users/{id}", s.handleDeleteUser)
|
||||
|
||||
mux.HandleFunc("GET /api/cars", s.listCars)
|
||||
mux.HandleFunc("POST /api/cars", s.createCar)
|
||||
mux.HandleFunc("GET /api/cars/{id}", s.getCar)
|
||||
mux.HandleFunc("PATCH /api/cars/{id}", s.updateCar)
|
||||
mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar)
|
||||
mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords)
|
||||
mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts)
|
||||
|
||||
mux.HandleFunc("GET /api/cars/{id}/shares", s.handleListShares)
|
||||
mux.HandleFunc("POST /api/cars/{id}/shares", s.handleUpsertShare)
|
||||
mux.HandleFunc("DELETE /api/cars/{id}/shares/{userId}", s.handleDeleteShare)
|
||||
|
||||
mux.HandleFunc("GET /api/service-records", s.listServiceRecords)
|
||||
mux.HandleFunc("POST /api/service-records", s.createServiceRecord)
|
||||
mux.HandleFunc("GET /api/service-records/{id}", s.getServiceRecord)
|
||||
mux.HandleFunc("PATCH /api/service-records/{id}", s.updateServiceRecord)
|
||||
mux.HandleFunc("DELETE /api/service-records/{id}", s.deleteServiceRecord)
|
||||
|
||||
mux.HandleFunc("GET /api/parts", s.listParts)
|
||||
mux.HandleFunc("POST /api/parts", s.createPart)
|
||||
mux.HandleFunc("GET /api/parts/{id}", s.getPart)
|
||||
mux.HandleFunc("PATCH /api/parts/{id}", s.updatePart)
|
||||
mux.HandleFunc("DELETE /api/parts/{id}", s.deletePart)
|
||||
|
||||
return s.withCORS(s.withLogging(s.withAuth(mux)))
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// --- middleware ---
|
||||
|
||||
func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && (s.corsOrigins[origin] || s.corsOrigins["*"]) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// writePBError maps a PocketBase error to an appropriate HTTP status.
|
||||
func writePBError(w http.ResponseWriter, err error) {
|
||||
if apiErr, ok := err.(*pb.APIError); ok {
|
||||
status := apiErr.Status
|
||||
if status < 400 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
writeError(w, status, apiErr.Body)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dest any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dest)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"carcontrol/api/internal/models"
|
||||
)
|
||||
|
||||
func (s *Server) listServiceRecords(w http.ResponseWriter, r *http.Request) {
|
||||
// Records are always scoped to a single car so access can be enforced; a
|
||||
// cross-car listing would leak other users' data.
|
||||
carID := r.URL.Query().Get("car")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sort", "-date") // most-recent service first
|
||||
q.Set("perPage", "500")
|
||||
q.Set("filter", fmt.Sprintf("car='%s'", carID))
|
||||
s.respondServiceList(w, r, q)
|
||||
}
|
||||
|
||||
// listCarServiceRecords serves GET /api/cars/{id}/service-records.
|
||||
func (s *Server) listCarServiceRecords(w http.ResponseWriter, r *http.Request) {
|
||||
carID := r.PathValue("id")
|
||||
if !s.requireCarAccess(w, r, carID, accessRead) {
|
||||
return
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sort", "-date")
|
||||
q.Set("perPage", "500")
|
||||
q.Set("filter", fmt.Sprintf("car='%s'", carID))
|
||||
s.respondServiceList(w, r, q)
|
||||
}
|
||||
|
||||
func (s *Server) respondServiceList(w http.ResponseWriter, r *http.Request, q url.Values) {
|
||||
res, err := s.pb.List(r.Context(), colServices, q)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []serviceRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cars := newCarCache(s)
|
||||
out := make([]models.ServiceRecord, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
m := rec.toModel()
|
||||
if car, err := cars.get(r.Context(), m.Car); err == nil {
|
||||
m.ComputeDerived(car)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) getServiceRecord(w http.ResponseWriter, r *http.Request) {
|
||||
var rec serviceRecord
|
||||
if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
m := rec.toModel()
|
||||
if !s.requireCarAccess(w, r, m.Car, accessRead) {
|
||||
return
|
||||
}
|
||||
if car, err := s.carModel(r.Context(), m.Car); err == nil {
|
||||
m.ComputeDerived(car)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (s *Server) createServiceRecord(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.ServiceRecord
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Car == "" {
|
||||
writeError(w, http.StatusBadRequest, "car is required")
|
||||
return
|
||||
}
|
||||
if in.Date.IsZero() {
|
||||
writeError(w, http.StatusBadRequest, "date is required")
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
|
||||
var rec serviceRecord
|
||||
if err := s.pb.Create(r.Context(), colServices, servicePayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
m := rec.toModel()
|
||||
if car, err := s.carModel(r.Context(), m.Car); err == nil {
|
||||
m.ComputeDerived(car)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, m)
|
||||
}
|
||||
|
||||
func (s *Server) updateServiceRecord(w http.ResponseWriter, r *http.Request) {
|
||||
var in models.ServiceRecord
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Enforce write access via the record's existing parent car (the body's
|
||||
// car field, if any, can't be used to escape into another user's car).
|
||||
var existing serviceRecord
|
||||
if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
var rec serviceRecord
|
||||
if err := s.pb.Update(r.Context(), colServices, r.PathValue("id"), servicePayload(in), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
m := rec.toModel()
|
||||
if car, err := s.carModel(r.Context(), m.Car); err == nil {
|
||||
m.ComputeDerived(car)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (s *Server) deleteServiceRecord(w http.ResponseWriter, r *http.Request) {
|
||||
var existing serviceRecord
|
||||
if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), colServices, r.PathValue("id")); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// carModel fetches a single car as a domain model.
|
||||
func (s *Server) carModel(ctx context.Context, id string) (*models.Car, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("empty car id")
|
||||
}
|
||||
var rec carRecord
|
||||
if err := s.pb.GetOne(ctx, colCars, id, &rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := rec.toModel()
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// carCache memoizes car lookups within a single list request to avoid N+1
|
||||
// fetches when many service records share the same car.
|
||||
type carCache struct {
|
||||
s *Server
|
||||
cache map[string]*models.Car
|
||||
}
|
||||
|
||||
func newCarCache(s *Server) *carCache {
|
||||
return &carCache{s: s, cache: map[string]*models.Car{}}
|
||||
}
|
||||
|
||||
func (c *carCache) get(ctx context.Context, id string) (*models.Car, error) {
|
||||
if car, ok := c.cache[id]; ok {
|
||||
return car, nil
|
||||
}
|
||||
car, err := c.s.carModel(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.cache[id] = car
|
||||
return car, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"carcontrol/api/internal/auth"
|
||||
"carcontrol/api/internal/models"
|
||||
)
|
||||
|
||||
// handleListSessions lists the current user's active (non-revoked) logins,
|
||||
// flagging which one is the request being made right now.
|
||||
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.pb.List(r.Context(), colSessions, url.Values{
|
||||
"filter": {fmt.Sprintf("user='%s' && revoked=false", claims.Sub)},
|
||||
"sort": {"-created"},
|
||||
"perPage": {"50"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []sessionRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]models.Session, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, models.Session{
|
||||
ID: rec.ID,
|
||||
DeviceLabel: rec.DeviceLabel,
|
||||
IP: rec.IP,
|
||||
Current: rec.Jti == claims.Jti,
|
||||
Created: parsePBDate(rec.Created),
|
||||
ExpiresAt: parsePBDate(rec.ExpiresAt),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleRevokeSession logs out one specific device (including possibly the
|
||||
// current one, same as an ordinary logout).
|
||||
func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
var rec sessionRecord
|
||||
if err := s.pb.GetOne(r.Context(), colSessions, id, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if rec.User != claims.Sub {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
if err := s.pb.Update(r.Context(), colSessions, id, map[string]any{"revoked": true}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRevokeOtherSessions logs out every device except the one making this
|
||||
// request ("log out everywhere else").
|
||||
func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.pb.List(r.Context(), colSessions, url.Values{
|
||||
"filter": {fmt.Sprintf("user='%s' && revoked=false && jti!='%s'", claims.Sub, claims.Jti)},
|
||||
"perPage": {"200"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []sessionRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, rec := range recs {
|
||||
if err := s.pb.Update(r.Context(), colSessions, rec.ID, map[string]any{"revoked": true}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"revoked": len(recs)})
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// shareRecord is the PocketBase-facing shape of a car_shares row: a grant that
|
||||
// lets `user` access `car` at `permission` ("read"/"write").
|
||||
type shareRecord struct {
|
||||
ID string `json:"id"`
|
||||
Car string `json:"car"`
|
||||
User string `json:"user"`
|
||||
Permission string `json:"permission"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// shareView is the API shape returned to clients: the grantee's public identity
|
||||
// plus their permission on the car.
|
||||
type shareView struct {
|
||||
User userInfo `json:"user"`
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
// requireCarOwner ensures the current user owns the car in the {id} path param.
|
||||
// Sharing management is owner-only. Returns the car id on success, else writes
|
||||
// the response and returns "".
|
||||
func (s *Server) requireCarOwner(w http.ResponseWriter, r *http.Request) string {
|
||||
carID := r.PathValue("id")
|
||||
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), carID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return ""
|
||||
}
|
||||
if level != accessOwner {
|
||||
writeError(w, http.StatusForbidden, "only the owner can manage sharing")
|
||||
return ""
|
||||
}
|
||||
return carID
|
||||
}
|
||||
|
||||
// handleListShares serves GET /api/cars/{id}/shares (owner only).
|
||||
func (s *Server) handleListShares(w http.ResponseWriter, r *http.Request) {
|
||||
carID := s.requireCarOwner(w, r)
|
||||
if carID == "" {
|
||||
return
|
||||
}
|
||||
res, err := s.pb.List(r.Context(), colShares, url.Values{
|
||||
"filter": {fmt.Sprintf("car='%s'", carID)},
|
||||
"perPage": {"200"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []shareRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]shareView, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
u, err := s.fetchUser(r, rec.User)
|
||||
if err != nil {
|
||||
continue // grantee user was deleted; skip defensively
|
||||
}
|
||||
out = append(out, shareView{
|
||||
User: userInfo{ID: u.ID, Email: u.Email, Name: u.Name},
|
||||
Permission: rec.Permission,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type shareRequest struct {
|
||||
Email string `json:"email"`
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
// handleUpsertShare serves POST /api/cars/{id}/shares (owner only). It grants
|
||||
// or updates a share for the user identified by email. Body: {email,
|
||||
// permission}. Idempotent: an existing grant for that user is updated.
|
||||
func (s *Server) handleUpsertShare(w http.ResponseWriter, r *http.Request) {
|
||||
carID := s.requireCarOwner(w, r)
|
||||
if carID == "" {
|
||||
return
|
||||
}
|
||||
var in shareRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
in.Email = strings.TrimSpace(strings.ToLower(in.Email))
|
||||
if in.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "email is required")
|
||||
return
|
||||
}
|
||||
if in.Permission != accessRead && in.Permission != accessWrite {
|
||||
writeError(w, http.StatusBadRequest, "permission must be 'read' or 'write'")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := s.findUserByEmail(r, in.Email)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if target == nil {
|
||||
writeError(w, http.StatusNotFound, "no user with that email")
|
||||
return
|
||||
}
|
||||
if target.ID == s.currentUserID(r) {
|
||||
writeError(w, http.StatusBadRequest, "you already own this car")
|
||||
return
|
||||
}
|
||||
|
||||
// Upsert: update the existing grant's permission, else create a new one.
|
||||
existing, err := s.findShare(r, carID, target.ID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
payload := map[string]any{"car": carID, "user": target.ID, "permission": in.Permission}
|
||||
if existing != nil {
|
||||
if err := s.pb.Update(r.Context(), colShares, existing.ID, payload, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
} else if err := s.pb.Create(r.Context(), colShares, payload, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, shareView{
|
||||
User: userInfo{ID: target.ID, Email: target.Email, Name: target.Name},
|
||||
Permission: in.Permission,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteShare serves DELETE /api/cars/{id}/shares/{userId} (owner only).
|
||||
func (s *Server) handleDeleteShare(w http.ResponseWriter, r *http.Request) {
|
||||
carID := s.requireCarOwner(w, r)
|
||||
if carID == "" {
|
||||
return
|
||||
}
|
||||
existing, err := s.findShare(r, carID, r.PathValue("userId"))
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
w.WriteHeader(http.StatusNoContent) // already not shared — idempotent
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), colShares, existing.ID); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// findShare returns the car_shares grant for (carID, userID), or nil if none.
|
||||
func (s *Server) findShare(r *http.Request, carID, userID string) (*shareRecord, error) {
|
||||
res, err := s.pb.List(r.Context(), colShares, url.Values{
|
||||
"filter": {fmt.Sprintf("car='%s' && user='%s'", carID, userID)},
|
||||
"perPage": {"1"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var recs []shareRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &recs[0], nil
|
||||
}
|
||||
|
||||
// findUserByEmail looks up a user in the auth collection by email, returning nil
|
||||
// if none matches.
|
||||
func (s *Server) findUserByEmail(r *http.Request, email string) (*userRecord, error) {
|
||||
res, err := s.pb.List(r.Context(), s.usersCollection, url.Values{
|
||||
"filter": {fmt.Sprintf("email='%s'", strings.ReplaceAll(email, "'", ""))},
|
||||
"perPage": {"1"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var recs []userRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &recs[0], nil
|
||||
}
|
||||
Reference in New Issue
Block a user