Rebuild API Server on the PilotVault structure
Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.
Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).
Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.
Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.
Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.
PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.
Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.
Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.
Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.
Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7d55f0a4cd
commit
ae6ed4ac1e
+201
-121
@@ -1,58 +1,71 @@
|
||||
// Package api exposes the HTTP REST surface of the car-control API Server.
|
||||
// Package api exposes the HTTP REST surface of the DriverVault API Server.
|
||||
//
|
||||
// Clients (web app, phone app, ...) talk only to this server; this server is
|
||||
// the only thing that talks to PocketBase. Endpoints:
|
||||
// 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/cars
|
||||
// POST /api/cars
|
||||
// GET /api/cars/{id}
|
||||
// PATCH /api/cars/{id}
|
||||
// DELETE /api/cars/{id}
|
||||
// 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/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
|
||||
// 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}
|
||||
// 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}
|
||||
// GET /api/parts POST /api/parts
|
||||
// GET /api/parts/{id} PATCH /api/parts/{id} DELETE /api/parts/{id}
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/pb"
|
||||
"drivervault/apiserver/internal/config"
|
||||
"drivervault/apiserver/internal/pb"
|
||||
"drivervault/apiserver/internal/plugins"
|
||||
_ "drivervault/apiserver/internal/plugins/builtin" // register built-in plugins
|
||||
)
|
||||
|
||||
// PocketBase collection names.
|
||||
@@ -60,53 +73,90 @@ const (
|
||||
colCars = "cars"
|
||||
colServices = "service_records"
|
||||
colParts = "parts"
|
||||
colSessions = "sessions"
|
||||
colShares = "car_shares"
|
||||
colOrgs = "organizations"
|
||||
)
|
||||
|
||||
// Server wires together the HTTP handlers and their dependencies.
|
||||
type Server struct {
|
||||
pb *pb.Client
|
||||
corsOrigins map[string]bool
|
||||
authSecret string
|
||||
usersCollection string
|
||||
mu sync.RWMutex // guards the mutable PocketBase connection in cfg
|
||||
cfg config.Config
|
||||
pb *pb.Client
|
||||
plugins *plugins.Manager
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// New constructs a Server around an already-built PocketBase client.
|
||||
func New(cfg config.Config, client *pb.Client) *Server {
|
||||
return &Server{
|
||||
pb: client,
|
||||
corsOrigins: set,
|
||||
authSecret: opts.AuthSecret,
|
||||
usersCollection: opts.UsersCollection,
|
||||
cfg: cfg,
|
||||
pb: client,
|
||||
plugins: plugins.NewManager(cfg.PluginsFile),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler builds the routed, CORS-wrapped HTTP handler (Go 1.22 ServeMux).
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
// 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.
|
||||
// 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)
|
||||
|
||||
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
||||
mux.HandleFunc("GET /api/auth/me", s.handleMe)
|
||||
// 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)
|
||||
@@ -120,16 +170,36 @@ func (s *Server) Handler() http.Handler {
|
||||
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)
|
||||
// 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))
|
||||
|
||||
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)
|
||||
// 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))
|
||||
|
||||
// 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)
|
||||
@@ -137,43 +207,74 @@ func (s *Server) Handler() http.Handler {
|
||||
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)
|
||||
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
|
||||
return s.withCORS(s.withLogging(s.withAuth(mux)))
|
||||
return s.withMiddleware(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),
|
||||
// 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))
|
||||
})
|
||||
}
|
||||
|
||||
// --- middleware ---
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||
func (s *Server) cors(next http.Handler) http.Handler {
|
||||
allowed := map[string]bool{}
|
||||
wildcard := false
|
||||
for _, o := range s.cfg.AllowOrigins {
|
||||
if o == "*" {
|
||||
wildcard = true
|
||||
}
|
||||
allowed[o] = true
|
||||
}
|
||||
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")
|
||||
if origin != "" && (wildcard || allowed[origin]) {
|
||||
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", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -183,43 +284,22 @@ func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
// statusWriter captures the response status code for logging.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wrote bool
|
||||
}
|
||||
|
||||
// --- 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 (w *statusWriter) WriteHeader(code int) {
|
||||
if !w.wrote {
|
||||
w.status = code
|
||||
w.wrote = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
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)
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
w.wrote = true
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user