package admin import ( "net/http" "os" "path/filepath" "strings" ) // The editor exposes the same list/get/create/update/delete contract for // several YAML families. entityCRUD captures everything that actually // differed between the hand-written copies; the handlers below are shared by // every family. // // Two wire styles predate this file and must be preserved (see failJSON's // contract comment): // // - status style (items, drops, mobs, objects, hazards): errors are HTTP // error statuses with a JSON body; a successful write echoes the entity // map (including _raw). // - body style (techs, modules): errors are a 200 JSON body read as // res.error; writes reply {"ok":true,"id":id}. These editors also upsert // (POST/PUT by id creates an absent file). type entityCRUD struct { route string // URL segment and data subdirectory, e.g. "items" noun string // singular for error text and undo descriptions searchSubdirs bool // look the entity up in nested directories too upsert bool // by-ID POST/PUT creates an absent file bodyErr bool // write errors as a 200 JSON body (failJSON) okResponse bool // reply {"ok":true,"id":id} and "Created/Updated" refresh func(*AdminServer) } var ( itemSpec = entityCRUD{route: "items", noun: "item", searchSubdirs: true, refresh: func(s *AdminServer) { s.invalidateEntityCaches("items") }} dropSpec = entityCRUD{route: "drops", noun: "drop", refresh: func(s *AdminServer) { s.invalidateEntityCaches("drops") }} mobSpec = entityCRUD{route: "mobs", noun: "mob", searchSubdirs: true, refresh: func(s *AdminServer) { s.invalidateEntityCaches("mobs") }} objectSpec = entityCRUD{route: "objects", noun: "object", searchSubdirs: true, refresh: func(s *AdminServer) { s.invalidateEntityCaches("objects") }} hazardSpec = entityCRUD{route: "hazards", noun: "hazard", refresh: func(s *AdminServer) { s.world.ClearHazardCache() }} techSpec = entityCRUD{route: "techs", noun: "tech", searchSubdirs: true, upsert: true, bodyErr: true, okResponse: true} moduleSpec = entityCRUD{route: "modules", noun: "module", searchSubdirs: true, upsert: true, bodyErr: true, okResponse: true} ) func (c entityCRUD) find(s *AdminServer, id string) ([]byte, string, error) { if c.searchSubdirs { return findYAMLFileInSubdirs(s.dataDir, c.route, id) } return readYAMLFile(s.dataDir, c.route, id) } // fail writes an entity error in the family's wire style. status is ignored // by the body style, whose clients read the error out of a 200. func (c entityCRUD) fail(w http.ResponseWriter, status int, msg string) { if c.bodyErr { failJSON(w, msg) return } writeJSONError(w, msg, status) } func (c entityCRUD) invalidate(s *AdminServer) { if c.refresh != nil { c.refresh(s) } } func (c entityCRUD) invalidID() string { if c.bodyErr { return "invalid id" } return "invalid " + c.noun + " id" } func (c entityCRUD) missingID() string { if c.bodyErr { return "missing id" } return "missing " + c.noun + " id" } func (c entityCRUD) notFound(id string) string { if c.bodyErr { return "not found: " + id } return c.noun + " not found" } func (c entityCRUD) jsonErr(err error) string { if c.bodyErr { return "invalid JSON: " + err.Error() } return "invalid json body" } func (c entityCRUD) parseErr(err error) string { if c.bodyErr { return "parse error: " + err.Error() } return "failed to parse " + c.noun + " yaml" } func (c entityCRUD) writeErr(err error) string { if c.bodyErr { return "write error: " + err.Error() } return "failed to write " + c.noun } func (c entityCRUD) readErr(err error) string { if c.bodyErr { return "read existing file: " + err.Error() } return "failed to read existing " + c.noun } func (c entityCRUD) removeErr(err error) string { if c.bodyErr { return "delete error: " + err.Error() } return "failed to delete " + c.noun } func (c entityCRUD) listErr(err error) string { if c.bodyErr { return err.Error() } return "failed to list " + c.route } // desc names an undo entry. The body style uses past-tense verbs ("Created // tech x"); the status style uses imperative ones ("Create item x"). func (c entityCRUD) desc(verb, okVerb, id string) string { if c.okResponse { return okVerb + " " + c.noun + " " + id } return verb + " " + c.noun + " " + id } // handleEntityList serves GET (tree) and POST (create) for one family. func (s *AdminServer) handleEntityList(c entityCRUD) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: tree, err := listYAMLTree(s.dataDir, c.route) if err != nil { c.fail(w, http.StatusInternalServerError, c.listErr(err)) return } if tree == nil { tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} } writeJSON(w, tree) case http.MethodPost: s.entityCreate(w, r, c) default: methodNotAllowed(w) } } } // handleEntityByID serves GET/PUT/POST(upsert)/DELETE for one family. func (s *AdminServer) handleEntityByID(c entityCRUD) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/api/"+c.route+"/") if id == "" { c.fail(w, http.StatusBadRequest, c.missingID()) return } if !validEntityID(id) { c.fail(w, http.StatusBadRequest, c.invalidID()) return } switch r.Method { case http.MethodGet: s.entityGet(w, id, c) case http.MethodPut: s.entityUpdate(w, r, id, c) case http.MethodPost: if !c.upsert { methodNotAllowed(w) return } s.entityUpdate(w, r, id, c) case http.MethodDelete: s.entityDelete(w, id, c) default: methodNotAllowed(w) } } } func (s *AdminServer) entityGet(w http.ResponseWriter, id string, c entityCRUD) { data, path, err := c.find(s, id) if err != nil { c.fail(w, http.StatusNotFound, c.notFound(id)) return } m, err := yamlToMap(data) if err != nil { c.fail(w, http.StatusInternalServerError, c.parseErr(err)) return } if c.okResponse { // The body style strips the id before writing, so echo it from the // filename; the status style already writes it into the file. m["id"] = id } m["_path"] = path m["_raw"] = string(data) writeJSON(w, m) } func (s *AdminServer) entityCreate(w http.ResponseWriter, r *http.Request, c entityCRUD) { var m map[string]any if err := readJSON(r, &m); err != nil { c.fail(w, http.StatusBadRequest, c.jsonErr(err)) return } id, ok := m["id"].(string) if !ok || strings.TrimSpace(id) == "" { c.fail(w, http.StatusBadRequest, c.missingID()) return } if !validEntityID(id) { c.fail(w, http.StatusBadRequest, c.invalidID()) return } // Editor-only keys never belong in the file (the update path drops them // too); without this a GET->POST round trip poisons the YAML. delete(m, "_path") delete(m, "_raw") if _, _, err := c.find(s, id); err == nil { c.fail(w, http.StatusConflict, c.noun+" already exists") return } if c.okResponse { delete(m, "id") } path := filepath.Join(s.dataDir, c.route, id+".yaml") newContent, err := writeMapAsYAML(path, m) if err != nil { c.fail(w, http.StatusInternalServerError, c.writeErr(err)) return } s.undoStack.Push(ChangeDesc{ Description: c.desc("Create", "Created", id), FilePath: path, NewContent: newContent, IsCreate: true, }) c.invalidate(s) if c.okResponse { writeJSON(w, map[string]any{"ok": true, "id": id}) return } writeJSON(w, m) } func (s *AdminServer) entityUpdate(w http.ResponseWriter, r *http.Request, id string, c entityCRUD) { var m map[string]any if err := readJSON(r, &m); err != nil { c.fail(w, http.StatusBadRequest, c.jsonErr(err)) return } _, path, findErr := c.find(s, id) isCreate := findErr != nil if isCreate && !c.upsert { c.fail(w, http.StatusNotFound, c.notFound(id)) return } if isCreate { path = filepath.Join(s.dataDir, c.route, id+".yaml") } var oldContent []byte if !isCreate { var err error oldContent, err = snapshotFile(path) if err != nil { c.fail(w, http.StatusInternalServerError, c.readErr(err)) return } } // _path/_raw are editor-only metadata and never belong in the file; the // status style also stores the id, while the body style derives it from // the filename. delete(m, "_path") delete(m, "_raw") if c.okResponse { delete(m, "id") } else { m["id"] = id } newContent, err := writeMapAsYAML(path, m) if err != nil { c.fail(w, http.StatusInternalServerError, c.writeErr(err)) return } s.undoStack.Push(ChangeDesc{ Description: c.desc("Update", "Updated", id), FilePath: path, OldContent: oldContent, NewContent: newContent, IsCreate: isCreate, }) c.invalidate(s) if c.okResponse { writeJSON(w, map[string]any{"ok": true, "id": id}) return } m["_raw"] = string(newContent) writeJSON(w, m) } func (s *AdminServer) entityDelete(w http.ResponseWriter, id string, c entityCRUD) { _, path, err := c.find(s, id) if err != nil { c.fail(w, http.StatusNotFound, c.notFound(id)) return } oldContent, exists, snapErr := snapshotBefore(path) if snapErr != nil { c.fail(w, http.StatusInternalServerError, c.removeErr(snapErr)) return } if !exists { oldContent = nil } if err := os.Remove(path); err != nil { c.fail(w, http.StatusInternalServerError, c.removeErr(err)) return } s.undoStack.Push(ChangeDesc{ Description: c.desc("Delete", "Deleted", id), FilePath: path, OldContent: oldContent, IsDelete: true, }) c.invalidate(s) if c.okResponse { writeJSON(w, map[string]any{"ok": true, "id": id}) return } writeJSON(w, map[string]any{"deleted": id}) } // methodNotAllowed writes the shared 405 body. The admin JS throws on any // non-200, so the JSON form is safe for both wire styles. func methodNotAllowed(w http.ResponseWriter) { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) }