This commit is contained in:
root
2026-10-01 22:16:48 +02:00
parent b6d1909d7f
commit 9dfdf79fa7
39 changed files with 4560 additions and 181 deletions
+95 -5
View File
@@ -4,6 +4,7 @@ package api
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
@@ -20,8 +21,11 @@ import (
)
type Server struct {
st *store.Store
cfg config.Config
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.
@@ -37,26 +41,44 @@ type Server struct {
// 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)
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header
// 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) *http.ServeMux {
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
}
@@ -117,9 +139,25 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
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 {
@@ -240,6 +278,7 @@ func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player)
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)
}
@@ -271,6 +310,20 @@ func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player)
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)
}
@@ -291,15 +344,52 @@ func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player)
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
}
data, day, err := s.st.Downlink(r.Context(), p.ID, id)
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