aboutsummaryrefslogtreecommitdiffhomepage
path: root/cmd/mbf/internal/pkgserver/api.go
diff options
context:
space:
mode:
authormae <mae@maestoso.online>2026-03-04 22:50:58 -0600
committerOphestra <cat@gensokyo.uk>2026-05-02 05:05:56 +0900
commit1d5d063d6a443c9d4a1206d2743e4824411da956 (patch)
treeb32105cf3896f394be91099cfbfb67785de39385 /cmd/mbf/internal/pkgserver/api.go
parente61628a34ecccae0feb5e059a2f808977261fa0e (diff)
cmd/mbf: package status dashboard
This displays package metadata with optional status from a report.
Diffstat (limited to 'cmd/mbf/internal/pkgserver/api.go')
-rw-r--r--cmd/mbf/internal/pkgserver/api.go202
1 files changed, 202 insertions, 0 deletions
diff --git a/cmd/mbf/internal/pkgserver/api.go b/cmd/mbf/internal/pkgserver/api.go
new file mode 100644
index 00000000..599ea522
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/api.go
@@ -0,0 +1,202 @@
+// Package pkgserver implements the package metadata service backend.
+package pkgserver
+
+import (
+ "context"
+ "encoding/json"
+ "log"
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+ "sync"
+ "time"
+
+ "hakurei.app/internal/info"
+ "hakurei.app/internal/rosa"
+)
+
+// for lazy initialisation of serveInfo
+var (
+ infoPayload struct {
+ // Current package count.
+ Count int `json:"count"`
+ // Hakurei version, set at link time.
+ HakureiVersion string `json:"hakurei_version"`
+ }
+ infoPayloadOnce sync.Once
+)
+
+// handleInfo writes constant system information.
+func handleInfo(w http.ResponseWriter, _ *http.Request) {
+ infoPayloadOnce.Do(func() {
+ infoPayload.Count = int(rosa.PresetUnexportedStart)
+ infoPayload.HakureiVersion = info.Version()
+ })
+ // TODO(mae): cache entire response if no additional fields are planned
+ writeAPIPayload(w, infoPayload)
+}
+
+// newStatusHandler returns a [http.HandlerFunc] that offers status files for
+// viewing or download, if available.
+func (index *packageIndex) newStatusHandler(disposition bool) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ m, ok := index.names[path.Base(r.URL.Path)]
+ if !ok || !m.HasReport {
+ http.NotFound(w, r)
+ return
+ }
+
+ contentType := "text/plain; charset=utf-8"
+ if disposition {
+ contentType = "application/octet-stream"
+
+ // quoting like this is unsound, but okay, because metadata is hardcoded
+ contentDisposition := `attachment; filename="`
+ contentDisposition += m.Name + "-"
+ if m.Version != "" {
+ contentDisposition += m.Version + "-"
+ }
+ contentDisposition += m.ids + `.log"`
+ w.Header().Set("Content-Disposition", contentDisposition)
+ }
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ if err := func() (err error) {
+ defer index.handleAccess(&err)()
+ _, err = w.Write(m.status)
+ return
+ }(); err != nil {
+ log.Println(err)
+ http.Error(
+ w, "cannot deliver status, contact maintainers",
+ http.StatusInternalServerError,
+ )
+ }
+ }
+}
+
+// handleGet writes a slice of metadata with specified order.
+func (index *packageIndex) handleGet(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ limit, err := strconv.Atoi(q.Get("limit"))
+ if err != nil || limit > 100 || limit < 1 {
+ http.Error(
+ w, "limit must be an integer between 1 and 100",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ i, err := strconv.Atoi(q.Get("index"))
+ if err != nil || i >= len(index.sorts[0]) || i < 0 {
+ http.Error(
+ w, "index must be an integer between 0 and "+
+ strconv.Itoa(int(rosa.PresetUnexportedStart-1)),
+ http.StatusBadRequest,
+ )
+ return
+ }
+ sort, err := strconv.Atoi(q.Get("sort"))
+ if err != nil || sort >= len(index.sorts) || sort < 0 {
+ http.Error(
+ w, "sort must be an integer between 0 and "+
+ strconv.Itoa(sortOrderEnd),
+ http.StatusBadRequest,
+ )
+ return
+ }
+ values := index.sorts[sort][i:min(i+limit, len(index.sorts[sort]))]
+ writeAPIPayload(w, &struct {
+ Values []*metadata `json:"values"`
+ }{values})
+}
+
+func (index *packageIndex) handleSearch(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ limit, err := strconv.Atoi(q.Get("limit"))
+ if err != nil || limit > 100 || limit < 1 {
+ http.Error(
+ w, "limit must be an integer between 1 and 100",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ i, err := strconv.Atoi(q.Get("index"))
+ if err != nil || i >= len(index.sorts[0]) || i < 0 {
+ http.Error(
+ w, "index must be an integer between 0 and "+
+ strconv.Itoa(int(rosa.PresetUnexportedStart-1)),
+ http.StatusBadRequest,
+ )
+ return
+ }
+ search, err := url.QueryUnescape(q.Get("search"))
+ if len(search) > 100 || err != nil {
+ http.Error(
+ w, "search must be a string between 0 and 100 characters long",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ desc := q.Get("desc") == "true"
+ n, res, err := index.performSearchQuery(limit, i, search, desc)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ writeAPIPayload(w, &struct {
+ Count int `json:"count"`
+ Values []searchResult `json:"values"`
+ }{n, res})
+}
+
+// apiVersion is the name of the current API revision, as part of the pattern.
+const apiVersion = "v1"
+
+// registerAPI registers API handler functions.
+func (index *packageIndex) registerAPI(mux *http.ServeMux) {
+ mux.HandleFunc("GET /api/"+apiVersion+"/info", handleInfo)
+ mux.HandleFunc("GET /api/"+apiVersion+"/get", index.handleGet)
+ mux.HandleFunc("GET /api/"+apiVersion+"/search", index.handleSearch)
+ mux.HandleFunc("GET /api/"+apiVersion+"/status/", index.newStatusHandler(false))
+ mux.HandleFunc("GET /status/", index.newStatusHandler(true))
+}
+
+// Register arranges for mux to service API requests.
+func Register(ctx context.Context, mux *http.ServeMux, report *rosa.Report) error {
+ var index packageIndex
+ index.search = make(searchCache)
+ if err := index.populate(report); err != nil {
+ return err
+ }
+ ticker := time.NewTicker(1 * time.Minute)
+ go func() {
+ for {
+ select {
+ case <-ctx.Done():
+ ticker.Stop()
+ return
+ case <-ticker.C:
+ index.search.clean()
+ }
+ }
+ }()
+ index.registerAPI(mux)
+ return nil
+}
+
+// writeAPIPayload sets headers common to API responses and encodes payload as
+// JSON for the response body.
+func writeAPIPayload(w http.ResponseWriter, payload any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ w.Header().Set("Pragma", "no-cache")
+ w.Header().Set("Expires", "0")
+
+ if err := json.NewEncoder(w).Encode(payload); err != nil {
+ log.Println(err)
+ http.Error(
+ w, "cannot encode payload, contact maintainers",
+ http.StatusInternalServerError,
+ )
+ }
+}