226 lines
7.1 KiB
Go
226 lines
7.1 KiB
Go
// 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)
|
|
}
|