aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/owner.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/owner.go')
-rw-r--r--internal/admin/owner.go82
1 files changed, 82 insertions, 0 deletions
diff --git a/internal/admin/owner.go b/internal/admin/owner.go
new file mode 100644
index 0000000..02288f7
--- /dev/null
+++ b/internal/admin/owner.go
@@ -0,0 +1,82 @@
+package admin
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "net/http"
+)
+
+const maxAdminRequestBytes = 8 << 20
+
+// ownerMiddleware is the single execution boundary for ALL editor API routes,
+// including reads (which must not observe partial edits) and undo/redo. Planning,
+// file writes, runtime callbacks, reloads and history updates stay in one task.
+// Register new API routes behind this boundary; handlers must never redispatch.
+// Network I/O happens outside the task so slow browsers cannot block gameplay.
+func (s *AdminServer) ownerMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxAdminRequestBytes))
+ r.Body.Close()
+ if err != nil {
+ status := http.StatusBadRequest
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ status = http.StatusRequestEntityTooLarge
+ }
+ writeJSONError(w, "cannot read request body: "+err.Error(), status)
+ return
+ }
+ if r.Context().Err() != nil {
+ return
+ }
+ r = r.Clone(r.Context())
+ r.Body = io.NopCloser(bytes.NewReader(body))
+ response := &bufferedResponse{header: make(http.Header)}
+ var panicValue any
+ if !s.runOnOwner(func() {
+ // Preserve net/http's panic recovery on the HTTP goroutine;
+ // a handler panic must not kill the game's owner loop.
+ defer func() { panicValue = recover() }()
+ if r.Context().Err() == nil {
+ next.ServeHTTP(response, r)
+ }
+ }) {
+ writeJSONError(w, "game is shutting down", http.StatusServiceUnavailable)
+ return
+ }
+ if panicValue != nil {
+ panic(panicValue)
+ }
+ for key, values := range response.header {
+ w.Header()[key] = values
+ }
+ if response.status != 0 {
+ w.WriteHeader(response.status)
+ }
+ _, _ = w.Write(response.body.Bytes())
+ })
+}
+
+// bufferedResponse deliberately provides no streaming/hijacking interfaces:
+// editor API handlers produce finite responses, never network streams.
+type bufferedResponse struct {
+ header http.Header
+ status int
+ body bytes.Buffer
+}
+
+func (w *bufferedResponse) Header() http.Header { return w.header }
+
+func (w *bufferedResponse) WriteHeader(status int) {
+ if w.status == 0 {
+ w.status = status
+ }
+}
+
+func (w *bufferedResponse) Write(p []byte) (int, error) {
+ if w.status == 0 {
+ w.WriteHeader(http.StatusOK)
+ }
+ return w.body.Write(p)
+}