diff options
Diffstat (limited to 'cmd/mbf/internal/pkgserver/api.go')
| -rw-r--r-- | cmd/mbf/internal/pkgserver/api.go | 202 |
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, + ) + } +} |
