Mirror the PocketBase settings card with a superadmin-only Web App section: it edits the Web App base URL (the address /api/status already probed) and the CORS allowed origins, applying both at runtime and persisting them to .env so they survive a restart. The CORS middleware previously built its allow-list once, when Handler() was constructed, so an edited origin list would not have taken effect until a restart — which would have made the new field quietly lie. Move the lookup into a per-request originAllowed helper reading under the existing lock. Verified behaviour is unchanged: an allowed origin still gets the headers plus Vary: Origin, an unknown origin gets none, and preflight still returns 204. PUT rejects an empty origin list rather than silently keeping the old one, since an empty list would lock out every browser client. The authenticated round trip (loading and saving real settings) is not verified here — it needs a superadmin login. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
437 lines
18 KiB
Go
437 lines
18 KiB
Go
// Package api exposes the HTTP REST surface of the DriverVault API Server.
|
|
//
|
|
// Clients (web app, phone app, Home Assistant plugin, ESP32 device) talk only to
|
|
// this server; this server is the only thing that talks to PocketBase. It also
|
|
// serves the superadmin web panel at the root.
|
|
//
|
|
// Authentication is PocketBase's own: /api/auth/login is proxied to the
|
|
// PocketBase users collection and the client keeps the token PocketBase minted.
|
|
// Every protected request re-resolves that token against PocketBase, so a role
|
|
// change or a deletion takes effect immediately.
|
|
//
|
|
// # public
|
|
// GET /healthz
|
|
// GET /api/health
|
|
// GET /api/status
|
|
// POST /api/auth/login
|
|
// GET /api/auth/validate
|
|
//
|
|
// # identity
|
|
// GET /api/auth/me
|
|
// GET /api/identity
|
|
//
|
|
// # current user
|
|
// 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
|
|
//
|
|
// # users + organizations (manager; writes to orgs are superadmin-only)
|
|
// GET /api/users POST /api/users
|
|
// PATCH /api/users/{id} DELETE /api/users/{id}
|
|
// GET /api/orgs POST /api/orgs
|
|
// PATCH /api/orgs/{id} DELETE /api/orgs/{id}
|
|
//
|
|
// # superadmin
|
|
// GET /api/admin/pb-config PUT /api/admin/pb-config
|
|
// POST /api/admin/pb-config/test
|
|
// GET /api/admin/webapp-config PUT /api/admin/webapp-config
|
|
// POST /api/admin/webapp-config/test
|
|
// GET /api/admin/plugins POST /api/admin/plugins
|
|
// GET /api/admin/plugins/{name} PUT /api/admin/plugins/{name}
|
|
// DELETE /api/admin/plugins/{name} POST /api/admin/plugins/{name}/health
|
|
//
|
|
// # cars, service records, parts, shares
|
|
// 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}
|
|
//
|
|
// # technical checks (roadworthiness inspections; time-only, next date derived)
|
|
// GET /api/cars/{id}/technical-checks
|
|
// GET /api/technical-checks POST /api/technical-checks
|
|
// GET /api/technical-checks/{id} PATCH /api/technical-checks/{id}
|
|
// DELETE /api/technical-checks/{id}
|
|
//
|
|
// # fuel tracking (efficiency is derived on read, never stored)
|
|
// GET /api/cars/{id}/fuel-entries
|
|
// GET /api/cars/{id}/fuel-stats
|
|
// GET /api/fuel-entries POST /api/fuel-entries
|
|
// GET /api/fuel-entries/{id} PATCH /api/fuel-entries/{id}
|
|
// DELETE /api/fuel-entries/{id}
|
|
//
|
|
// # maintenance log (workshop visits + repairs; distinct from service records)
|
|
// GET /api/cars/{id}/maintenance
|
|
// GET /api/maintenance POST /api/maintenance
|
|
// GET /api/maintenance/{id} PATCH /api/maintenance/{id}
|
|
// DELETE /api/maintenance/{id}
|
|
//
|
|
// # document tracking (insurance, pollution certs, … + renewal dates)
|
|
// GET /api/cars/{id}/documents
|
|
// GET /api/car-documents POST /api/car-documents
|
|
// GET /api/car-documents/{id} PATCH /api/car-documents/{id}
|
|
// DELETE /api/car-documents/{id}
|
|
//
|
|
// # attachments — one optional file per record, same three verbs everywhere.
|
|
// # {records} is car-documents | service-records | technical-checks | maintenance
|
|
// # | fuel-entries | parts
|
|
// POST /api/{records}/{id}/file
|
|
// GET /api/{records}/{id}/file
|
|
// DELETE /api/{records}/{id}/file
|
|
//
|
|
// # reminders (stored + auto-derived from documents and service records)
|
|
// GET /api/cars/{id}/reminders
|
|
// GET /api/reminders POST /api/reminders
|
|
// GET /api/reminders/{id} PATCH /api/reminders/{id}
|
|
// DELETE /api/reminders/{id} POST /api/reminders/{id}/complete
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/config"
|
|
"drivervault/apiserver/internal/pb"
|
|
"drivervault/apiserver/internal/plugins"
|
|
_ "drivervault/apiserver/internal/plugins/builtin" // register built-in plugins
|
|
)
|
|
|
|
// PocketBase collection names.
|
|
const (
|
|
colCars = "cars"
|
|
colServices = "service_records"
|
|
colTechnicalChecks = "technical_checks"
|
|
colParts = "parts"
|
|
colShares = "car_shares"
|
|
colOrgs = "organizations"
|
|
colFuel = "fuel_entries"
|
|
colMaintenance = "maintenance_entries"
|
|
colDocuments = "car_documents"
|
|
colReminders = "reminders"
|
|
)
|
|
|
|
// Server wires together the HTTP handlers and their dependencies.
|
|
type Server struct {
|
|
mu sync.RWMutex // guards the mutable PocketBase connection in cfg
|
|
cfg config.Config
|
|
pb *pb.Client
|
|
plugins *plugins.Manager
|
|
}
|
|
|
|
// New constructs a Server around an already-built PocketBase client.
|
|
func New(cfg config.Config, client *pb.Client) *Server {
|
|
return &Server{
|
|
cfg: cfg,
|
|
pb: client,
|
|
plugins: plugins.NewManager(cfg.PluginsFile),
|
|
}
|
|
}
|
|
|
|
// StartPlugins loads persisted plugin state and initialises enabled plugins.
|
|
func (s *Server) StartPlugins() error { return s.plugins.Load() }
|
|
|
|
// Stop releases server-held resources (currently: plugin instances).
|
|
func (s *Server) Stop(ctx context.Context) { s.plugins.Shutdown(ctx) }
|
|
|
|
// usersCollection returns the PocketBase auth collection holding app users.
|
|
func (s *Server) usersCollection() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.UsersCollection
|
|
}
|
|
|
|
// webAppURL returns the Web App address probed by /api/status.
|
|
func (s *Server) webAppURL() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.WebAppURL
|
|
}
|
|
|
|
// webAppSettings snapshots the Web App settings for the settings endpoints.
|
|
func (s *Server) webAppSettings() (url string, allowOrigins []string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.WebAppURL, append([]string(nil), s.cfg.AllowOrigins...)
|
|
}
|
|
|
|
// setWebAppConfig applies new Web App settings at runtime. The CORS middleware
|
|
// reads the origin list per request, so the new list is live immediately.
|
|
func (s *Server) setWebAppConfig(url string, allowOrigins []string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cfg.WebAppURL = url
|
|
s.cfg.AllowOrigins = append([]string(nil), allowOrigins...)
|
|
}
|
|
|
|
// pbSettings snapshots the PocketBase connection for the settings endpoints.
|
|
func (s *Server) pbSettings() (url, adminEmail, adminPassword string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.PocketBaseURL, s.cfg.PocketBaseAdminEmail, s.cfg.PocketBaseAdminPassword
|
|
}
|
|
|
|
// setPBConfig retargets the PocketBase connection at runtime: it updates the
|
|
// cached config and repoints the client (which drops its cached superuser token,
|
|
// so the next call re-authenticates against the new target).
|
|
func (s *Server) setPBConfig(url, adminEmail, adminPassword string) {
|
|
s.mu.Lock()
|
|
s.cfg.PocketBaseURL = url
|
|
s.cfg.PocketBaseAdminEmail = adminEmail
|
|
s.cfg.PocketBaseAdminPassword = adminPassword
|
|
s.mu.Unlock()
|
|
|
|
s.pb.Reconfigure(url, adminEmail, adminPassword)
|
|
}
|
|
|
|
// Handler returns the root HTTP handler with all routes registered.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Web panel (public) — embedded Vue + Tailwind app. Only the explicit panel
|
|
// paths are routed to it so unknown /api/* paths still 404 as JSON.
|
|
panel := panelHandler()
|
|
mux.Handle("GET /{$}", panel)
|
|
mux.Handle("GET /assets/", panel)
|
|
mux.Handle("GET /favicon.svg", panel)
|
|
|
|
// Health (public).
|
|
mux.HandleFunc("GET /healthz", s.handleHealth)
|
|
mux.HandleFunc("GET /api/health", s.handleHealth)
|
|
mux.HandleFunc("GET /api/status", s.handleStatus)
|
|
|
|
// Auth — proxied to the PocketBase kept behind this server.
|
|
mux.HandleFunc("POST /api/auth/login", s.handleAuthLogin)
|
|
mux.HandleFunc("GET /api/auth/validate", s.handleAuthValidate)
|
|
mux.HandleFunc("GET /api/auth/me", s.handleAuthMe)
|
|
mux.HandleFunc("GET /api/identity", s.handleIdentity)
|
|
|
|
// Current user (profile / appearance / avatar / data / account lifecycle).
|
|
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)
|
|
|
|
// User management — gated on the caller being a manager (admin or
|
|
// superadmin). Admins are scoped to their own organization inside each
|
|
// handler.
|
|
mux.HandleFunc("GET /api/users", s.requireManager(s.handleListUsers))
|
|
mux.HandleFunc("POST /api/users", s.requireManager(s.handleCreateUser))
|
|
mux.HandleFunc("PATCH /api/users/{id}", s.requireManager(s.handleUpdateUser))
|
|
mux.HandleFunc("DELETE /api/users/{id}", s.requireManager(s.handleDeleteUser))
|
|
|
|
// Organizations — listing is manager-scoped; create/edit/delete are
|
|
// superadmin-only (a superadmin spans all organizations).
|
|
mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs))
|
|
mux.HandleFunc("POST /api/orgs", s.requireSuperadmin(s.handleCreateOrg))
|
|
mux.HandleFunc("PATCH /api/orgs/{id}", s.requireSuperadmin(s.handleUpdateOrg))
|
|
mux.HandleFunc("DELETE /api/orgs/{id}", s.requireSuperadmin(s.handleDeleteOrg))
|
|
|
|
// PocketBase connection settings — superadmin only. These do NOT require the
|
|
// service account to already be configured (they exist to configure it).
|
|
mux.HandleFunc("GET /api/admin/pb-config", s.requireSuperadminAuth(s.handleGetPBConfig))
|
|
mux.HandleFunc("POST /api/admin/pb-config/test", s.requireSuperadminAuth(s.handleTestPBConfig))
|
|
mux.HandleFunc("PUT /api/admin/pb-config", s.requireSuperadminAuth(s.handleUpdatePBConfig))
|
|
|
|
// Web App settings — superadmin only. Where the Web App lives (probed by
|
|
// /api/status) and which browser origins CORS admits.
|
|
mux.HandleFunc("GET /api/admin/webapp-config", s.requireSuperadminAuth(s.handleGetWebAppConfig))
|
|
mux.HandleFunc("POST /api/admin/webapp-config/test", s.requireSuperadminAuth(s.handleTestWebAppConfig))
|
|
mux.HandleFunc("PUT /api/admin/webapp-config", s.requireSuperadminAuth(s.handleUpdateWebAppConfig))
|
|
|
|
// Plugins — external-service integrations, managed by a superadmin.
|
|
mux.HandleFunc("GET /api/admin/plugins", s.requireSuperadminAuth(s.handleListPlugins))
|
|
mux.HandleFunc("POST /api/admin/plugins", s.requireSuperadminAuth(s.handleRegisterPlugin))
|
|
mux.HandleFunc("GET /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleGetPlugin))
|
|
mux.HandleFunc("PUT /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleUpdatePlugin))
|
|
mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin))
|
|
mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth))
|
|
|
|
// Cars + sharing.
|
|
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}/technical-checks", s.listCarTechnicalChecks)
|
|
mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts)
|
|
mux.HandleFunc("GET /api/cars/{id}/fuel-entries", s.listCarFuelEntries)
|
|
mux.HandleFunc("GET /api/cars/{id}/fuel-stats", s.listCarFuelStats)
|
|
mux.HandleFunc("GET /api/cars/{id}/maintenance", s.listCarMaintenance)
|
|
mux.HandleFunc("GET /api/cars/{id}/documents", s.listCarDocuments)
|
|
mux.HandleFunc("GET /api/cars/{id}/reminders", s.listCarReminders)
|
|
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)
|
|
|
|
// Service records.
|
|
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)
|
|
|
|
// Technical checks (roadworthiness inspections).
|
|
mux.HandleFunc("GET /api/technical-checks", s.listTechnicalChecks)
|
|
mux.HandleFunc("POST /api/technical-checks", s.createTechnicalCheck)
|
|
mux.HandleFunc("GET /api/technical-checks/{id}", s.getTechnicalCheck)
|
|
mux.HandleFunc("PATCH /api/technical-checks/{id}", s.updateTechnicalCheck)
|
|
mux.HandleFunc("DELETE /api/technical-checks/{id}", s.deleteTechnicalCheck)
|
|
|
|
// Parts.
|
|
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)
|
|
|
|
// Fuel entries.
|
|
mux.HandleFunc("GET /api/fuel-entries", s.listFuelEntries)
|
|
mux.HandleFunc("POST /api/fuel-entries", s.createFuelEntry)
|
|
mux.HandleFunc("GET /api/fuel-entries/{id}", s.getFuelEntry)
|
|
mux.HandleFunc("PATCH /api/fuel-entries/{id}", s.updateFuelEntry)
|
|
mux.HandleFunc("DELETE /api/fuel-entries/{id}", s.deleteFuelEntry)
|
|
|
|
// Maintenance log.
|
|
mux.HandleFunc("GET /api/maintenance", s.listMaintenance)
|
|
mux.HandleFunc("POST /api/maintenance", s.createMaintenance)
|
|
mux.HandleFunc("GET /api/maintenance/{id}", s.getMaintenance)
|
|
mux.HandleFunc("PATCH /api/maintenance/{id}", s.updateMaintenance)
|
|
mux.HandleFunc("DELETE /api/maintenance/{id}", s.deleteMaintenance)
|
|
|
|
// Documents. Named /api/car-documents so the path can't be mistaken for the
|
|
// user-facing account documents some other Vault services expose.
|
|
mux.HandleFunc("GET /api/car-documents", s.listDocuments)
|
|
mux.HandleFunc("POST /api/car-documents", s.createDocument)
|
|
mux.HandleFunc("GET /api/car-documents/{id}", s.getDocument)
|
|
mux.HandleFunc("PATCH /api/car-documents/{id}", s.updateDocument)
|
|
mux.HandleFunc("DELETE /api/car-documents/{id}", s.deleteDocument)
|
|
|
|
// Reminders.
|
|
mux.HandleFunc("GET /api/reminders", s.listReminders)
|
|
mux.HandleFunc("POST /api/reminders", s.createReminder)
|
|
mux.HandleFunc("GET /api/reminders/{id}", s.getReminder)
|
|
mux.HandleFunc("PATCH /api/reminders/{id}", s.updateReminder)
|
|
mux.HandleFunc("DELETE /api/reminders/{id}", s.deleteReminder)
|
|
mux.HandleFunc("POST /api/reminders/{id}/complete", s.handleCompleteReminder)
|
|
|
|
// Attachments — one optional file per record, on identical terms for every
|
|
// collection that takes one (see attachments.go). The GET handler passed
|
|
// alongside is what renders the record after an upload.
|
|
s.attachmentRoutes(mux, "/api/car-documents", colDocuments, s.getDocument)
|
|
s.attachmentRoutes(mux, "/api/service-records", colServices, s.getServiceRecord)
|
|
s.attachmentRoutes(mux, "/api/technical-checks", colTechnicalChecks, s.getTechnicalCheck)
|
|
s.attachmentRoutes(mux, "/api/maintenance", colMaintenance, s.getMaintenance)
|
|
s.attachmentRoutes(mux, "/api/fuel-entries", colFuel, s.getFuelEntry)
|
|
s.attachmentRoutes(mux, "/api/parts", colParts, s.getPart)
|
|
|
|
return s.withMiddleware(mux)
|
|
}
|
|
|
|
// withMiddleware applies panic recovery, CORS, request logging, and
|
|
// authentication globally.
|
|
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
|
return s.recoverer(s.cors(s.logger(s.withAuth(next))))
|
|
}
|
|
|
|
func (s *Server) logger(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(sw, r)
|
|
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
|
|
})
|
|
}
|
|
|
|
func (s *Server) recoverer(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
log.Printf("panic: %v", rec)
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// originAllowed reports whether origin may call this server, and whether it was
|
|
// the wildcard that allowed it. The allow-list is consulted per request rather
|
|
// than captured once, so editing it from the panel takes effect without a
|
|
// restart.
|
|
func (s *Server) originAllowed(origin string) (allowed, wildcard bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, o := range s.cfg.AllowOrigins {
|
|
if o == "*" {
|
|
return true, true
|
|
}
|
|
if o == origin {
|
|
allowed = true
|
|
}
|
|
}
|
|
return allowed, false
|
|
}
|
|
|
|
func (s *Server) cors(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
if allowed, wildcard := s.originAllowed(origin); allowed {
|
|
if wildcard {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
} else {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Add("Vary", "Origin")
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
}
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// statusWriter captures the response status code for logging.
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
wrote bool
|
|
}
|
|
|
|
func (w *statusWriter) WriteHeader(code int) {
|
|
if !w.wrote {
|
|
w.status = code
|
|
w.wrote = true
|
|
}
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *statusWriter) Write(b []byte) (int, error) {
|
|
w.wrote = true
|
|
return w.ResponseWriter.Write(b)
|
|
}
|