diff --git a/.gitignore b/.gitignore index fe160921..96094d86 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # go generate /cmd/hakurei/LICENSE +/cmd/mbf/internal/pkgserver/ui/static /internal/pkg/testdata/testtool /internal/rosa/hakurei_current.tar.gz diff --git a/cmd/mbf/info.go b/cmd/mbf/info.go index f421ce66..15357301 100644 --- a/cmd/mbf/info.go +++ b/cmd/mbf/info.go @@ -17,25 +17,12 @@ func commandInfo( args []string, w io.Writer, writeStatus bool, - reportPath string, + r *rosa.Report, ) (err error) { if len(args) == 0 { return errors.New("info requires at least 1 argument") } - var r *rosa.Report - if reportPath != "" { - if r, err = rosa.OpenReport(reportPath); err != nil { - return err - } - defer func() { - if closeErr := r.Close(); err == nil { - err = closeErr - } - }() - defer r.HandleAccess(&err)() - } - // recovered by HandleAccess mustPrintln := func(a ...any) { if _, _err := fmt.Fprintln(w, a...); _err != nil { diff --git a/cmd/mbf/info_test.go b/cmd/mbf/info_test.go index 16e94364..a7219e9c 100644 --- a/cmd/mbf/info_test.go +++ b/cmd/mbf/info_test.go @@ -95,7 +95,7 @@ status : not in report var ( cm *cache buf strings.Builder - rp string + r *rosa.Report ) if tc.status != nil || tc.report != "" { @@ -108,14 +108,25 @@ status : not in report } if tc.report != "" { - rp = filepath.Join(t.TempDir(), "report") - if err := os.WriteFile( - rp, + pathname := filepath.Join(t.TempDir(), "report") + err := os.WriteFile( + pathname, unsafe.Slice(unsafe.StringData(tc.report), len(tc.report)), 0400, - ); err != nil { + ) + if err != nil { t.Fatal(err) } + + r, err = rosa.OpenReport(pathname) + if err != nil { + t.Fatal(err) + } + defer func() { + if err = r.Close(); err != nil { + t.Fatal(err) + } + }() } if tc.status != nil { @@ -157,7 +168,7 @@ status : not in report tc.args, &buf, cm != nil, - rp, + r, ); !reflect.DeepEqual(err, wantErr) { t.Fatalf("commandInfo: error = %v, want %v", err, wantErr) } 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, + ) + } +} diff --git a/cmd/mbf/internal/pkgserver/api_test.go b/cmd/mbf/internal/pkgserver/api_test.go new file mode 100644 index 00000000..8449c234 --- /dev/null +++ b/cmd/mbf/internal/pkgserver/api_test.go @@ -0,0 +1,181 @@ +package pkgserver + +import ( + "net/http" + "net/http/httptest" + "slices" + "strconv" + "testing" + + "hakurei.app/internal/info" + "hakurei.app/internal/rosa" +) + +// prefix is prepended to every API path. +const prefix = "/api/" + apiVersion + "/" + +func TestAPIInfo(t *testing.T) { + t.Parallel() + + w := httptest.NewRecorder() + handleInfo(w, httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + prefix+"info", + nil, + )) + + resp := w.Result() + checkStatus(t, resp, http.StatusOK) + checkAPIHeader(t, w.Header()) + + checkPayload(t, resp, struct { + Count int `json:"count"` + HakureiVersion string `json:"hakurei_version"` + }{int(rosa.PresetUnexportedStart), info.Version()}) +} + +func TestAPIGet(t *testing.T) { + t.Parallel() + const target = prefix + "get" + + index := newIndex(t) + newRequest := func(suffix string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + index.handleGet(w, httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + target+suffix, + nil, + )) + return w + } + + checkValidate := func(t *testing.T, suffix string, vmin, vmax int, wantErr string) { + t.Run("invalid", func(t *testing.T) { + t.Parallel() + + w := newRequest("?" + suffix + "=invalid") + resp := w.Result() + checkError(t, resp, wantErr, http.StatusBadRequest) + }) + + t.Run("min", func(t *testing.T) { + t.Parallel() + + w := newRequest("?" + suffix + "=" + strconv.Itoa(vmin-1)) + resp := w.Result() + checkError(t, resp, wantErr, http.StatusBadRequest) + + w = newRequest("?" + suffix + "=" + strconv.Itoa(vmin)) + resp = w.Result() + checkStatus(t, resp, http.StatusOK) + }) + + t.Run("max", func(t *testing.T) { + t.Parallel() + + w := newRequest("?" + suffix + "=" + strconv.Itoa(vmax+1)) + resp := w.Result() + checkError(t, resp, wantErr, http.StatusBadRequest) + + w = newRequest("?" + suffix + "=" + strconv.Itoa(vmax)) + resp = w.Result() + checkStatus(t, resp, http.StatusOK) + }) + } + + t.Run("limit", func(t *testing.T) { + t.Parallel() + checkValidate( + t, "index=0&sort=0&limit", 1, 100, + "limit must be an integer between 1 and 100", + ) + }) + + t.Run("index", func(t *testing.T) { + t.Parallel() + checkValidate( + t, "limit=1&sort=0&index", 0, int(rosa.PresetUnexportedStart-1), + "index must be an integer between 0 and "+strconv.Itoa(int(rosa.PresetUnexportedStart-1)), + ) + }) + + t.Run("sort", func(t *testing.T) { + t.Parallel() + checkValidate( + t, "index=0&limit=1&sort", 0, int(sortOrderEnd), + "sort must be an integer between 0 and "+strconv.Itoa(int(sortOrderEnd)), + ) + }) + + checkWithSuffix := func(name, suffix string, want []*metadata) { + t.Run(name, func(t *testing.T) { + t.Parallel() + + w := newRequest(suffix) + resp := w.Result() + checkStatus(t, resp, http.StatusOK) + checkAPIHeader(t, w.Header()) + checkPayloadFunc(t, resp, func(got *struct { + Values []*metadata `json:"values"` + }) bool { + return slices.EqualFunc(got.Values, want, func(a, b *metadata) bool { + return (a.Version == b.Version || + a.Version == rosa.Unversioned || + b.Version == rosa.Unversioned) && + a.HasReport == b.HasReport && + a.Name == b.Name && + a.Description == b.Description && + a.Website == b.Website + }) + }) + + }) + } + + checkWithSuffix("declarationAscending", "?limit=2&index=1&sort=0", []*metadata{ + { + Metadata: rosa.GetMetadata(1), + Version: rosa.Std.Version(1), + }, + { + Metadata: rosa.GetMetadata(2), + Version: rosa.Std.Version(2), + }, + }) + checkWithSuffix("declarationAscending offset", "?limit=3&index=5&sort=0", []*metadata{ + { + Metadata: rosa.GetMetadata(5), + Version: rosa.Std.Version(5), + }, + { + Metadata: rosa.GetMetadata(6), + Version: rosa.Std.Version(6), + }, + { + Metadata: rosa.GetMetadata(7), + Version: rosa.Std.Version(7), + }, + }) + checkWithSuffix("declarationDescending", "?limit=3&index=0&sort=1", []*metadata{ + { + Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 1), + Version: rosa.Std.Version(rosa.PresetUnexportedStart - 1), + }, + { + Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 2), + Version: rosa.Std.Version(rosa.PresetUnexportedStart - 2), + }, + { + Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 3), + Version: rosa.Std.Version(rosa.PresetUnexportedStart - 3), + }, + }) + checkWithSuffix("declarationDescending offset", "?limit=1&index=37&sort=1", []*metadata{ + { + Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 38), + Version: rosa.Std.Version(rosa.PresetUnexportedStart - 38), + }, + }) +} diff --git a/cmd/mbf/internal/pkgserver/index.go b/cmd/mbf/internal/pkgserver/index.go new file mode 100644 index 00000000..911183da --- /dev/null +++ b/cmd/mbf/internal/pkgserver/index.go @@ -0,0 +1,106 @@ +package pkgserver + +import ( + "cmp" + "errors" + "slices" + "strings" + + "hakurei.app/internal/pkg" + "hakurei.app/internal/rosa" +) + +const ( + declarationAscending = iota + declarationDescending + nameAscending + nameDescending + sizeAscending + sizeDescending + + sortOrderEnd = iota - 1 +) + +// packageIndex refers to metadata by name and various sort orders. +type packageIndex struct { + sorts [sortOrderEnd + 1][rosa.PresetUnexportedStart]*metadata + names map[string]*metadata + search searchCache + // Taken from [rosa.Report] if available. + handleAccess func(*error) func() +} + +// metadata holds [rosa.Metadata] extended with additional information. +type metadata struct { + p rosa.PArtifact + *rosa.Metadata + + // Populated via [rosa.Toolchain.Version], [rosa.Unversioned] is equivalent + // to the zero value. Otherwise, the zero value is invalid. + Version string `json:"version,omitempty"` + // Output data size, available if present in report. + Size int64 `json:"size,omitempty"` + // Whether the underlying [pkg.Artifact] is present in the report. + HasReport bool `json:"report"` + + // Ident string encoded ahead of time. + ids string + // Backed by [rosa.Report], access must be prepared by HandleAccess. + status []byte +} + +// populate deterministically populates packageIndex, optionally with a report. +func (index *packageIndex) populate(report *rosa.Report) (err error) { + if report != nil { + defer report.HandleAccess(&err)() + index.handleAccess = report.HandleAccess + } + + var work [rosa.PresetUnexportedStart]*metadata + index.names = make(map[string]*metadata) + ir := pkg.NewIR() + for p := range rosa.PresetUnexportedStart { + m := metadata{ + p: p, + + Metadata: rosa.GetMetadata(p), + Version: rosa.Std.Version(p), + } + if m.Version == "" { + return errors.New("invalid version from " + m.Name) + } + if m.Version == rosa.Unversioned { + m.Version = "" + } + + if report != nil { + id := ir.Ident(rosa.Std.Load(p)) + m.ids = pkg.Encode(id.Value()) + m.status, m.Size = report.ArtifactOf(id) + m.HasReport = m.Size >= 0 + } + + work[p] = &m + index.names[m.Name] = &m + } + + index.sorts[declarationAscending] = work + index.sorts[declarationDescending] = work + slices.Reverse(index.sorts[declarationDescending][:]) + + index.sorts[nameAscending] = work + slices.SortFunc(index.sorts[nameAscending][:], func(a, b *metadata) int { + return strings.Compare(a.Name, b.Name) + }) + index.sorts[nameDescending] = index.sorts[nameAscending] + slices.Reverse(index.sorts[nameDescending][:]) + + index.sorts[sizeAscending] = work + slices.SortFunc(index.sorts[sizeAscending][:], func(a, b *metadata) int { + return cmp.Compare(a.Size, b.Size) + }) + index.sorts[sizeDescending] = index.sorts[sizeAscending] + slices.Reverse(index.sorts[sizeDescending][:]) + + return +} diff --git a/cmd/mbf/internal/pkgserver/index_test.go b/cmd/mbf/internal/pkgserver/index_test.go new file mode 100644 index 00000000..8f3b5530 --- /dev/null +++ b/cmd/mbf/internal/pkgserver/index_test.go @@ -0,0 +1,96 @@ +package pkgserver + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "testing" +) + +// newIndex returns the address of a newly populated packageIndex. +func newIndex(t *testing.T) *packageIndex { + t.Helper() + + var index packageIndex + if err := index.populate(nil); err != nil { + t.Fatalf("populate: error = %v", err) + } + return &index +} + +// checkStatus checks response status code. +func checkStatus(t *testing.T, resp *http.Response, want int) { + t.Helper() + + if resp.StatusCode != want { + t.Errorf( + "StatusCode: %s, want %s", + http.StatusText(resp.StatusCode), + http.StatusText(want), + ) + } +} + +// checkHeader checks the value of a header entry. +func checkHeader(t *testing.T, h http.Header, key, want string) { + t.Helper() + + if got := h.Get(key); got != want { + t.Errorf("%s: %q, want %q", key, got, want) + } +} + +// checkAPIHeader checks common entries set for API endpoints. +func checkAPIHeader(t *testing.T, h http.Header) { + t.Helper() + + checkHeader(t, h, "Content-Type", "application/json; charset=utf-8") + checkHeader(t, h, "Cache-Control", "no-cache, no-store, must-revalidate") + checkHeader(t, h, "Pragma", "no-cache") + checkHeader(t, h, "Expires", "0") +} + +// checkPayloadFunc checks the JSON response of an API endpoint by passing it to f. +func checkPayloadFunc[T any]( + t *testing.T, + resp *http.Response, + f func(got *T) bool, +) { + t.Helper() + + var got T + r := io.Reader(resp.Body) + if testing.Verbose() { + var buf bytes.Buffer + r = io.TeeReader(r, &buf) + defer func() { t.Helper(); t.Log(buf.String()) }() + } + if err := json.NewDecoder(r).Decode(&got); err != nil { + t.Fatalf("Decode: error = %v", err) + } + + if !f(&got) { + t.Errorf("Body: %#v", got) + } +} + +// checkPayload checks the JSON response of an API endpoint. +func checkPayload[T any](t *testing.T, resp *http.Response, want T) { + t.Helper() + + checkPayloadFunc(t, resp, func(got *T) bool { + return reflect.DeepEqual(got, &want) + }) +} + +func checkError(t *testing.T, resp *http.Response, error string, code int) { + t.Helper() + + checkStatus(t, resp, code) + if got, _ := io.ReadAll(resp.Body); string(got) != fmt.Sprintln(error) { + t.Errorf("Body: %q, want %q", string(got), error) + } +} diff --git a/cmd/mbf/internal/pkgserver/search.go b/cmd/mbf/internal/pkgserver/search.go new file mode 100644 index 00000000..5756512f --- /dev/null +++ b/cmd/mbf/internal/pkgserver/search.go @@ -0,0 +1,81 @@ +package pkgserver + +import ( + "cmp" + "maps" + "regexp" + "slices" + "time" +) + +type searchCache map[string]searchCacheEntry +type searchResult struct { + NameIndices [][]int `json:"name_matches"` + DescIndices [][]int `json:"desc_matches,omitempty"` + Score float64 `json:"score"` + *metadata +} +type searchCacheEntry struct { + query string + results []searchResult + expiry time.Time +} + +func (index *packageIndex) performSearchQuery(limit int, i int, search string, desc bool) (int, []searchResult, error) { + query := search + if desc { + query += ";withDesc" + } + entry, ok := index.search[query] + if ok && len(entry.results) > 0 { + return len(entry.results), entry.results[min(i, len(entry.results)-1):min(i+limit, len(entry.results))], nil + } + + regex, err := regexp.Compile(search) + if err != nil { + return 0, make([]searchResult, 0), err + } + res := make([]searchResult, 0) + for p := range maps.Values(index.names) { + nameIndices := regex.FindAllIndex([]byte(p.Name), -1) + var descIndices [][]int = nil + if desc { + descIndices = regex.FindAllIndex([]byte(p.Description), -1) + } + if nameIndices == nil && descIndices == nil { + continue + } + score := float64(indexsum(nameIndices)) / (float64(len(nameIndices)) + 1) + if desc { + score += float64(indexsum(descIndices)) / (float64(len(descIndices)) + 1) / 10.0 + } + res = append(res, searchResult{ + NameIndices: nameIndices, + DescIndices: descIndices, + Score: score, + metadata: p, + }) + } + slices.SortFunc(res[:], func(a, b searchResult) int { return -cmp.Compare(a.Score, b.Score) }) + expiry := time.Now().Add(1 * time.Minute) + entry = searchCacheEntry{ + query: search, + results: res, + expiry: expiry, + } + index.search[query] = entry + + return len(res), res[i:min(i+limit, len(entry.results))], nil +} +func (s *searchCache) clean() { + maps.DeleteFunc(*s, func(_ string, v searchCacheEntry) bool { + return v.expiry.Before(time.Now()) + }) +} +func indexsum(in [][]int) int { + sum := 0 + for i := 0; i < len(in); i++ { + sum += in[i][1] - in[i][0] + } + return sum +} diff --git a/cmd/mbf/internal/pkgserver/ui/index.html b/cmd/mbf/internal/pkgserver/ui/index.html new file mode 100644 index 00000000..d2d71cbd --- /dev/null +++ b/cmd/mbf/internal/pkgserver/ui/index.html @@ -0,0 +1,57 @@ + + +
+ + + +Showing entries .
+ + + + + + + + + +Showing search results for query "".
+ + +Sorted by best match
+| Loading... |
Size: ${toByteSizeString(entry.size)} (${entry.size})
` : "" + let n: string + let d: string + if ('name_matches' in entry) { + n = `${escapeHtml((entry as PackageIndexEntry).description)}
` : "" + } + let w = entry.website != null ? `Website` : "" + let r = entry.report ? `Log (View | Download)` : "" + let row =${escapeHtml(this.getSearchQuery())}`
+ }
+ }
+
+ getSearchQuery(): string {
+ let queryString = document.getElementById("search")!;
+ return (queryString as HTMLInputElement).value
+ }
+
+ getIncludeDescriptions(): boolean {
+ let includeDesc = document.getElementById("include-desc")!;
+ return (includeDesc as HTMLInputElement).checked
+ }
+
+ updateListings() {
+ if (this.search) {
+ searchRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSearchQuery(), this.getIncludeDescriptions())
+ .then(res => {
+ let table = document.getElementById("pkg-list")!
+ table.innerHTML = ''
+ for (let row of res.values!) {
+ table.appendChild(entryToHTML(row))
+ }
+ STATE.maxTotal = res.count!
+ STATE.updateRange()
+ if(res.count! < 1) {
+ exitSearch()
+ alert("no results found!")
+ }
+ })
+ } else {
+ getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder())
+ .then(res => {
+ let table = document.getElementById("pkg-list")!
+ table.innerHTML = ''
+ for (let row of res.values!) {
+ table.appendChild(entryToHTML(row))
+ }
+ })
+ }
+ }
+}
+
+let STATE: State
+
+
+function lastPageIndex(): number {
+ return Math.floor(STATE.getMaxTotal() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()
+}
+
+function setPage(page: number) {
+ STATE.setEntryIndex(Math.max(0, Math.min(STATE.getEntriesPerPage() * (page - 1), lastPageIndex())))
+}
+
+
+function escapeHtml(str?: string): string {
+ let out: string = ''
+ if (str == undefined) return ""
+ for (let i = 0; i < str.length; i++) {
+ out += escapeHtmlChar(str[i])
+ }
+ return out
+}
+
+function escapeHtmlChar(char: string): string {
+ if (char.length != 1) return char
+ switch (char[0]) {
+ case '&':
+ return "&"
+ case '<':
+ return "<"
+ case '>':
+ return ">"
+ case '"':
+ return """
+ case "'":
+ return "'"
+ default:
+ return char
+ }
+}
+
+function firstPage() {
+ STATE.setEntryIndex(0)
+}
+
+function prevPage() {
+ let index = STATE.getEntryIndex()
+ STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage()))
+}
+
+function lastPage() {
+ STATE.setEntryIndex(lastPageIndex())
+}
+
+function nextPage() {
+ let index = STATE.getEntryIndex()
+ STATE.setEntryIndex(Math.min(lastPageIndex(), index + STATE.getEntriesPerPage()))
+}
+
+function doSearch() {
+ document.getElementById("top-controls-regular")!.toggleAttribute("hidden");
+ document.getElementById("search-top-controls")!.toggleAttribute("hidden");
+ STATE.search = true;
+ STATE.setEntryIndex(0);
+}
+
+function exitSearch() {
+ document.getElementById("top-controls-regular")!.toggleAttribute("hidden");
+ document.getElementById("search-top-controls")!.toggleAttribute("hidden");
+ STATE.search = false;
+ STATE.setMaxTotal(STATE.maxEntries)
+ STATE.setEntryIndex(0)
+}
+
+function main() {
+ STATE = new State()
+ infoRequest()
+ .then(res => {
+ STATE.maxEntries = res.count!
+ STATE.setMaxTotal(STATE.maxEntries)
+ document.getElementById("hakurei-version")!.textContent = res.hakurei_version!
+ STATE.updateRange()
+ STATE.updateListings()
+ })
+ for (let e of document.getElementsByClassName("page-number")) {
+ e.addEventListener("change", (_) => {
+ setPage(parseInt((e as HTMLInputElement).value))
+ })
+ }
+ document.getElementById("count")?.addEventListener("change", (event) => {
+ STATE.setEntriesPerPage(parseInt((event.target as HTMLSelectElement).value))
+ })
+ document.getElementById("sort")?.addEventListener("change", (event) => {
+ STATE.setSortOrder(parseInt((event.target as HTMLSelectElement).value))
+ })
+ document.getElementById("search")?.addEventListener("keyup", (event) => {
+ if (event.key === 'Enter') doSearch()
+ })
+}
\ No newline at end of file
diff --git a/cmd/mbf/internal/pkgserver/ui/style.css b/cmd/mbf/internal/pkgserver/ui/style.css
new file mode 100644
index 00000000..b4f281ac
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/style.css
@@ -0,0 +1,21 @@
+.page-number {
+ width: 2em;
+ text-align: center;
+}
+.page-number {
+ width: 2em;
+ text-align: center;
+}
+
+@media (prefers-color-scheme: dark) {
+ html {
+ background-color: #2c2c2c;
+ color: ghostwhite;
+ }
+}
+@media (prefers-color-scheme: light) {
+ html {
+ background-color: #d3d3d3;
+ color: black;
+ }
+}
\ No newline at end of file
diff --git a/cmd/mbf/internal/pkgserver/ui/tsconfig.json b/cmd/mbf/internal/pkgserver/ui/tsconfig.json
new file mode 100644
index 00000000..24df4936
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "target": "ES2024",
+ "strict": true,
+ "alwaysStrict": true,
+ "outDir": "static"
+ }
+}
\ No newline at end of file
diff --git a/cmd/mbf/internal/pkgserver/ui/ui.go b/cmd/mbf/internal/pkgserver/ui/ui.go
new file mode 100644
index 00000000..3fc0d89c
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/ui.go
@@ -0,0 +1,9 @@
+// Package ui holds the static web UI.
+package ui
+
+import "net/http"
+
+// Register arranges for mux to serve the embedded frontend.
+func Register(mux *http.ServeMux) {
+ mux.Handle("GET /", http.FileServer(http.FS(static)))
+}
diff --git a/cmd/mbf/internal/pkgserver/ui/ui_full.go b/cmd/mbf/internal/pkgserver/ui/ui_full.go
new file mode 100644
index 00000000..564787dc
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/ui_full.go
@@ -0,0 +1,21 @@
+//go:build frontend
+
+package ui
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:generate tsc
+//go:generate cp index.html style.css static
+//go:embed static
+var _static embed.FS
+
+var static = func() fs.FS {
+ if f, err := fs.Sub(_static, "static"); err != nil {
+ panic(err)
+ } else {
+ return f
+ }
+}()
diff --git a/cmd/mbf/internal/pkgserver/ui/ui_stub.go b/cmd/mbf/internal/pkgserver/ui/ui_stub.go
new file mode 100644
index 00000000..daf010f8
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/ui_stub.go
@@ -0,0 +1,7 @@
+//go:build !frontend
+
+package ui
+
+import "testing/fstest"
+
+var static fstest.MapFS
diff --git a/cmd/mbf/main.go b/cmd/mbf/main.go
index 0d44bdbc..291e91ef 100644
--- a/cmd/mbf/main.go
+++ b/cmd/mbf/main.go
@@ -20,6 +20,7 @@ import (
"io"
"log"
"net"
+ "net/http"
"os"
"os/signal"
"path/filepath"
@@ -41,6 +42,9 @@ import (
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
"hakurei.app/message"
+
+ "hakurei.app/cmd/mbf/internal/pkgserver"
+ "hakurei.app/cmd/mbf/internal/pkgserver/ui"
)
func main() {
@@ -180,6 +184,7 @@ func main() {
{
var (
+ flagBind string
flagStatus bool
flagReport string
)
@@ -187,8 +192,52 @@ func main() {
"info",
"Display out-of-band metadata of an artifact",
func(args []string) (err error) {
- return commandInfo(&cm, args, os.Stdout, flagStatus, flagReport)
+ const shutdownTimeout = 15 * time.Second
+
+ var r *rosa.Report
+ if flagReport != "" {
+ if r, err = rosa.OpenReport(flagReport); err != nil {
+ return err
+ }
+ defer func() {
+ if closeErr := r.Close(); err == nil {
+ err = closeErr
+ }
+ }()
+ defer r.HandleAccess(&err)()
+ }
+
+ if flagBind == "" {
+ return commandInfo(&cm, args, os.Stdout, flagStatus, r)
+ }
+
+ var mux http.ServeMux
+ ui.Register(&mux)
+ if err = pkgserver.Register(ctx, &mux, r); err != nil {
+ return
+ }
+
+ server := http.Server{Addr: flagBind, Handler: &mux}
+ go func() {
+ <-ctx.Done()
+ cc, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ if _err := server.Shutdown(cc); _err != nil {
+ log.Fatal(_err)
+ }
+ }()
+
+ msg.Verbosef("listening on %q", flagBind)
+ err = server.ListenAndServe()
+ if errors.Is(err, http.ErrServerClosed) {
+ err = nil
+ }
+ return
},
+ ).Flag(
+ &flagBind,
+ "bind", command.StringFlag(""),
+ "TCP address for the server to listen on",
).Flag(
&flagStatus,
"status", command.BoolFlag(false),