ollie-httpgw/main.go

359 lines
9.2 KiB
Go

// ollie-httpgw - HTTP gateway for the ollie 9P server.
//
// Maps HTTP methods to 9P file operations:
//
// GET /path - read file content or list directory entries
// PUT /path - write request body to file
// POST /path - create file (name = last path element)
// DELETE /path - remove file
// HEAD /path - stat file (metadata in headers)
// PATCH /path - wstat (rename via JSON {"name": "newname"})
package main
import (
_ "embed"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"path"
"strings"
"9fans.net/go/plan9"
"9fans.net/go/plan9/client"
)
//go:embed openapi.json
var openapiSpec []byte
var (
listen = flag.String("listen", ":8080", "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")
)
func main() {
flag.Parse()
http.HandleFunc("/openapi.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(openapiSpec)
})
http.HandleFunc("/", handler)
log.Printf("ollie-httpgw listening on %s", *listen)
log.Fatal(http.ListenAndServe(*listen, nil))
}
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 writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func handler(w http.ResponseWriter, r *http.Request) {
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 "", "a", "p", "m", "t", "sk", "help", "tr", "u", "x", "tmp":
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
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.MethodDelete:
handleRemove(w, fsys, p)
case http.MethodPatch:
handleWstat(w, r, fsys, p)
case http.MethodOptions:
handleOptions(w, r, p)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}
var allowedMethods = "GET, HEAD, POST, DELETE, PATCH, OPTIONS"
var methodDescriptions = map[string]string{
"GET": "read file content or list directory entries",
"HEAD": "stat file (metadata in response headers)",
"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 handleOptions(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 == "s/idx" {
writeJSON(w, http.StatusOK, parseIdx(content))
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"`
}
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") {
if line != "" {
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 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
}
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(serializeKV(m))
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)
}