405 lines
12 KiB
Go
405 lines
12 KiB
Go
// Package api exposes the player-facing HTTP interface.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"halcyon/comms"
|
|
"halcyon/config"
|
|
"halcyon/runner"
|
|
"halcyon/store"
|
|
"halcyon/vm"
|
|
"halcyon/world"
|
|
)
|
|
|
|
type Server struct {
|
|
st *store.Store
|
|
cfg config.Config
|
|
admin *adminAuth // nil when the admin interface is off
|
|
|
|
mapCache mapCache // the admin map's last response
|
|
}
|
|
|
|
// New returns the HTTP handler.
|
|
//
|
|
// POST /register {"name": "..."} -> player, api_key, ship_id
|
|
// GET /me -> name, credits, day
|
|
// GET /market -> ore prices
|
|
// GET /info -> day and the belt's limits
|
|
// POST /assemble assembly text -> program (base64), size
|
|
// GET /ships -> your ships
|
|
// PUT /ships/{id}/program raw bytecode (ship must be in inventory)
|
|
// POST /ships/{id}/launch {"slot": n} (optional) (enters the belt on the next run; with a slot, that
|
|
// slot's code is assembled and sealed in first)
|
|
// GET /slots, GET/PUT/DELETE /slots/{n} the sixteen code slots (1-3 are read-only examples)
|
|
// PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink)
|
|
// POST /ships/{id}/scrap scrap a ship in the belt (on the next run it vanishes and a new ship
|
|
// appears in your inventory)
|
|
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header; ?day=N picks an earlier one
|
|
// GET /ships/{id}/downlinks {"days":[...]} the days with a stored downlink
|
|
//
|
|
// Authenticated routes take "Authorization: Bearer <api_key>".
|
|
func New(st *store.Store, cfg config.Config, opts ...Option) *http.ServeMux {
|
|
s := &Server{st: st, cfg: cfg}
|
|
for _, o := range opts {
|
|
o(s)
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /register", s.register)
|
|
mux.HandleFunc("GET /market", s.market)
|
|
mux.HandleFunc("GET /info", s.info)
|
|
mux.HandleFunc("POST /assemble", s.assemble)
|
|
mux.Handle("POST /login", s.auth(s.playerLogin))
|
|
mux.Handle("GET /me", s.auth(s.me))
|
|
mux.Handle("GET /ships", s.auth(s.ships))
|
|
mux.Handle("PUT /ships/{id}/program", s.auth(s.program))
|
|
mux.Handle("POST /ships/{id}/launch", s.auth(s.launch))
|
|
mux.Handle("POST /ships/{id}/scrap", s.auth(s.scrap))
|
|
mux.HandleFunc("POST /admin/api/login", s.adminLogin)
|
|
mux.HandleFunc("POST /admin/api/logout", s.adminLogout)
|
|
mux.Handle("GET /admin/api/session", s.adminOnly(s.adminSession))
|
|
mux.Handle("GET /admin/api/accounts", s.adminOnly(s.adminAccounts))
|
|
mux.Handle("GET /admin/api/accounts/{id}", s.adminOnly(s.adminAccount))
|
|
mux.Handle("GET /admin/api/accounts/{id}/slots/{n}", s.adminOnly(s.adminSlotSource))
|
|
mux.Handle("GET /admin/api/audit", s.adminOnly(s.adminAudit))
|
|
mux.Handle("GET /admin/api/map", s.adminOnly(s.adminMap))
|
|
mux.Handle("GET /admin/api/map/orbit", s.adminOnly(s.adminMapOrbit)) // ?ship=N: its predicted coasting path
|
|
mux.Handle("GET /slots", s.auth(s.listSlots))
|
|
mux.Handle("GET /slots/{n}", s.auth(s.getSlot))
|
|
mux.Handle("PUT /slots/{n}", s.auth(s.putSlot))
|
|
mux.Handle("DELETE /slots/{n}", s.auth(s.deleteSlot))
|
|
mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink))
|
|
mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink))
|
|
mux.Handle("GET /ships/{id}/downlinks", s.auth(s.downlinks))
|
|
return mux
|
|
}
|
|
|
|
func (s *Server) auth(h func(http.ResponseWriter, *http.Request, store.Player)) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
key, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !ok {
|
|
httpError(w, http.StatusUnauthorized, "missing bearer token")
|
|
return
|
|
}
|
|
p, err := s.st.PlayerByKey(r.Context(), key)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
httpError(w, http.StatusUnauthorized, "invalid api key")
|
|
} else {
|
|
fail(w, err)
|
|
}
|
|
return
|
|
}
|
|
h(w, r, p)
|
|
})
|
|
}
|
|
|
|
func httpError(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, map[string]string{"error": msg})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// fail maps store errors to HTTP statuses.
|
|
func fail(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
httpError(w, http.StatusNotFound, "not found")
|
|
case errors.Is(err, store.ErrState):
|
|
httpError(w, http.StatusConflict, err.Error())
|
|
case errors.Is(err, store.ErrTaken):
|
|
httpError(w, http.StatusConflict, err.Error())
|
|
default:
|
|
httpError(w, http.StatusInternalServerError, "internal error")
|
|
}
|
|
}
|
|
|
|
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<10)).Decode(&req); err != nil || len(req.Name) == 0 || len(req.Name) > 40 {
|
|
httpError(w, http.StatusBadRequest, `body must be {"name": "<1-40 chars>"}`)
|
|
return
|
|
}
|
|
p, key, ship, err := s.st.Register(r.Context(), req.Name)
|
|
if err != nil {
|
|
fail(w, err)
|
|
return
|
|
}
|
|
s.audit(r, p.ID, "register", ship, 0, map[string]any{"name": p.Name})
|
|
writeJSON(w, http.StatusCreated, map[string]any{"player_id": p.ID, "api_key": key, "ship_id": ship})
|
|
}
|
|
|
|
// playerLogin is what the sign-in form calls once it has been given an access key:
|
|
// it checks the key, returns the account, and puts the sign-in in the audit log.
|
|
// (Every other request is authenticated by the key alone, so it is not a "login".)
|
|
func (s *Server) playerLogin(w http.ResponseWriter, r *http.Request, p store.Player) {
|
|
s.audit(r, p.ID, "login", 0, 0, map[string]any{"user_agent": trunc(r.UserAgent(), 200)})
|
|
s.me(w, r, p)
|
|
}
|
|
|
|
func trunc(s string, n int) string {
|
|
if r := []rune(s); len(r) > n {
|
|
return string(r[:n])
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Server) currentDay(ctx context.Context) int64 {
|
|
v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
n, _ := strconv.ParseInt(v, 10, 64)
|
|
return n
|
|
}
|
|
|
|
func (s *Server) me(w http.ResponseWriter, r *http.Request, p store.Player) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "credits": p.Credits, "day": s.currentDay(r.Context())})
|
|
}
|
|
|
|
func (s *Server) market(w http.ResponseWriter, r *http.Request) {
|
|
v, ok, err := s.st.GetMeta(r.Context(), runner.MetaMarket)
|
|
if err != nil {
|
|
fail(w, err)
|
|
return
|
|
}
|
|
if !ok {
|
|
v = "null"
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
io.WriteString(w, v)
|
|
}
|
|
|
|
// info reports the day and the limits that apply to programs and messages.
|
|
func (s *Server) info(w http.ResponseWriter, r *http.Request) {
|
|
ores := make([]string, world.NumOre)
|
|
for o := range ores {
|
|
ores[o] = world.Ore(o).String()
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"day": s.currentDay(r.Context()),
|
|
"turns_per_day": s.cfg.TicksPerDay,
|
|
"turn_seconds": s.cfg.TickSeconds(),
|
|
"cycles_per_turn": s.cfg.CyclesPerTick,
|
|
"program_bytes": s.cfg.ProgramBytes,
|
|
"ram_bytes": s.cfg.RAMBytes,
|
|
"comm_bytes": s.cfg.CommBytes,
|
|
"ores": ores,
|
|
})
|
|
}
|
|
|
|
// assemble compiles assembly source with the same assembler as cmd/asm, so
|
|
// that clients need no assembler of their own. Nothing is stored.
|
|
func (s *Server) assemble(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := readBody(w, r, 64<<10)
|
|
if !ok {
|
|
return
|
|
}
|
|
prog, err := vm.Assemble(string(b))
|
|
if err != nil {
|
|
httpError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"size": len(prog),
|
|
"instructions": len(prog) / 4,
|
|
"limit": s.cfg.ProgramBytes,
|
|
"fits": len(prog) > 0 && len(prog) <= s.cfg.ProgramBytes,
|
|
"program": base64.StdEncoding.EncodeToString(prog),
|
|
})
|
|
}
|
|
|
|
func (s *Server) ships(w http.ResponseWriter, r *http.Request, p store.Player) {
|
|
ships, err := s.st.PlayerShips(r.Context(), p.ID)
|
|
if err != nil {
|
|
fail(w, err)
|
|
return
|
|
}
|
|
type out struct {
|
|
ID int64 `json:"id"`
|
|
Status string `json:"status"`
|
|
ProgramSize int `json:"program_bytes"`
|
|
DownlinkDay int64 `json:"downlink_day"`
|
|
}
|
|
res := make([]out, 0, len(ships))
|
|
for _, sh := range ships {
|
|
res = append(res, out{sh.ID, sh.Status, sh.ProgramSize, sh.DownlinkDay})
|
|
}
|
|
writeJSON(w, http.StatusOK, res)
|
|
}
|
|
|
|
func shipID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
httpError(w, http.StatusBadRequest, "bad ship id")
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// readBody reads at most limit bytes and rejects larger bodies.
|
|
func readBody(w http.ResponseWriter, r *http.Request, limit int) ([]byte, bool) {
|
|
b, err := io.ReadAll(io.LimitReader(r.Body, int64(limit)+1))
|
|
if err != nil || len(b) > limit {
|
|
httpError(w, http.StatusRequestEntityTooLarge, "body too large")
|
|
return nil, false
|
|
}
|
|
return b, true
|
|
}
|
|
|
|
func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player) {
|
|
id, ok := shipID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
b, ok := readBody(w, r, s.cfg.ProgramBytes)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := comms.CheckProgram(b, s.cfg.ProgramBytes); err != nil {
|
|
httpError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := s.st.SetProgram(r.Context(), p.ID, id, b); err != nil {
|
|
fail(w, err)
|
|
return
|
|
}
|
|
s.audit(r, p.ID, "program_upload", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b)})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player) {
|
|
id, ok := shipID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// An optional {"slot": n} chooses which code slot the ship flies. Without it
|
|
// the ship launches with the program already sealed in it (PUT .../program).
|
|
body, ok := readBody(w, r, 1<<10)
|
|
if !ok {
|
|
return
|
|
}
|
|
if strings.TrimSpace(string(body)) != "" {
|
|
var req struct {
|
|
Slot *int `json:"slot"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
httpError(w, http.StatusBadRequest, `body must be {"slot": <1-16>} or empty`)
|
|
return
|
|
}
|
|
if req.Slot != nil {
|
|
s.launchFromSlot(w, r, p, id, *req.Slot)
|
|
return
|
|
}
|
|
}
|
|
if err := s.st.Launch(r.Context(), p.ID, id); err != nil {
|
|
fail(w, err)
|
|
return
|
|
}
|
|
s.audit(r, p.ID, "launch", id, 0, map[string]any{"program": "already sealed in the ship"})
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
func (s *Server) scrap(w http.ResponseWriter, r *http.Request, p store.Player) {
|
|
id, ok := shipID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := s.st.Scrap(r.Context(), p.ID, id); err != nil {
|
|
fail(w, err)
|
|
return
|
|
}
|
|
s.audit(r, p.ID, "scrap", id, 0, nil)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player) {
|
|
id, ok := shipID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
b, ok := readBody(w, r, s.cfg.CommBytes)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := comms.CheckUplink(b, s.cfg.CommBytes); err != nil {
|
|
httpError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := s.st.SetUplink(r.Context(), p.ID, id, b); err != nil {
|
|
fail(w, err)
|
|
return
|
|
}
|
|
// The whole message is kept: it is at most one kilobyte.
|
|
s.audit(r, p.ID, "uplink", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b), "hex": hex.EncodeToString(b)})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) downlinks(w http.ResponseWriter, r *http.Request, p store.Player) {
|
|
id, ok := shipID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, _, err := s.st.Downlink(r.Context(), p.ID, id); err != nil {
|
|
fail(w, err) // not this player's ship
|
|
return
|
|
}
|
|
days, err := s.st.DownlinkDays(r.Context(), p.ID, id)
|
|
if err != nil {
|
|
fail(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"days": days})
|
|
}
|
|
|
|
func (s *Server) downlink(w http.ResponseWriter, r *http.Request, p store.Player) {
|
|
id, ok := shipID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var (
|
|
data []byte
|
|
day int64
|
|
err error
|
|
)
|
|
if q := r.URL.Query().Get("day"); q != "" {
|
|
day, err = strconv.ParseInt(q, 10, 64)
|
|
if err != nil || day < 0 {
|
|
httpError(w, http.StatusBadRequest, "day must be a non-negative integer")
|
|
return
|
|
}
|
|
data, err = s.st.DownlinkAt(r.Context(), p.ID, id, day)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
httpError(w, http.StatusNotFound, "no downlink for that day")
|
|
return
|
|
}
|
|
} else {
|
|
data, day, err = s.st.Downlink(r.Context(), p.ID, id)
|
|
}
|
|
if err != nil {
|
|
fail(w, err)
|
|
return
|
|
}
|
|
if day < 0 {
|
|
httpError(w, http.StatusNotFound, "no downlink yet")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("X-Downlink-Day", strconv.FormatInt(day, 10))
|
|
w.Write(data)
|
|
}
|