From 2d8b404338aeb37eaca528ded71012b2a327a91e Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:36:16 +0200 Subject: [PATCH] httpgw: remove 9P backend, D-Bus only Drop ninep.go and all 9P-related flags/imports. The gateway now exclusively uses D-Bus calls to org.ollie.SessionManager. The 9P transport was unreliable for HTTP gateway use; D-Bus is synchronous and well-suited for request/response translation. --- go.mod | 5 +- go.sum | 34 ----- main.go | 64 ++------ ninep.go | 434 ------------------------------------------------------- 4 files changed, 13 insertions(+), 524 deletions(-) delete mode 100644 ninep.go diff --git a/go.mod b/go.mod index 0eb19fd..7028051 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,4 @@ module ollie-httpgw go 1.25.6 -require ( - 9fans.net/go v0.0.7 - github.com/godbus/dbus/v5 v5.1.0 -) +require github.com/godbus/dbus/v5 v5.1.0 diff --git a/go.sum b/go.sum index 72f69f9..024b269 100644 --- a/go.sum +++ b/go.sum @@ -1,36 +1,2 @@ -9fans.net/go v0.0.7 h1:H5CsYJTf99C8EYAQr+uSoEJnLP/iZU8RmDuhyk30iSM= -9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= -golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= -golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index 5e02d7b..edeecde 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,10 @@ // ollie-httpgw - HTTP gateway for ollie session management. // -// Supports two backend modes: -// -// dbus (default) - translates HTTP to D-Bus calls against org.ollie.SessionManager -// 9p - translates HTTP to 9P file operations +// Translates HTTP requests to D-Bus calls against org.ollie.SessionManager. // // Usage: // -// ollie-httpgw [start|fgstart|stop|status] # dbus mode (default) -// ollie-httpgw 9p [start|fgstart|stop|status] # 9p mode -// ollie-httpgw dbus [start|fgstart|stop|status] # explicit dbus mode +// ollie-httpgw [start|fgstart|stop|status] package main import ( @@ -24,48 +19,26 @@ import ( "os/exec" "path/filepath" "syscall" - - "9fans.net/go/plan9/client" ) //go:embed openapi.json var openapiSpec []byte -var ( - listen = flag.String("listen", ":8011", "HTTP listen address") - service = flag.String("service", "ollie", "9P service name to connect to") - network = flag.String("net", "", "network type (unix, tcp); empty = use namespace") - addr = flag.String("addr", "", "9P server address; empty = use namespace service") -) +var listen = flag.String("listen", ":8011", "HTTP listen address") const pidFile = "ollie-httpgw.pid" -// backendMode tracks which backend we're using. -var backendMode = "dbus" - func main() { args := os.Args[1:] - // Parse optional mode prefix: "dbus" or "9p" - if len(args) > 0 { - switch args[0] { - case "dbus": - backendMode = "dbus" - args = args[1:] - case "9p": - backendMode = "9p" - args = args[1:] - } - } - if len(args) >= 1 { subcmd := args[0] switch subcmd { case "start", "fgstart", "stop", "status": flag.CommandLine.Parse(args[1:]) //nolint:errcheck - ns := client.Namespace() + ns := os.Getenv("NAMESPACE") if ns == "" { - ns = os.TempDir() + ns = fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER")) } pidPath := filepath.Join(ns, pidFile) switch subcmd { @@ -74,7 +47,7 @@ func main() { fmt.Println("ollie-httpgw already running") os.Exit(0) } - daemonize(args) + daemonize() case "fgstart": if gwRunning() { fmt.Println("ollie-httpgw already running") @@ -95,7 +68,7 @@ func main() { } } - // Legacy: no subcommand — run in foreground directly (no pid file). + // No subcommand — run in foreground directly (no pid file). flag.CommandLine.Parse(args) //nolint:errcheck serve() } @@ -109,22 +82,16 @@ func gwRunning() bool { return false } -func daemonize(subcmdArgs []string) { +func daemonize() { exe, _ := os.Executable() - args := []string{backendMode, "fgstart", "-listen", *listen, "-service", *service} - if *network != "" { - args = append(args, "-net", *network) - } - if *addr != "" { - args = append(args, "-addr", *addr) - } + args := []string{"fgstart", "-listen", *listen} cmd := exec.Command(exe, args...) cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { fmt.Fprintf(os.Stderr, "failed to start: %v\n", err) os.Exit(1) } - fmt.Printf("ollie-httpgw started (pid %d, mode %s)\n", cmd.Process.Pid, backendMode) + fmt.Printf("ollie-httpgw started (pid %d)\n", cmd.Process.Pid) } func stopGateway(pidPath string) { @@ -154,16 +121,9 @@ func serve() { w.Write(openapiSpec) }) - switch backendMode { - case "dbus": - http.HandleFunc("/", dbusHandler) - case "9p": - http.HandleFunc("/", ninepHandler) - default: - log.Fatalf("unknown backend mode: %s", backendMode) - } + http.HandleFunc("/", dbusHandler) - log.Printf("ollie-httpgw listening on %s (mode: %s)", *listen, backendMode) + log.Printf("ollie-httpgw listening on %s", *listen) log.Fatal(http.ListenAndServe(*listen, nil)) } diff --git a/ninep.go b/ninep.go deleted file mode 100644 index 2115a28..0000000 --- a/ninep.go +++ /dev/null @@ -1,434 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path" - "strings" - - "9fans.net/go/plan9" - "9fans.net/go/plan9/client" -) - -func dial() (*client.Fsys, error) { - if *addr != "" { - n := *network - if n == "" { - n = "tcp" - } - return client.Mount(n, *addr) - } - return client.MountService(*service) -} - -type dirEntry struct { - Name string `json:"name"` - Size uint64 `json:"size"` - Mode uint32 `json:"mode"` - Mtime uint32 `json:"mtime"` - IsDir bool `json:"is_dir"` -} - -func dirToEntry(d *plan9.Dir) dirEntry { - return dirEntry{ - Name: d.Name, - Size: d.Length, - Mode: uint32(d.Mode), - Mtime: d.Mtime, - IsDir: d.Mode&plan9.DMDIR != 0, - } -} - -func setStatHeaders(w http.ResponseWriter, d *plan9.Dir) { - w.Header().Set("X-9p-Name", d.Name) - w.Header().Set("X-9p-Size", fmt.Sprintf("%d", d.Length)) - w.Header().Set("X-9p-Mode", fmt.Sprintf("%o", d.Mode)) - w.Header().Set("X-9p-Mtime", fmt.Sprintf("%d", d.Mtime)) - if d.Mode&plan9.DMDIR != 0 { - w.Header().Set("X-9p-Type", "dir") - } else { - w.Header().Set("X-9p-Type", "file") - } -} - -func ninepHandler(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/open" { - handleOpen(w, r) - return - } - - fsys, err := dial() - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "9p connect: " + err.Error()}) - return - } - defer fsys.Close() - - p := strings.TrimPrefix(r.URL.Path, "/") - - // Internal paths not exposed via HTTP. - first, _, _ := strings.Cut(p, "/") - switch first { - case "p", "help", "tr", "u", "x", "tmp": - writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) - return - } - // Read-only paths — only GET and HEAD allowed. - switch first { - case "", "sk": - if r.Method != http.MethodGet && r.Method != http.MethodHead { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "read-only"}) - return - } - } - base := path.Base(p) - switch base { - case "systemprompt", "statewait": - writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) - return - case "new": - if r.Method == http.MethodPost { - handleWrite(w, r, fsys, p) - return - } - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - return - } - - switch r.Method { - case http.MethodGet: - handleRead(w, fsys, p) - case http.MethodHead: - handleStat(w, fsys, p) - case http.MethodPost: - if base == "chat" { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "chat is read-only"}) - return - } - handleWrite(w, r, fsys, p) - case http.MethodPut: - handlePut(w, r, fsys, p) - case http.MethodDelete: - handleRemove(w, fsys, p) - case http.MethodPatch: - handleWstat(w, r, fsys, p) - case http.MethodOptions: - handleNinepOptions(w, r, p) - default: - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - } -} - -var allowedMethods = "GET, HEAD, PUT, POST, DELETE, PATCH, OPTIONS" - -var methodDescriptions = map[string]string{ - "GET": "read file content or list directory entries", - "HEAD": "stat file (metadata in response headers)", - "PUT": "write raw body to file (truncate)", - "POST": "write to file or trigger action", - "DELETE": "remove file or session", - "PATCH": "rename via JSON {\"name\": \"newname\"}", - "OPTIONS": "describe available operations for this path", -} - -func handleNinepOptions(w http.ResponseWriter, _ *http.Request, p string) { - w.Header().Set("Allow", allowedMethods) - writeJSON(w, http.StatusOK, map[string]any{ - "path": "/" + p, - "methods": methodDescriptions, - }) -} - -func handleRead(w http.ResponseWriter, fsys *client.Fsys, p string) { - d, err := fsys.Stat(p) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - setStatHeaders(w, d) - - if d.Mode&plan9.DMDIR != 0 { - fid, err := fsys.Open(p, plan9.OREAD) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - defer fid.Close() - dirs, err := fid.Dirreadall() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if p == "s" { - var ids []string - for _, de := range dirs { - if de.Mode&plan9.DMDIR != 0 { - ids = append(ids, de.Name) - } - } - writeJSON(w, http.StatusOK, ids) - return - } - entries := make([]dirEntry, len(dirs)) - for i, de := range dirs { - entries[i] = dirToEntry(de) - } - writeJSON(w, http.StatusOK, entries) - return - } - - fid, err := fsys.Open(p, plan9.OREAD) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - defer fid.Close() - content, err := io.ReadAll(fid) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if p == "models" { - writeJSON(w, http.StatusOK, parseModels(content)) - return - } - if p == "s/idx" { - writeJSON(w, http.StatusOK, parseIdx(content)) - return - } - if path.Base(p) == "chat" { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Write(content) - return - } - if path.Base(p) == "cfg" { - m := parseContent(content, "cfg") - if mm, ok := m.(map[string]string); ok { - // Inject state from sibling file. - stateFile := path.Dir(p) + "/state" - if sf, err := fsys.Open(stateFile, plan9.OREAD); err == nil { - if sb, err := io.ReadAll(sf); err == nil { - mm["state"] = strings.TrimSpace(string(sb)) - } - sf.Close() - } - } - writeJSON(w, http.StatusOK, m) - return - } - writeJSON(w, http.StatusOK, parseContent(content, path.Base(p))) -} - -type idxEntry struct { - ID string `json:"id"` - State string `json:"state"` - CWD string `json:"cwd"` - Backend string `json:"backend"` - Model string `json:"model"` -} - -type modelsEntry struct { - Backend string `json:"backend"` - Models []string `json:"models"` -} - -func parseModels(data []byte) []modelsEntry { - groups := make(map[string][]string) - var order []string - for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") { - if line == "" { - continue - } - b, m, ok := strings.Cut(line, "\t") - if !ok { - continue - } - if _, exists := groups[b]; !exists { - order = append(order, b) - } - groups[b] = append(groups[b], m) - } - out := make([]modelsEntry, len(order)) - for i, b := range order { - out[i] = modelsEntry{Backend: b, Models: groups[b]} - } - return out -} - -func parseIdx(data []byte) []idxEntry { - var entries []idxEntry - for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") { - if line == "" { - continue - } - fields := strings.SplitN(line, "\t", 5) - if len(fields) != 5 { - continue - } - entries = append(entries, idxEntry{ - ID: fields[0], - State: fields[1], - CWD: fields[2], - Backend: fields[3], - Model: fields[4], - }) - } - return entries -} - -// parseContent detects the wire format and returns the appropriate Go value: -// - key=value lines → map[string]string -// - tab-separated records → [][]string -// - multiple newline-separated values → []string -// - single value → map[string]string{name: value} -func parseContent(data []byte, name string) any { - s := strings.TrimRight(string(data), "\n") - if s == "" { - return map[string]string{} - } - first, _, _ := strings.Cut(s, "\n") - if strings.Contains(first, "=") { - m := make(map[string]string) - for line := range strings.SplitSeq(s, "\n") { - if line == "" { - continue - } - k, v, _ := strings.Cut(line, "=") - m[k] = v - } - return m - } - if strings.Contains(first, "\t") { - var rows [][]string - for line := range strings.SplitSeq(s, "\n") { - if line != "" { - rows = append(rows, strings.Split(line, "\t")) - } - } - return rows - } - var lines []string - for line := range strings.SplitSeq(s, "\n") { - lines = append(lines, line) - } - if len(lines) == 1 { - return map[string]string{name: lines[0]} - } - return lines -} - -// serializeKV serializes a map to newline-separated key=value lines. -func serializeKV(m map[string]any) []byte { - var sb strings.Builder - for k, v := range m { - fmt.Fprintf(&sb, "%s=%v\n", k, v) - } - return []byte(sb.String()) -} - -func handleStat(w http.ResponseWriter, fsys *client.Fsys, p string) { - d, err := fsys.Stat(p) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - setStatHeaders(w, d) - w.Header().Set("Content-Type", "application/json") -} - -func handleOpen(w http.ResponseWriter, r *http.Request) { - p := r.URL.Query().Get("path") - if p == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing path"}) - return - } - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "xdg-open" - } - if err := exec.Command(editor, p).Start(); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"opened": p}) -} - -func handlePut(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { - body, err := io.ReadAll(r.Body) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - fid, err := fsys.Open(p, plan9.OWRITE|plan9.OTRUNC) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - n, err := fid.Write(body) - fid.Close() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n}) -} - -func handleWrite(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { - var m map[string]any - if err := json.NewDecoder(r.Body).Decode(&m); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - // If the body has a single key matching the file name, write just the value. - // Otherwise serialize as key=value lines (e.g. for "new"). - var payload []byte - base := path.Base(p) - if len(m) == 1 { - if v, ok := m[base]; ok { - payload = []byte(fmt.Sprintf("%v", v)) - } - } - if payload == nil { - payload = serializeKV(m) - } - fid, err := fsys.Open(p, plan9.OWRITE) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - n, err := fid.Write(payload) - fid.Close() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n}) -} - -func handleRemove(w http.ResponseWriter, fsys *client.Fsys, p string) { - if err := fsys.Remove(p); err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func handleWstat(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { - var req struct { - Name string `json:"name"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - d := &plan9.Dir{Name: req.Name} - if err := fsys.Wstat(p, d); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - w.WriteHeader(http.StatusNoContent) -}