From 834db962958652502aba88362dbe91df2ee618ee Mon Sep 17 00:00:00 2001 From: Ophestra Date: Tue, 15 Sep 2026 00:06:21 +0900 Subject: cmd/mbf: rename pkgsite This name is less ambiguous than pkgserver. Signed-off-by: Ophestra --- .gitignore | 2 +- cmd/mbf/internal/pkgserver/api.go | 202 ----------------- cmd/mbf/internal/pkgserver/api_test.go | 111 ---------- cmd/mbf/internal/pkgserver/index.go | 108 --------- cmd/mbf/internal/pkgserver/index_test.go | 96 -------- cmd/mbf/internal/pkgserver/search.go | 81 ------- cmd/mbf/internal/pkgserver/ui/index.html | 58 ----- cmd/mbf/internal/pkgserver/ui/index.ts | 331 ---------------------------- cmd/mbf/internal/pkgserver/ui/style.css | 21 -- cmd/mbf/internal/pkgserver/ui/tsconfig.json | 8 - cmd/mbf/internal/pkgserver/ui/ui.go | 9 - cmd/mbf/internal/pkgserver/ui/ui_full.go | 21 -- cmd/mbf/internal/pkgserver/ui/ui_stub.go | 7 - cmd/mbf/internal/pkgsite/api.go | 202 +++++++++++++++++ cmd/mbf/internal/pkgsite/api_test.go | 111 ++++++++++ cmd/mbf/internal/pkgsite/index.go | 108 +++++++++ cmd/mbf/internal/pkgsite/index_test.go | 96 ++++++++ cmd/mbf/internal/pkgsite/search.go | 81 +++++++ cmd/mbf/internal/pkgsite/ui/index.html | 58 +++++ cmd/mbf/internal/pkgsite/ui/index.ts | 331 ++++++++++++++++++++++++++++ cmd/mbf/internal/pkgsite/ui/style.css | 21 ++ cmd/mbf/internal/pkgsite/ui/tsconfig.json | 8 + cmd/mbf/internal/pkgsite/ui/ui.go | 9 + cmd/mbf/internal/pkgsite/ui/ui_full.go | 21 ++ cmd/mbf/internal/pkgsite/ui/ui_stub.go | 7 + cmd/mbf/main.go | 6 +- 26 files changed, 1057 insertions(+), 1057 deletions(-) delete mode 100644 cmd/mbf/internal/pkgserver/api.go delete mode 100644 cmd/mbf/internal/pkgserver/api_test.go delete mode 100644 cmd/mbf/internal/pkgserver/index.go delete mode 100644 cmd/mbf/internal/pkgserver/index_test.go delete mode 100644 cmd/mbf/internal/pkgserver/search.go delete mode 100644 cmd/mbf/internal/pkgserver/ui/index.html delete mode 100644 cmd/mbf/internal/pkgserver/ui/index.ts delete mode 100644 cmd/mbf/internal/pkgserver/ui/style.css delete mode 100644 cmd/mbf/internal/pkgserver/ui/tsconfig.json delete mode 100644 cmd/mbf/internal/pkgserver/ui/ui.go delete mode 100644 cmd/mbf/internal/pkgserver/ui/ui_full.go delete mode 100644 cmd/mbf/internal/pkgserver/ui/ui_stub.go create mode 100644 cmd/mbf/internal/pkgsite/api.go create mode 100644 cmd/mbf/internal/pkgsite/api_test.go create mode 100644 cmd/mbf/internal/pkgsite/index.go create mode 100644 cmd/mbf/internal/pkgsite/index_test.go create mode 100644 cmd/mbf/internal/pkgsite/search.go create mode 100644 cmd/mbf/internal/pkgsite/ui/index.html create mode 100644 cmd/mbf/internal/pkgsite/ui/index.ts create mode 100644 cmd/mbf/internal/pkgsite/ui/style.css create mode 100644 cmd/mbf/internal/pkgsite/ui/tsconfig.json create mode 100644 cmd/mbf/internal/pkgsite/ui/ui.go create mode 100644 cmd/mbf/internal/pkgsite/ui/ui_full.go create mode 100644 cmd/mbf/internal/pkgsite/ui/ui_stub.go diff --git a/.gitignore b/.gitignore index cd4c42ea..022468e6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ # go generate /cmd/hakurei/LICENSE -/cmd/mbf/internal/pkgserver/ui/static +/cmd/mbf/internal/pkgsite/ui/static /pkg/internal/testtool/testtool /internal/rosa/hakurei_current.tar.gz diff --git a/cmd/mbf/internal/pkgserver/api.go b/cmd/mbf/internal/pkgserver/api.go deleted file mode 100644 index 68ccd471..00000000 --- a/cmd/mbf/internal/pkgserver/api.go +++ /dev/null @@ -1,202 +0,0 @@ -// 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 = len(rosa.Collect()) - 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(len(index.sorts[0])-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(len(index.sorts[0])-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 deleted file mode 100644 index 1552fec9..00000000 --- a/cmd/mbf/internal/pkgserver/api_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package pkgserver - -import ( - "net/http" - "net/http/httptest" - "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"` - }{len(rosa.Collect()), 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", - ) - }) - - count := len(rosa.Collect()) - t.Run("index", func(t *testing.T) { - t.Parallel() - checkValidate( - t, "limit=1&sort=0&index", 0, count-1, - "index must be an integer between 0 and "+strconv.Itoa(count-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)), - ) - }) -} diff --git a/cmd/mbf/internal/pkgserver/index.go b/cmd/mbf/internal/pkgserver/index.go deleted file mode 100644 index 2a9b6de5..00000000 --- a/cmd/mbf/internal/pkgserver/index.go +++ /dev/null @@ -1,108 +0,0 @@ -package pkgserver - -import ( - "cmp" - "errors" - "slices" - "strings" - - "hakurei.app/internal/rosa" - "hakurei.app/pkg" -) - -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][]*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 { - handle rosa.ArtifactH - *rosa.Metadata - - // Copied from [rosa.Metadata], [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 - } - - handles := rosa.Collect() - work := make([]*metadata, len(handles)) - index.names = make(map[string]*metadata) - ir := pkg.NewIR() - for i, handle := range handles { - meta, a := rosa.MustLoad(handle) - m := metadata{ - handle: handle, - - Metadata: meta, - Version: meta.Version, - } - if m.Version == "" { - return errors.New("invalid version from " + m.Name) - } - if m.Version == rosa.Unversioned { - m.Version = "" - } - - if report != nil { - id := ir.Ident(a) - m.ids = pkg.Encode(id.Value()) - m.status, m.Size = report.ArtifactOf(id) - m.HasReport = m.Size >= 0 - } - - work[i] = &m - index.names[m.Name] = &m - } - - index.sorts[declarationAscending] = work - index.sorts[declarationDescending] = slices.Clone(work) - slices.Reverse(index.sorts[declarationDescending][:]) - - index.sorts[nameAscending] = slices.Clone(work) - slices.SortFunc(index.sorts[nameAscending][:], func(a, b *metadata) int { - return strings.Compare(a.Name, b.Name) - }) - index.sorts[nameDescending] = slices.Clone(index.sorts[nameAscending]) - slices.Reverse(index.sorts[nameDescending][:]) - - index.sorts[sizeAscending] = slices.Clone(work) - slices.SortFunc(index.sorts[sizeAscending][:], func(a, b *metadata) int { - return cmp.Compare(a.Size, b.Size) - }) - index.sorts[sizeDescending] = slices.Clone(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 deleted file mode 100644 index 8f3b5530..00000000 --- a/cmd/mbf/internal/pkgserver/index_test.go +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 15947804..00000000 --- a/cmd/mbf/internal/pkgserver/search.go +++ /dev/null @@ -1,81 +0,0 @@ -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 := range in { - 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 deleted file mode 100644 index 6a5d72b4..00000000 --- a/cmd/mbf/internal/pkgserver/ui/index.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - Rosa OS Packages - - - -

Rosa OS Packages

-
-

Showing entries .

- - - - - - - -
-
-
- - - - -
Loading...
- -
-

©Hakurei (unknown). Licensed under the MIT license.

-
- - - diff --git a/cmd/mbf/internal/pkgserver/ui/index.ts b/cmd/mbf/internal/pkgserver/ui/index.ts deleted file mode 100644 index 0784b81f..00000000 --- a/cmd/mbf/internal/pkgserver/ui/index.ts +++ /dev/null @@ -1,331 +0,0 @@ -interface PackageIndexEntry { - name: string - size?: number - description?: string - website?: string - version?: string - report?: boolean -} - -function entryToHTML(entry: PackageIndexEntry | SearchResult): HTMLTableRowElement { - let v = entry.version != null ? `${escapeHtml(entry.version)}` : "" - let s = entry.size != null && entry.size > 0 ? `

Size: ${toByteSizeString(entry.size)} (${entry.size})

` : "" - let n: string - let d: string - if ('name_matches' in entry) { - n = `

${nameMatches(entry as SearchResult)} ${v}

` - } else { - n = `

${escapeHtml(entry.name)} ${v}

` - } - if ('desc_matches' in entry && STATE.getIncludeDescriptions()) { - d = descMatches(entry as SearchResult) - } else { - d = (entry as PackageIndexEntry).description != null ? `

${escapeHtml((entry as PackageIndexEntry).description)}

` : "" - } - let w = entry.website != null ? `Website` : "" - let r = entry.report ? `Log (View | Download)` : "" - let row = (document.createElement('tr')) - row.innerHTML = ` - ${n} - ${d} - ${s} - ${w} - ${r} - ` - return row -} - -function nameMatches(sr: SearchResult): string { - return markMatches(sr.name, sr.name_matches) -} - -function descMatches(sr: SearchResult): string { - return markMatches(sr.description!, sr.desc_matches) -} - -function markMatches(str: string, indices: [number, number][]): string { - if (indices == null) { - return str - } - let out: string = "" - let j = 0 - for (let i = 0; i < str.length; i++) { - if (j < indices.length) { - if (i === indices[j][0]) { - out += `${escapeHtmlChar(str[i])}` - continue - } - if (i === indices[j][1]) { - out += `${escapeHtmlChar(str[i])}` - j++ - continue - } - } - out += escapeHtmlChar(str[i]) - } - if (indices[j] !== undefined) { - out += "" - } - return out -} - -function toByteSizeString(bytes: number): string { - if (bytes == null) return `unspecified` - if (bytes < 1024) return `${bytes}B` - if (bytes < Math.pow(1024, 2)) return `${(bytes / 1024).toFixed(2)}kiB` - if (bytes < Math.pow(1024, 3)) return `${(bytes / Math.pow(1024, 2)).toFixed(2)}MiB` - if (bytes < Math.pow(1024, 4)) return `${(bytes / Math.pow(1024, 3)).toFixed(2)}GiB` - if (bytes < Math.pow(1024, 5)) return `${(bytes / Math.pow(1024, 4)).toFixed(2)}TiB` - return "not only is it big, it's large" -} - -const API_VERSION = 1 -const ENDPOINT = `/api/v${API_VERSION}` - -interface InfoPayload { - count?: number - hakurei_version?: string -} - -async function infoRequest(): Promise { - const res = await fetch(`${ENDPOINT}/info`) - const payload = await res.json() - return payload as InfoPayload -} - -interface GetPayload { - values?: PackageIndexEntry[] -} - -enum SortOrders { - DeclarationAscending, - DeclarationDescending, - NameAscending, - NameDescending -} - -async function getRequest(limit: number, index: number, sort: SortOrders): Promise { - const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`) - const payload = await res.json() - return payload as GetPayload -} - -interface SearchResult extends PackageIndexEntry { - name_matches: [number, number][] - desc_matches: [number, number][] - score: number -} - -interface SearchPayload { - count?: number - values?: SearchResult[] -} - -async function searchRequest(limit: number, index: number, search: string, desc: boolean): Promise { - const res = await fetch(`${ENDPOINT}/search?limit=${limit}&index=${index}&search=${encodeURIComponent(search)}&desc=${desc}`) - if (!res.ok) { - exitSearch() - alert("invalid search query!") - return Promise.reject(res.statusText) - } - const payload = await res.json() - return payload as SearchPayload -} - -class State { - entriesPerPage: number = 10 - entryIndex: number = 0 - maxTotal: number = 0 - maxEntries: number = 0 - sort: SortOrders = SortOrders.DeclarationAscending - search: boolean = false - - getEntriesPerPage(): number { - return this.entriesPerPage - } - - setEntriesPerPage(entriesPerPage: number) { - this.entriesPerPage = entriesPerPage - this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage) - } - - getEntryIndex(): number { - return this.entryIndex - } - - setEntryIndex(entryIndex: number) { - this.entryIndex = entryIndex - this.updatePage() - this.updateRange() - this.updateListings() - } - - getMaxTotal(): number { - return this.maxTotal - } - - setMaxTotal(max: number) { - this.maxTotal = max - } - - getSortOrder(): SortOrders { - return this.sort - } - - setSortOrder(sortOrder: SortOrders) { - this.sort = sortOrder - this.setEntryIndex(0) - } - - updatePage() { - let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage()) - for (let e of document.getElementsByClassName("page-number")) { - (e as HTMLInputElement).value = String(page) - } - } - - updateRange() { - let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxTotal()) - document.getElementById("entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxTotal()}` - if (this.search) { - document.getElementById("search-entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.maxTotal}/${this.maxEntries}` - document.getElementById("search-query")!.innerHTML = `${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 deleted file mode 100644 index b4f281ac..00000000 --- a/cmd/mbf/internal/pkgserver/ui/style.css +++ /dev/null @@ -1,21 +0,0 @@ -.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 deleted file mode 100644 index 24df4936..00000000 --- a/cmd/mbf/internal/pkgserver/ui/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index 3fc0d89c..00000000 --- a/cmd/mbf/internal/pkgserver/ui/ui.go +++ /dev/null @@ -1,9 +0,0 @@ -// 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 deleted file mode 100644 index 564787dc..00000000 --- a/cmd/mbf/internal/pkgserver/ui/ui_full.go +++ /dev/null @@ -1,21 +0,0 @@ -//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 deleted file mode 100644 index daf010f8..00000000 --- a/cmd/mbf/internal/pkgserver/ui/ui_stub.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !frontend - -package ui - -import "testing/fstest" - -var static fstest.MapFS diff --git a/cmd/mbf/internal/pkgsite/api.go b/cmd/mbf/internal/pkgsite/api.go new file mode 100644 index 00000000..a3094359 --- /dev/null +++ b/cmd/mbf/internal/pkgsite/api.go @@ -0,0 +1,202 @@ +// Package pkgsite implements the package metadata website. +package pkgsite + +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 = len(rosa.Collect()) + 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(len(index.sorts[0])-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(len(index.sorts[0])-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/pkgsite/api_test.go b/cmd/mbf/internal/pkgsite/api_test.go new file mode 100644 index 00000000..d2c21d81 --- /dev/null +++ b/cmd/mbf/internal/pkgsite/api_test.go @@ -0,0 +1,111 @@ +package pkgsite + +import ( + "net/http" + "net/http/httptest" + "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"` + }{len(rosa.Collect()), 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", + ) + }) + + count := len(rosa.Collect()) + t.Run("index", func(t *testing.T) { + t.Parallel() + checkValidate( + t, "limit=1&sort=0&index", 0, count-1, + "index must be an integer between 0 and "+strconv.Itoa(count-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)), + ) + }) +} diff --git a/cmd/mbf/internal/pkgsite/index.go b/cmd/mbf/internal/pkgsite/index.go new file mode 100644 index 00000000..760806c0 --- /dev/null +++ b/cmd/mbf/internal/pkgsite/index.go @@ -0,0 +1,108 @@ +package pkgsite + +import ( + "cmp" + "errors" + "slices" + "strings" + + "hakurei.app/internal/rosa" + "hakurei.app/pkg" +) + +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][]*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 { + handle rosa.ArtifactH + *rosa.Metadata + + // Copied from [rosa.Metadata], [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 + } + + handles := rosa.Collect() + work := make([]*metadata, len(handles)) + index.names = make(map[string]*metadata) + ir := pkg.NewIR() + for i, handle := range handles { + meta, a := rosa.MustLoad(handle) + m := metadata{ + handle: handle, + + Metadata: meta, + Version: meta.Version, + } + if m.Version == "" { + return errors.New("invalid version from " + m.Name) + } + if m.Version == rosa.Unversioned { + m.Version = "" + } + + if report != nil { + id := ir.Ident(a) + m.ids = pkg.Encode(id.Value()) + m.status, m.Size = report.ArtifactOf(id) + m.HasReport = m.Size >= 0 + } + + work[i] = &m + index.names[m.Name] = &m + } + + index.sorts[declarationAscending] = work + index.sorts[declarationDescending] = slices.Clone(work) + slices.Reverse(index.sorts[declarationDescending][:]) + + index.sorts[nameAscending] = slices.Clone(work) + slices.SortFunc(index.sorts[nameAscending][:], func(a, b *metadata) int { + return strings.Compare(a.Name, b.Name) + }) + index.sorts[nameDescending] = slices.Clone(index.sorts[nameAscending]) + slices.Reverse(index.sorts[nameDescending][:]) + + index.sorts[sizeAscending] = slices.Clone(work) + slices.SortFunc(index.sorts[sizeAscending][:], func(a, b *metadata) int { + return cmp.Compare(a.Size, b.Size) + }) + index.sorts[sizeDescending] = slices.Clone(index.sorts[sizeAscending]) + slices.Reverse(index.sorts[sizeDescending][:]) + + return +} diff --git a/cmd/mbf/internal/pkgsite/index_test.go b/cmd/mbf/internal/pkgsite/index_test.go new file mode 100644 index 00000000..abf78876 --- /dev/null +++ b/cmd/mbf/internal/pkgsite/index_test.go @@ -0,0 +1,96 @@ +package pkgsite + +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/pkgsite/search.go b/cmd/mbf/internal/pkgsite/search.go new file mode 100644 index 00000000..ef7cd76f --- /dev/null +++ b/cmd/mbf/internal/pkgsite/search.go @@ -0,0 +1,81 @@ +package pkgsite + +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 := range in { + sum += in[i][1] - in[i][0] + } + return sum +} diff --git a/cmd/mbf/internal/pkgsite/ui/index.html b/cmd/mbf/internal/pkgsite/ui/index.html new file mode 100644 index 00000000..6a5d72b4 --- /dev/null +++ b/cmd/mbf/internal/pkgsite/ui/index.html @@ -0,0 +1,58 @@ + + + + + + + + Rosa OS Packages + + + +

Rosa OS Packages

+
+

Showing entries .

+ + + + + + + +
+
+
+ + + + +
Loading...
+ +
+

©Hakurei (unknown). Licensed under the MIT license.

+
+ + + diff --git a/cmd/mbf/internal/pkgsite/ui/index.ts b/cmd/mbf/internal/pkgsite/ui/index.ts new file mode 100644 index 00000000..0784b81f --- /dev/null +++ b/cmd/mbf/internal/pkgsite/ui/index.ts @@ -0,0 +1,331 @@ +interface PackageIndexEntry { + name: string + size?: number + description?: string + website?: string + version?: string + report?: boolean +} + +function entryToHTML(entry: PackageIndexEntry | SearchResult): HTMLTableRowElement { + let v = entry.version != null ? `${escapeHtml(entry.version)}` : "" + let s = entry.size != null && entry.size > 0 ? `

Size: ${toByteSizeString(entry.size)} (${entry.size})

` : "" + let n: string + let d: string + if ('name_matches' in entry) { + n = `

${nameMatches(entry as SearchResult)} ${v}

` + } else { + n = `

${escapeHtml(entry.name)} ${v}

` + } + if ('desc_matches' in entry && STATE.getIncludeDescriptions()) { + d = descMatches(entry as SearchResult) + } else { + d = (entry as PackageIndexEntry).description != null ? `

${escapeHtml((entry as PackageIndexEntry).description)}

` : "" + } + let w = entry.website != null ? `Website` : "" + let r = entry.report ? `Log (View | Download)` : "" + let row = (document.createElement('tr')) + row.innerHTML = ` + ${n} + ${d} + ${s} + ${w} + ${r} + ` + return row +} + +function nameMatches(sr: SearchResult): string { + return markMatches(sr.name, sr.name_matches) +} + +function descMatches(sr: SearchResult): string { + return markMatches(sr.description!, sr.desc_matches) +} + +function markMatches(str: string, indices: [number, number][]): string { + if (indices == null) { + return str + } + let out: string = "" + let j = 0 + for (let i = 0; i < str.length; i++) { + if (j < indices.length) { + if (i === indices[j][0]) { + out += `${escapeHtmlChar(str[i])}` + continue + } + if (i === indices[j][1]) { + out += `${escapeHtmlChar(str[i])}` + j++ + continue + } + } + out += escapeHtmlChar(str[i]) + } + if (indices[j] !== undefined) { + out += "" + } + return out +} + +function toByteSizeString(bytes: number): string { + if (bytes == null) return `unspecified` + if (bytes < 1024) return `${bytes}B` + if (bytes < Math.pow(1024, 2)) return `${(bytes / 1024).toFixed(2)}kiB` + if (bytes < Math.pow(1024, 3)) return `${(bytes / Math.pow(1024, 2)).toFixed(2)}MiB` + if (bytes < Math.pow(1024, 4)) return `${(bytes / Math.pow(1024, 3)).toFixed(2)}GiB` + if (bytes < Math.pow(1024, 5)) return `${(bytes / Math.pow(1024, 4)).toFixed(2)}TiB` + return "not only is it big, it's large" +} + +const API_VERSION = 1 +const ENDPOINT = `/api/v${API_VERSION}` + +interface InfoPayload { + count?: number + hakurei_version?: string +} + +async function infoRequest(): Promise { + const res = await fetch(`${ENDPOINT}/info`) + const payload = await res.json() + return payload as InfoPayload +} + +interface GetPayload { + values?: PackageIndexEntry[] +} + +enum SortOrders { + DeclarationAscending, + DeclarationDescending, + NameAscending, + NameDescending +} + +async function getRequest(limit: number, index: number, sort: SortOrders): Promise { + const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`) + const payload = await res.json() + return payload as GetPayload +} + +interface SearchResult extends PackageIndexEntry { + name_matches: [number, number][] + desc_matches: [number, number][] + score: number +} + +interface SearchPayload { + count?: number + values?: SearchResult[] +} + +async function searchRequest(limit: number, index: number, search: string, desc: boolean): Promise { + const res = await fetch(`${ENDPOINT}/search?limit=${limit}&index=${index}&search=${encodeURIComponent(search)}&desc=${desc}`) + if (!res.ok) { + exitSearch() + alert("invalid search query!") + return Promise.reject(res.statusText) + } + const payload = await res.json() + return payload as SearchPayload +} + +class State { + entriesPerPage: number = 10 + entryIndex: number = 0 + maxTotal: number = 0 + maxEntries: number = 0 + sort: SortOrders = SortOrders.DeclarationAscending + search: boolean = false + + getEntriesPerPage(): number { + return this.entriesPerPage + } + + setEntriesPerPage(entriesPerPage: number) { + this.entriesPerPage = entriesPerPage + this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage) + } + + getEntryIndex(): number { + return this.entryIndex + } + + setEntryIndex(entryIndex: number) { + this.entryIndex = entryIndex + this.updatePage() + this.updateRange() + this.updateListings() + } + + getMaxTotal(): number { + return this.maxTotal + } + + setMaxTotal(max: number) { + this.maxTotal = max + } + + getSortOrder(): SortOrders { + return this.sort + } + + setSortOrder(sortOrder: SortOrders) { + this.sort = sortOrder + this.setEntryIndex(0) + } + + updatePage() { + let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage()) + for (let e of document.getElementsByClassName("page-number")) { + (e as HTMLInputElement).value = String(page) + } + } + + updateRange() { + let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxTotal()) + document.getElementById("entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxTotal()}` + if (this.search) { + document.getElementById("search-entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.maxTotal}/${this.maxEntries}` + document.getElementById("search-query")!.innerHTML = `${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/pkgsite/ui/style.css b/cmd/mbf/internal/pkgsite/ui/style.css new file mode 100644 index 00000000..b4f281ac --- /dev/null +++ b/cmd/mbf/internal/pkgsite/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/pkgsite/ui/tsconfig.json b/cmd/mbf/internal/pkgsite/ui/tsconfig.json new file mode 100644 index 00000000..24df4936 --- /dev/null +++ b/cmd/mbf/internal/pkgsite/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/pkgsite/ui/ui.go b/cmd/mbf/internal/pkgsite/ui/ui.go new file mode 100644 index 00000000..3fc0d89c --- /dev/null +++ b/cmd/mbf/internal/pkgsite/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/pkgsite/ui/ui_full.go b/cmd/mbf/internal/pkgsite/ui/ui_full.go new file mode 100644 index 00000000..564787dc --- /dev/null +++ b/cmd/mbf/internal/pkgsite/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/pkgsite/ui/ui_stub.go b/cmd/mbf/internal/pkgsite/ui/ui_stub.go new file mode 100644 index 00000000..daf010f8 --- /dev/null +++ b/cmd/mbf/internal/pkgsite/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 4f471d6a..22bfbc3e 100644 --- a/cmd/mbf/main.go +++ b/cmd/mbf/main.go @@ -43,8 +43,8 @@ import ( "hakurei.app/message" "hakurei.app/pkg" - "hakurei.app/cmd/mbf/internal/pkgserver" - "hakurei.app/cmd/mbf/internal/pkgserver/ui" + "hakurei.app/cmd/mbf/internal/pkgsite" + "hakurei.app/cmd/mbf/internal/pkgsite/ui" ) // builtin contains native and embedded [rosa.Artifact] registrations. @@ -343,7 +343,7 @@ func main() { var mux http.ServeMux ui.Register(&mux) - if err = pkgserver.Register(ctx, &mux, r); err != nil { + if err = pkgsite.Register(ctx, &mux, r); err != nil { return } -- cgit v1.3.1