diff --git a/api/cdb/cached_client.go b/api/cdb/cached_client.go new file mode 100644 index 00000000..b49a80de --- /dev/null +++ b/api/cdb/cached_client.go @@ -0,0 +1,71 @@ +package cdb + +import ( + "fmt" + "time" + + cache "github.com/Code-Hex/go-generics-cache" +) + +type CachedCDBClient struct { + ttl time.Duration + client *CDBClient + search_cache *cache.Cache[string, []*Package] + dependency_cache *cache.Cache[string, PackageDependency] + detail_cache *cache.Cache[string, *PackageDetails] +} + +func NewCachedClient(client *CDBClient, ttl time.Duration) *CachedCDBClient { + return &CachedCDBClient{ + ttl: ttl, + client: client, + search_cache: cache.New[string, []*Package](), + dependency_cache: cache.New[string, PackageDependency](), + detail_cache: cache.New[string, *PackageDetails](), + } +} + +func (c *CachedCDBClient) SearchPackages(q *PackageQuery) ([]*Package, error) { + key := q.Params().Encode() + res, ok := c.search_cache.Get(key) + + var err error + if !ok { + res, err = c.client.SearchPackages(q) + if err != nil { + return nil, err + } + c.search_cache.Set(key, res, cache.WithExpiration(c.ttl)) + } + return res, nil +} + +func (c *CachedCDBClient) GetDependencies(author, name string) (PackageDependency, error) { + key := fmt.Sprintf("%s/%s", author, name) + res, ok := c.dependency_cache.Get(key) + + var err error + if !ok { + res, err = c.client.GetDependencies(author, name) + if err != nil { + return nil, err + } + c.dependency_cache.Set(key, res, cache.WithExpiration(c.ttl)) + } + return res, nil +} + +func (c *CachedCDBClient) GetDetails(author, name string) (*PackageDetails, error) { + key := fmt.Sprintf("%s/%s", author, name) + res, ok := c.detail_cache.Get(key) + + var err error + if !ok { + res, err = c.client.GetDetails(author, name) + if err != nil { + return nil, err + } + c.detail_cache.Set(key, res, cache.WithExpiration(c.ttl)) + } + return res, nil +} diff --git a/api/cdb/client.go b/api/cdb/client.go index 23f777aa..164e3dc7 100644 --- a/api/cdb/client.go +++ b/api/cdb/client.go @@ -68,29 +68,7 @@ func (c *CDBClient) GetPackages() ([]*Package, error) { func (c *CDBClient) SearchPackages(q *PackageQuery) ([]*Package, error) { pkgs := make([]*Package, 0) - params := url.Values{} - for _, t := range q.Type { - params.Add("type", string(t)) - } - if q.Query != "" { - params.Add("q", q.Query) - } - if q.Author != "" { - params.Add("author", q.Author) - } - if q.Limit > 0 { - params.Add("limit", fmt.Sprintf("%d", q.Limit)) - } - for _, cw := range q.Hide { - params.Add("hide", cw.Name) - } - if q.Sort != "" { - params.Add("sort", string(q.Sort)) - } - if q.Order != "" { - params.Add("order", string(q.Order)) - } - + params := q.Params() err := c.get("api/packages", &pkgs, params) return pkgs, err } diff --git a/api/cdb/dependency.go b/api/cdb/dependency.go new file mode 100644 index 00000000..fe7313a9 --- /dev/null +++ b/api/cdb/dependency.go @@ -0,0 +1,128 @@ +package cdb + +import ( + "fmt" +) + +type ResolvedDependency struct { + Name string `json:"name"` + Choices []string `json:"choices"` + Selected string `json:"selected"` + Installed bool `json:"installed"` +} + +func ResolveDependencies(cc *CachedCDBClient, required_pkg string, selected_pkgs, installed_pkgs []string) ([]*ResolvedDependency, error) { + rd := []*ResolvedDependency{} + + // already processed dependencies + processed_deps := map[string]bool{} + + // convert to lookup maps + installed_pkg_map := map[string]bool{} + for _, pkg := range installed_pkgs { + installed_pkg_map[pkg] = true + } + + selected_pkg_map := map[string]bool{} + for _, pkg := range selected_pkgs { + selected_pkg_map[pkg] = true + } + + mod_list, err := cc.SearchPackages(&PackageQuery{Type: []PackageType{PackageTypeMod}}) + if err != nil { + return nil, fmt.Errorf("failed to query mods: %v", err) + } + + mod_map := map[string]*Package{} + for _, pkg := range mod_list { + mod_map[GetPackagename(pkg.Author, pkg.Name)] = pkg + } + + resolved_dep_infos := map[string][]*DependencyInfo{} + + // recursive resolver + var resolve func(string) error + resolve = func(pkgname string) error { + if processed_deps[pkgname] { + return nil + } + processed_deps[pkgname] = true + + dep := resolved_dep_infos[pkgname] + author, name := GetAuthorName(pkgname) + + if dep == nil { + // fetch dep infos + deps, err := cc.GetDependencies(author, name) + if err != nil { + return fmt.Errorf("failed to resolve deps for mod '%s': %v", pkgname, err) + } + + for n, dep := range deps { + resolved_dep_infos[n] = dep + } + dep = resolved_dep_infos[pkgname] + } + + if dep == nil { + // should not happen but check anyway + return fmt.Errorf("dep unresolved: '%s'", pkgname) + } + + for _, di := range dep { + if di.IsOptional || processed_deps[di.Name] { + // optional or already processed + continue + } + + processed_deps[di.Name] = true + + d := &ResolvedDependency{ + Name: di.Name, + Choices: []string{}, + } + + if installed_pkg_map[di.Name] { + // already installed + d.Installed = true + rd = append(rd, d) + continue + } + + var selected_pkgname string + for _, dep_pkgname := range di.Packages { + if mod_map[dep_pkgname] == nil { + // not of "mod"-type + continue + } + + d.Choices = append(d.Choices, dep_pkgname) + _, name := GetAuthorName(dep_pkgname) + if (selected_pkgname == "" && name == di.Name) || selected_pkg_map[dep_pkgname] { + // exact match found or manually selected + selected_pkgname = dep_pkgname + } + } + + if selected_pkgname != "" { + d.Selected = selected_pkgname + // resolve selected sub-package + err = resolve(selected_pkgname) + if err != nil { + return err + } + } + + rd = append(rd, d) + } + + return nil + } + + err = resolve(required_pkg) + if err != nil { + return nil, err + } + + return rd, nil +} diff --git a/api/cdb/dependency_test.go b/api/cdb/dependency_test.go new file mode 100644 index 00000000..b978813d --- /dev/null +++ b/api/cdb/dependency_test.go @@ -0,0 +1,32 @@ +package cdb_test + +import ( + "mtui/api/cdb" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestResolveDependencies(t *testing.T) { + c := cdb.New() + cc := cdb.NewCachedClient(c, time.Hour*1) + + installed_pkgs := []string{"default"} + selected_pkgs := []string{} + + rd, err := cdb.ResolveDependencies(cc, "mt-mods/technic_plus", selected_pkgs, installed_pkgs) + assert.NoError(t, err) + assert.NotNil(t, rd) + + for _, di := range rd { + switch di.Name { + case "basic_materials": + assert.True(t, len(di.Choices) >= 1) + case "default": + assert.True(t, di.Installed) + } + } + + assert.Equal(t, 3, len(rd)) +} diff --git a/api/cdb/types.go b/api/cdb/types.go index bbcfc429..033cb711 100644 --- a/api/cdb/types.go +++ b/api/cdb/types.go @@ -1,5 +1,10 @@ package cdb +import ( + "fmt" + "net/url" +) + type PackageType string const ( @@ -25,13 +30,47 @@ const ( ) type PackageQuery struct { - Type []PackageType `json:"type"` - Query string `json:"query"` - Author string `json:"author"` - Limit int `json:"limit"` - Hide []ContentWarning `json:"hide"` - Sort PackageSortType `json:"sort"` - Order PackageSortOrderType `json:"order"` + Type []PackageType `json:"type"` + Query string `json:"query"` + Author string `json:"author"` + Limit int `json:"limit"` + Hide []ContentWarning `json:"hide"` + Sort PackageSortType `json:"sort"` + Order PackageSortOrderType `json:"order"` + ProtocolVersion int `json:"protocol_version"` + EngineVersion string `json:"engine_version"` +} + +func (q *PackageQuery) Params() url.Values { + params := url.Values{} + for _, t := range q.Type { + params.Add("type", string(t)) + } + if q.Query != "" { + params.Add("q", q.Query) + } + if q.Author != "" { + params.Add("author", q.Author) + } + if q.Limit > 0 { + params.Add("limit", fmt.Sprintf("%d", q.Limit)) + } + for _, cw := range q.Hide { + params.Add("hide", cw.Name) + } + if q.Sort != "" { + params.Add("sort", string(q.Sort)) + } + if q.Order != "" { + params.Add("order", string(q.Order)) + } + if q.ProtocolVersion > 0 { + params.Add("protocol_version", fmt.Sprintf("%d", q.ProtocolVersion)) + } + if q.EngineVersion != "" { + params.Add("engine_version", q.EngineVersion) + } + return params } type Package struct { diff --git a/api/cdb/util.go b/api/cdb/util.go new file mode 100644 index 00000000..aac82575 --- /dev/null +++ b/api/cdb/util.go @@ -0,0 +1,15 @@ +package cdb + +import ( + "fmt" + "strings" +) + +func GetAuthorName(pkgname string) (string, string) { + parts := strings.Split(pkgname, "/") + return parts[0], parts[1] +} + +func GetPackagename(author, name string) string { + return fmt.Sprintf("%s/%s", author, name) +} diff --git a/go.mod b/go.mod index 7a5b9d15..bebe831d 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,8 @@ require ( require ( dario.cat/mergo v1.0.0 // indirect + github.com/Code-Hex/go-generics-cache v1.3.1 // indirect + github.com/dchest/captcha v1.0.0 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/docker/distribution v2.8.2+incompatible // indirect github.com/docker/go-units v0.5.0 // indirect @@ -45,6 +47,7 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/rtred v0.1.2 // indirect github.com/tidwall/tinyqueue v0.1.1 // indirect + golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect golang.org/x/time v0.3.0 // indirect gotest.tools/v3 v3.5.0 // indirect ) diff --git a/go.sum b/go.sum index c26f3220..d10f6a08 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Code-Hex/go-generics-cache v1.3.1 h1:i8rLwyhoyhaerr7JpjtYjJZUcCbWOdiYO3fZXLiEC4g= +github.com/Code-Hex/go-generics-cache v1.3.1/go.mod h1:qxcC9kRVrct9rHeiYpFWSoW1vxyillCVzX13KZG8dl4= github.com/HimbeerserverDE/srp v0.0.0 h1:Iy2GIF7DJphXXO9NjncLEBO6VsZd8Yhrlxl/qTr09eE= github.com/HimbeerserverDE/srp v0.0.0/go.mod h1:pxNH8S2nh4n2DWE0ToX5GnnDr/uEAuaAhJsCpkDLIWw= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -259,6 +261,8 @@ golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2Uz golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= diff --git a/jobs/log_cleanup.go b/jobs/log_cleanup.go index fd6da2ea..38b5bac1 100644 --- a/jobs/log_cleanup.go +++ b/jobs/log_cleanup.go @@ -16,7 +16,7 @@ func logCleanup(a *app.App) { } } - // re-schedule every minute + // re-schedule time.Sleep(time.Second * 10) } } diff --git a/public/js/api/cdb.js b/public/js/api/cdb.js index 98ed5477..ab506daf 100644 --- a/public/js/api/cdb.js +++ b/public/js/api/cdb.js @@ -5,6 +5,11 @@ export const search_packages = q => protected_fetch(`api/cdb/search`, { body: JSON.stringify(q) }); +export const resolve_package = data => protected_fetch(`api/cdb/resolve`, { + method: "POST", + body: JSON.stringify(data) +}); + export const get_package = (author, name) => protected_fetch(`api/cdb/detail/${author}/${name}`); export const get_dependencies = (author, name) => protected_fetch(`api/cdb/detail/${author}/${name}/dependencies`); \ No newline at end of file diff --git a/public/js/api/mods.js b/public/js/api/mods.js index b6f47d4d..f966662b 100644 --- a/public/js/api/mods.js +++ b/public/js/api/mods.js @@ -2,6 +2,8 @@ import { protected_fetch } from "./util.js"; export const list_mods = () => protected_fetch("api/mods"); +export const validate = () => protected_fetch("api/mods/validate"); + export const create_mod = mod => protected_fetch("api/mods", { method: "POST", body: JSON.stringify(mod) diff --git a/public/js/components/Breadcrumb.js b/public/js/components/Breadcrumb.js index 6af5ebdd..507da87d 100644 --- a/public/js/components/Breadcrumb.js +++ b/public/js/components/Breadcrumb.js @@ -38,4 +38,7 @@ export const ADMINISTRATION = { icon: "screwdriver-wrench", name: "Administratio export const OAUTH_APPS = { icon: "passport", name: "OAuth apps", link: "/oauth-apps" }; export const MODS = { name: "Mods", icon: "cubes", link: "/mods" }; export const CDB = { name: "ContentDB", icon: "box-open", link: "/cdb/browse" }; -export const FILEBROWSER = { name: "Filebrowser", icon: "folder", link: "/filebrowser/" }; \ No newline at end of file +export const FILEBROWSER = { name: "Filebrowser", icon: "folder", link: "/filebrowser/" }; +export const CDB_DETAIL = (author, name) => { + return { name: `'${author}/${name}'`, icon: "box-open", link: `/cdb/detail/${author}/${name}` }; +}; \ No newline at end of file diff --git a/public/js/components/CDBPackageLink.js b/public/js/components/CDBPackageLink.js new file mode 100644 index 00000000..c9bc9260 --- /dev/null +++ b/public/js/components/CDBPackageLink.js @@ -0,0 +1,26 @@ + +export default { + props: ["author", "name", "pkg"], + computed: { + link: function() { + if (this.pkg) { + return `/cdb/detail/${this.pkg.author}/${this.pkg.name}`; + } else { + return `/cdb/detail/${this.author}/${this.name}`; + } + }, + text: function() { + if (this.pkg) { + return `${this.pkg.author}/${this.pkg.name}`; + } else { + return `${this.author}/${this.name}`; + } + } + }, + template: /*html*/` + + + {{text}} + + ` +}; \ No newline at end of file diff --git a/public/js/components/NavBar.js b/public/js/components/NavBar.js index b9d774af..1ba0c16f 100644 --- a/public/js/components/NavBar.js +++ b/public/js/components/NavBar.js @@ -2,17 +2,12 @@ import { has_priv, is_logged_in, get_claims, logout } from "../service/login.js" import { has_feature } from "../service/features.js"; import { get_player_count, get_maintenance } from "../service/stats.js"; import { get_unread_count } from '../service/mail.js'; + import StatsDisplay from './StatsDisplay.js'; import EngineStatus from "./pages/services/EngineStatus.js"; +import NavDropdown from "./NavDropdown.js"; export default { - data: function() { - return { - admin_menu: false, - mod_menu: false, - services_menu: false - }; - }, methods: { has_priv: has_priv, has_feature: has_feature, @@ -29,7 +24,8 @@ export default { }, components: { "stats-display": StatsDisplay, - "engine-status": EngineStatus + "engine-status": EngineStatus, + "nav-dropdown": NavDropdown }, template: /*html*/`