mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
software display names: DB changes (#35066)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #33776 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`).
This commit is contained in:
parent
475614f19d
commit
1cf16f0539
19 changed files with 646 additions and 28 deletions
|
|
@ -797,6 +797,7 @@ spec:
|
|||
software_package: null
|
||||
source: chrome_extensions
|
||||
extension_for: chrome
|
||||
display_name: ""
|
||||
browser: chrome
|
||||
versions:
|
||||
- id: 0
|
||||
|
|
@ -821,6 +822,7 @@ spec:
|
|||
software_package: null
|
||||
source: deb_packages
|
||||
extension_for: ""
|
||||
display_name: ""
|
||||
browser: ""
|
||||
versions:
|
||||
- id: 0
|
||||
|
|
@ -839,6 +841,7 @@ spec:
|
|||
"name": "foo",
|
||||
"source": "chrome_extensions",
|
||||
"extension_for": "chrome",
|
||||
"display_name": "",
|
||||
"browser": "chrome",
|
||||
"hosts_count": 2,
|
||||
"icon_url": null,
|
||||
|
|
@ -874,6 +877,7 @@ spec:
|
|||
"id": 0,
|
||||
"name": "bar",
|
||||
"source": "deb_packages",
|
||||
"display_name": "",
|
||||
"extension_for": "",
|
||||
"browser": "",
|
||||
"hosts_count": 0,
|
||||
|
|
@ -950,6 +954,7 @@ spec:
|
|||
source: chrome_extensions
|
||||
browser: chrome
|
||||
extension_for: chrome
|
||||
display_name: ""
|
||||
version: 0.0.1
|
||||
vulnerabilities:
|
||||
- cve: cve-321-432-543
|
||||
|
|
@ -966,6 +971,7 @@ spec:
|
|||
extension_id: xyz
|
||||
browser: edge
|
||||
extension_for: edge
|
||||
display_name: ""
|
||||
vulnerabilities: null
|
||||
- generated_cpe: someothercpewithoutvulns
|
||||
id: 0
|
||||
|
|
@ -973,6 +979,7 @@ spec:
|
|||
source: chrome_extensions
|
||||
browser: chrome
|
||||
extension_for: chrome
|
||||
display_name: ""
|
||||
version: 0.0.3
|
||||
vulnerabilities: null
|
||||
- bundle_identifier: bundle
|
||||
|
|
@ -982,6 +989,7 @@ spec:
|
|||
source: deb_packages
|
||||
browser: ""
|
||||
extension_for: ""
|
||||
display_name: ""
|
||||
version: 0.0.3
|
||||
vulnerabilities: null
|
||||
`
|
||||
|
|
@ -998,6 +1006,7 @@ spec:
|
|||
"source": "chrome_extensions",
|
||||
"browser": "chrome",
|
||||
"extension_for": "chrome",
|
||||
"display_name": "",
|
||||
"generated_cpe": "somecpe",
|
||||
"vulnerabilities": [
|
||||
{
|
||||
|
|
@ -1020,6 +1029,7 @@ spec:
|
|||
"extension_id": "xyz",
|
||||
"browser": "edge",
|
||||
"extension_for": "edge",
|
||||
"display_name": "",
|
||||
"generated_cpe": "",
|
||||
"vulnerabilities": null
|
||||
},
|
||||
|
|
@ -1030,6 +1040,7 @@ spec:
|
|||
"source": "chrome_extensions",
|
||||
"browser": "chrome",
|
||||
"extension_for": "chrome",
|
||||
"display_name": "",
|
||||
"generated_cpe": "someothercpewithoutvulns",
|
||||
"vulnerabilities": null
|
||||
},
|
||||
|
|
@ -1039,8 +1050,9 @@ spec:
|
|||
"version": "0.0.3",
|
||||
"bundle_identifier": "bundle",
|
||||
"source": "deb_packages",
|
||||
"display_name": "",
|
||||
"browser": "",
|
||||
"extension_for": "",
|
||||
"extension_for": "",
|
||||
"generated_cpe": "",
|
||||
"vulnerabilities": null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20251103160848, Down_20251103160848)
|
||||
}
|
||||
|
||||
func Up_20251103160848(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS software_title_display_names (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT UNSIGNED NOT NULL,
|
||||
software_title_id INT UNSIGNED NOT NULL,
|
||||
display_name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
|
||||
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
UNIQUE KEY idx_unique_team_id_title_id (team_id, software_title_id),
|
||||
FOREIGN KEY (software_title_id) REFERENCES software_titles(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "create software_title_display_names table")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20251103160848(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package tables
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUp_20251103160848(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// Apply current migration.
|
||||
applyNext(t, db)
|
||||
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1275,6 +1275,7 @@ func listSoftwareDB(
|
|||
}
|
||||
softwares[idx].Vulnerabilities = append(softwares[idx].Vulnerabilities, cve)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return softwares, nil
|
||||
|
|
@ -1328,6 +1329,7 @@ func selectSoftwareSQL(opts fleet.SoftwareListOptions) (string, []interface{}, e
|
|||
"s.vendor",
|
||||
"s.arch",
|
||||
"s.application_id",
|
||||
"s.title_id",
|
||||
goqu.I("scp.cpe").As("generated_cpe"),
|
||||
).
|
||||
// Include this in the sub-query in case we want to sort by 'generated_cpe'
|
||||
|
|
@ -1517,6 +1519,7 @@ func selectSoftwareSQL(opts fleet.SoftwareListOptions) (string, []interface{}, e
|
|||
"s.vendor",
|
||||
"s.arch",
|
||||
"s.application_id",
|
||||
"s.title_id",
|
||||
goqu.COALESCE(goqu.I("s.generated_cpe"), "").As("generated_cpe"),
|
||||
"scv.cve",
|
||||
"scv.created_at",
|
||||
|
|
@ -1785,6 +1788,30 @@ func (ds *Datastore) ListSoftware(ctx context.Context, opt fleet.SoftwareListOpt
|
|||
return nil, nil, err
|
||||
}
|
||||
|
||||
var titleIDs []uint
|
||||
for _, s := range software {
|
||||
if s.TitleID != nil {
|
||||
titleIDs = append(titleIDs, *s.TitleID)
|
||||
}
|
||||
}
|
||||
|
||||
var tmID uint
|
||||
if opt.TeamID != nil {
|
||||
tmID = *opt.TeamID
|
||||
}
|
||||
displayNames, err := ds.getDisplayNamesByTeamAndTitleIds(ctx, tmID, titleIDs)
|
||||
if err != nil && !fleet.IsNotFound(err) {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get software display names by team and title IDs")
|
||||
}
|
||||
|
||||
for i, s := range software {
|
||||
if s.TitleID != nil {
|
||||
if displayName, ok := displayNames[*s.TitleID]; ok {
|
||||
software[i].DisplayName = displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perPage := opt.ListOptions.PerPage
|
||||
var metaData *fleet.PaginationMetadata
|
||||
if opt.ListOptions.IncludeMetadata {
|
||||
|
|
@ -1849,6 +1876,7 @@ func (ds *Datastore) SoftwareByID(ctx context.Context, id uint, teamID *uint, in
|
|||
"s.vendor",
|
||||
"s.arch",
|
||||
"s.extension_id",
|
||||
"s.title_id",
|
||||
"scv.cve",
|
||||
"scv.created_at",
|
||||
goqu.COALESCE(goqu.I("scp.cpe"), "").As("generated_cpe"),
|
||||
|
|
@ -1931,6 +1959,19 @@ func (ds *Datastore) SoftwareByID(ctx context.Context, id uint, teamID *uint, in
|
|||
software = result.Software
|
||||
}
|
||||
|
||||
var tmID uint
|
||||
if teamID != nil {
|
||||
tmID = *teamID
|
||||
}
|
||||
|
||||
if software.TitleID != nil {
|
||||
displayName, err := ds.getSoftwareTitleDisplayName(ctx, tmID, *software.TitleID)
|
||||
if err != nil && !fleet.IsNotFound(err) {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting display name for software")
|
||||
}
|
||||
software.DisplayName = displayName
|
||||
}
|
||||
|
||||
if result.CVE != nil {
|
||||
cveID := *result.CVE
|
||||
cve := fleet.CVE{
|
||||
|
|
|
|||
|
|
@ -669,6 +669,10 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up
|
|||
}
|
||||
}
|
||||
|
||||
if err := updateSoftwareTitleDisplayName(ctx, tx, payload.TeamID, payload.TitleID, payload.DisplayName); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update software title display name")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ func TestSoftwareInstallers(t *testing.T) {
|
|||
{"BatchSetSoftwareInstallersActivateNextActivity", testBatchSetSoftwareInstallersActivateNextActivity},
|
||||
{"SaveInstallerUpdatesClearsFleetMaintainedAppID", testSaveInstallerUpdatesClearsFleetMaintainedAppID},
|
||||
{"SoftwareInstallerReplicaLag", testSoftwareInstallerReplicaLag},
|
||||
{"SoftwareTitleDisplayName", testSoftwareTitleDisplayName},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
|
@ -3343,7 +3344,7 @@ func testSaveInstallerUpdatesClearsFleetMaintainedAppID(t *testing.T, ds *Datast
|
|||
require.NoError(t, err)
|
||||
|
||||
// Create an installer with a non-NULL fleet_maintained_app_id
|
||||
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
Title: "testpkg",
|
||||
Source: "apps",
|
||||
InstallScript: "echo install",
|
||||
|
|
@ -3365,6 +3366,7 @@ func testSaveInstallerUpdatesClearsFleetMaintainedAppID(t *testing.T, ds *Datast
|
|||
preInstallQuery := "SELECT 2"
|
||||
selfService := true
|
||||
payload := &fleet.UpdateSoftwareInstallerPayload{
|
||||
TitleID: titleID,
|
||||
InstallerID: installerID,
|
||||
StorageID: "storageid2", // different storage id
|
||||
Filename: "test2.pkg",
|
||||
|
|
@ -3423,3 +3425,197 @@ func testSoftwareInstallerReplicaLag(t *testing.T, _ *Datastore) {
|
|||
require.NoError(t, err)
|
||||
require.NotNil(t, gotInstaller)
|
||||
}
|
||||
|
||||
func testSoftwareTitleDisplayName(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
||||
host0 := test.NewHost(t, ds, "host0", "", "host0key", "host0uuid", time.Now())
|
||||
|
||||
_, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallerFile: tfr1,
|
||||
Extension: "msi",
|
||||
StorageID: "storageid",
|
||||
Filename: "originalname.msi",
|
||||
Title: "OriginalName1",
|
||||
PackageIDs: []string{"id2"},
|
||||
Version: "2.0",
|
||||
Source: "programs",
|
||||
AutomaticInstall: true,
|
||||
UserID: user1.ID,
|
||||
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Display name is empty by default
|
||||
titles, _, _, err := ds.ListSoftwareTitles(
|
||||
ctx,
|
||||
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
|
||||
fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, titles, 1)
|
||||
assert.Empty(t, titles[0].DisplayName)
|
||||
|
||||
title, err := ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, title.DisplayName)
|
||||
|
||||
err = ds.SaveInstallerUpdates(ctx, &fleet.UpdateSoftwareInstallerPayload{
|
||||
DisplayName: "update1",
|
||||
TitleID: titleID,
|
||||
InstallerFile: &fleet.TempFileReader{},
|
||||
InstallScript: new(string),
|
||||
PreInstallQuery: new(string),
|
||||
PostInstallScript: new(string),
|
||||
SelfService: ptr.Bool(false),
|
||||
UninstallScript: new(string),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Display name entry should be in join table
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
type result struct {
|
||||
DisplayName string `db:"display_name"`
|
||||
SoftwareTitleID uint `db:"software_title_id"`
|
||||
TeamID uint `db:"team_id"`
|
||||
}
|
||||
var r []result
|
||||
|
||||
err := sqlx.SelectContext(ctx, q, &r, "SELECT display_name, software_title_id, team_id FROM software_title_display_names")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, r, 1)
|
||||
assert.Equal(t, r[0], result{"update1", titleID, 0})
|
||||
return nil
|
||||
})
|
||||
|
||||
// List contains display name
|
||||
titles, _, _, err = ds.ListSoftwareTitles(
|
||||
ctx,
|
||||
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
|
||||
fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, titles, 1)
|
||||
assert.Equal(t, "update1", titles[0].DisplayName)
|
||||
|
||||
// Entity contains display name
|
||||
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "update1", title.DisplayName)
|
||||
|
||||
// Update host's software so we get a software version
|
||||
software0 := []fleet.Software{
|
||||
{Name: "OriginalName1", Version: "0.0.1", Source: "programs", TitleID: ptr.Uint(titleID)},
|
||||
}
|
||||
_, err = ds.UpdateHostSoftware(ctx, host0.ID, software0)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
|
||||
|
||||
softwareList, _, err := ds.ListSoftware(ctx, fleet.SoftwareListOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, softwareList, 1)
|
||||
assert.Equal(t, titleID, *softwareList[0].TitleID)
|
||||
assert.Equal(t, "update1", softwareList[0].DisplayName)
|
||||
|
||||
software, err := ds.SoftwareByID(ctx, softwareList[0].ID, ptr.Uint(0), false, &fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, titleID, *software.TitleID)
|
||||
assert.Equal(t, "update1", software.DisplayName)
|
||||
|
||||
// Update the display name again, should see the change
|
||||
err = ds.SaveInstallerUpdates(ctx, &fleet.UpdateSoftwareInstallerPayload{
|
||||
DisplayName: "update2",
|
||||
TitleID: titleID,
|
||||
InstallerFile: &fleet.TempFileReader{},
|
||||
InstallScript: new(string),
|
||||
PreInstallQuery: new(string),
|
||||
PostInstallScript: new(string),
|
||||
SelfService: ptr.Bool(false),
|
||||
UninstallScript: new(string),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// List contains display name
|
||||
titles, _, _, err = ds.ListSoftwareTitles(
|
||||
ctx,
|
||||
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
|
||||
fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, titles, 1)
|
||||
assert.Equal(t, "update2", titles[0].DisplayName)
|
||||
|
||||
// Entity contains display name
|
||||
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "update2", title.DisplayName)
|
||||
|
||||
softwareList, _, err = ds.ListSoftware(ctx, fleet.SoftwareListOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, softwareList, 1)
|
||||
assert.Equal(t, titleID, *softwareList[0].TitleID)
|
||||
assert.Equal(t, "update2", softwareList[0].DisplayName)
|
||||
|
||||
software, err = ds.SoftwareByID(ctx, softwareList[0].ID, ptr.Uint(0), false, &fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, titleID, *software.TitleID)
|
||||
assert.Equal(t, "update2", software.DisplayName)
|
||||
|
||||
// Update display name to be empty
|
||||
err = ds.SaveInstallerUpdates(ctx, &fleet.UpdateSoftwareInstallerPayload{
|
||||
TitleID: titleID,
|
||||
InstallerFile: &fleet.TempFileReader{},
|
||||
InstallScript: new(string),
|
||||
PreInstallQuery: new(string),
|
||||
PostInstallScript: new(string),
|
||||
SelfService: ptr.Bool(false),
|
||||
UninstallScript: new(string),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// List contains display name
|
||||
titles, _, _, err = ds.ListSoftwareTitles(
|
||||
ctx,
|
||||
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
|
||||
fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, titles, 1)
|
||||
assert.Empty(t, titles[0].DisplayName)
|
||||
|
||||
// Entity contains display name
|
||||
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, title.DisplayName)
|
||||
|
||||
softwareList, _, err = ds.ListSoftware(ctx, fleet.SoftwareListOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, softwareList, 1)
|
||||
assert.Equal(t, titleID, *softwareList[0].TitleID)
|
||||
assert.Empty(t, softwareList[0].DisplayName)
|
||||
|
||||
software, err = ds.SoftwareByID(ctx, softwareList[0].ID, ptr.Uint(0), false, &fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, titleID, *software.TitleID)
|
||||
assert.Empty(t, software.DisplayName)
|
||||
|
||||
}
|
||||
|
|
|
|||
78
server/datastore/mysql/software_title_display_names.go
Normal file
78
server/datastore/mysql/software_title_display_names.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func updateSoftwareTitleDisplayName(ctx context.Context, tx sqlx.ExtContext, teamID *uint, titleID uint, displayName string) error {
|
||||
var tmID uint
|
||||
if teamID != nil {
|
||||
tmID = *teamID
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO software_title_display_names
|
||||
(team_id, software_title_id, display_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = VALUES(display_name)`, tmID, titleID, displayName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) getDisplayNamesByTeamAndTitleIds(ctx context.Context, teamID uint, titleIDs []uint) (map[uint]string, error) {
|
||||
if len(titleIDs) == 0 {
|
||||
return map[uint]string{}, nil
|
||||
}
|
||||
|
||||
var args []any
|
||||
query := `
|
||||
SELECT software_title_id, display_name
|
||||
FROM software_title_display_names
|
||||
WHERE software_title_id IN (?) AND team_id = ?
|
||||
`
|
||||
query, args, err := sqlx.In(query, titleIDs, teamID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "building query for get software title display names")
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
SoftwareTitleID uint `db:"software_title_id"`
|
||||
DisplayName string `db:"display_name"`
|
||||
}
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, query, args...); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get software title display names")
|
||||
}
|
||||
|
||||
iconsBySoftwareTitleID := make(map[uint]string, len(results))
|
||||
for _, r := range results {
|
||||
iconsBySoftwareTitleID[r.SoftwareTitleID] = r.DisplayName
|
||||
}
|
||||
|
||||
return iconsBySoftwareTitleID, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) getSoftwareTitleDisplayName(ctx context.Context, teamID uint, titleID uint) (string, error) {
|
||||
args := []any{teamID, titleID}
|
||||
query := `
|
||||
SELECT display_name
|
||||
FROM software_title_display_names
|
||||
WHERE team_id = ? AND software_title_id = ?
|
||||
`
|
||||
var displayName string
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &displayName, query, args...)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", ctxerr.Wrap(ctx, notFound("SoftwareTitleDisplayName"), "get software title display name")
|
||||
}
|
||||
return "", ctxerr.Wrap(ctx, err, "get software title display name")
|
||||
}
|
||||
|
||||
return displayName, nil
|
||||
}
|
||||
|
|
@ -12,13 +12,13 @@ import (
|
|||
|
||||
func (ds *Datastore) CreateOrUpdateSoftwareTitleIcon(ctx context.Context, payload *fleet.UploadSoftwareTitleIconPayload) (*fleet.SoftwareTitleIcon, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
var args []any
|
||||
query = `
|
||||
INSERT INTO software_title_icons (team_id, software_title_id, storage_id, filename)
|
||||
VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE
|
||||
storage_id = VALUES(storage_id), filename = VALUES(filename)
|
||||
`
|
||||
args = []interface{}{payload.TeamID, payload.TitleID, payload.StorageID, payload.Filename}
|
||||
args = []any{payload.TeamID, payload.TitleID, payload.StorageID, payload.Filename}
|
||||
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
|
|
@ -34,7 +34,7 @@ func (ds *Datastore) CreateOrUpdateSoftwareTitleIcon(ctx context.Context, payloa
|
|||
}
|
||||
|
||||
func (ds *Datastore) GetSoftwareTitleIcon(ctx context.Context, teamID uint, titleID uint) (*fleet.SoftwareTitleIcon, error) {
|
||||
args := []interface{}{teamID, titleID}
|
||||
args := []any{teamID, titleID}
|
||||
query := `
|
||||
SELECT team_id, software_title_id, storage_id, filename
|
||||
FROM software_title_icons
|
||||
|
|
@ -67,7 +67,7 @@ func (ds *Datastore) GetSoftwareIconsByTeamAndTitleIds(ctx context.Context, team
|
|||
return map[uint]fleet.SoftwareTitleIcon{}, nil
|
||||
}
|
||||
|
||||
var args []interface{}
|
||||
var args []any
|
||||
query := `
|
||||
SELECT team_id, software_title_id, storage_id, filename
|
||||
FROM software_title_icons
|
||||
|
|
|
|||
|
|
@ -97,6 +97,13 @@ GROUP BY
|
|||
if icon != nil {
|
||||
title.IconUrl = ptr.String(icon.IconUrl())
|
||||
}
|
||||
|
||||
displayName, err := ds.getSoftwareTitleDisplayName(ctx, *teamID, id)
|
||||
if err != nil && !fleet.IsNotFound(err) {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get software title display name")
|
||||
}
|
||||
|
||||
title.DisplayName = displayName
|
||||
}
|
||||
|
||||
title.VersionsCount = uint(len(title.Versions))
|
||||
|
|
@ -299,11 +306,22 @@ func (ds *Datastore) ListSoftwareTitles(
|
|||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "get software icons by team and title IDs")
|
||||
}
|
||||
|
||||
displayNames, err := ds.getDisplayNamesByTeamAndTitleIds(ctx, *opt.TeamID, titleIDs)
|
||||
if err != nil {
|
||||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "get software display names by team and title IDs")
|
||||
}
|
||||
|
||||
for _, icon := range icons {
|
||||
if i, ok := titleIndex[icon.SoftwareTitleID]; ok {
|
||||
softwareList[i].IconUrl = ptr.String(icon.IconUrl())
|
||||
}
|
||||
}
|
||||
|
||||
for titleID, i := range titleIndex {
|
||||
if displayName, ok := displayNames[titleID]; ok {
|
||||
softwareList[i].DisplayName = displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we grab matching versions separately and build the desired object in
|
||||
|
|
|
|||
|
|
@ -119,6 +119,18 @@ func saveTeamSecretsDB(ctx context.Context, q sqlx.ExtContext, team *fleet.Team)
|
|||
return applyEnrollSecretsDB(ctx, q, &team.ID, team.Secrets)
|
||||
}
|
||||
|
||||
// teamRefs are the tables referenced by teams.
|
||||
// These tables are cleared when the team is deleted.
|
||||
// Analogous to hostRefs.
|
||||
var teamRefs = []string{
|
||||
"mdm_apple_configuration_profiles",
|
||||
"mdm_windows_configuration_profiles",
|
||||
"mdm_apple_declarations",
|
||||
"mdm_android_configuration_profiles",
|
||||
"software_title_icons",
|
||||
"software_title_display_names",
|
||||
}
|
||||
|
||||
func (ds *Datastore) DeleteTeam(ctx context.Context, tid uint) error {
|
||||
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
// Delete team policies first, because policies can have associated installers and scripts
|
||||
|
|
@ -138,24 +150,11 @@ func (ds *Datastore) DeleteTeam(ctx context.Context, tid uint) error {
|
|||
return ctxerr.Wrapf(ctx, err, "deleting pack_targets for team %d", tid)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM mdm_apple_configuration_profiles WHERE team_id=?`, tid)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "deleting mdm_apple_configuration_profiles for team %d", tid)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM mdm_windows_configuration_profiles WHERE team_id=?`, tid)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "deleting mdm_windows_configuration_profiles for team %d", tid)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM mdm_apple_declarations WHERE team_id=?`, tid)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "deleting mdm_apple_declarations for team %d", tid)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM mdm_android_configuration_profiles WHERE team_id=?`, tid)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "deleting mdm_android_configuration_profiles for team %d", tid)
|
||||
for _, table := range teamRefs {
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE team_id=?`, table), tid)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "deleting %s for team %d", table, tid)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -571,7 +570,7 @@ func (ds *Datastore) SaveDefaultTeamConfig(ctx context.Context, config *fleet.Te
|
|||
}
|
||||
|
||||
_, err = ds.writer(ctx).ExecContext(ctx,
|
||||
`INSERT INTO default_team_config_json(id, json_value) VALUES(1, ?)
|
||||
`INSERT INTO default_team_config_json(id, json_value) VALUES(1, ?)
|
||||
ON DUPLICATE KEY UPDATE json_value = VALUES(json_value)`,
|
||||
configBytes,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package mysql
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
|
@ -107,9 +108,80 @@ func testTeamsGetSetDelete(t *testing.T, ds *Datastore) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a bunch of data into tables with team IDs that should be cleared when team is deleted
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
res, err := q.ExecContext(context.Background(), fmt.Sprintf(`INSERT INTO software_titles (name, source) VALUES ('MyCoolApp_%s', 'apps')`, tt.name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
titleID, _ := res.LastInsertId()
|
||||
_, err = q.ExecContext(
|
||||
context.Background(),
|
||||
`INSERT INTO
|
||||
mdm_apple_configuration_profiles (profile_uuid, team_id, identifier, name, mobileconfig, checksum)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
fmt.Sprintf("uuid_%s", tt.name),
|
||||
0,
|
||||
fmt.Sprintf("TestPayloadIdentifier_%s", tt.name),
|
||||
fmt.Sprintf("TestPayloadName_%s", tt.name),
|
||||
`<?xml version="1.0"`,
|
||||
[]byte("test"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = q.ExecContext(context.Background(), `
|
||||
INSERT INTO
|
||||
mdm_windows_configuration_profiles (team_id, name, syncml, profile_uuid)
|
||||
VALUES (?, ?, ?, ?)`, 0, fmt.Sprintf("TestPayloadName_%s", tt.name), `<?xml version="1.0"`, fmt.Sprintf("uuid_%s", tt.name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = q.ExecContext(context.Background(), `
|
||||
INSERT INTO
|
||||
mdm_android_configuration_profiles (profile_uuid, team_id, name, raw_json)
|
||||
VALUES (?, ?, ?, ?)`, fmt.Sprintf("uuid_%s", tt.name), 0, fmt.Sprintf("TestPayloadName_%s", tt.name), `{"foo": "bar"}`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = q.ExecContext(
|
||||
context.Background(),
|
||||
"INSERT INTO software_title_display_names (team_id, software_title_id, display_name) VALUES (?, ?, ?)",
|
||||
team.ID,
|
||||
titleID,
|
||||
"delete_test",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = q.ExecContext(
|
||||
context.Background(),
|
||||
"INSERT INTO software_title_icons (team_id, software_title_id, storage_id, filename) VALUES (?, ?, ?, ?)",
|
||||
team.ID,
|
||||
titleID,
|
||||
"delete_test",
|
||||
"delete_test.png",
|
||||
)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
err = ds.DeleteTeam(context.Background(), team.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that related tables were cleared
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
for _, table := range teamRefs {
|
||||
var count int
|
||||
err := sqlx.GetContext(context.Background(), q, &count, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE team_id = ?`, table), team.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assert.Zero(t, count)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
newP, err := ds.Pack(context.Background(), p.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, newP.Teams)
|
||||
|
|
|
|||
|
|
@ -616,6 +616,10 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp
|
|||
app.VPPAppTeam.AddedAutomaticInstallPolicy = policy
|
||||
}
|
||||
|
||||
if err := updateSoftwareTitleDisplayName(ctx, tx, teamID, titleID, app.DisplayName); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "setting software title display name for vpp app")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ func TestVPP(t *testing.T) {
|
|||
{"TestVPPTokenTeamAssignment", testVPPTokenTeamAssignment},
|
||||
{"TestGetAllVPPApps", testGetAllVPPApps},
|
||||
{"TestGetUnverifiedVPPInstallsForHost", testGetUnverifiedVPPInstallsForHost},
|
||||
{"SoftwareTitleDisplayName", testSoftwareTitleDisplayNameVPP},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
|
@ -2089,3 +2090,127 @@ func testGetUnverifiedVPPInstallsForHost(t *testing.T, ds *Datastore) {
|
|||
assert.Len(t, x, step.after)
|
||||
}
|
||||
}
|
||||
|
||||
func testSoftwareTitleDisplayNameVPP(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
test.CreateInsertGlobalVPPToken(t, ds)
|
||||
|
||||
va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
|
||||
Name: "vpp1", BundleIdentifier: "com.app.vpp1",
|
||||
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
titleID := va1.TitleID
|
||||
|
||||
// Display name is empty by default
|
||||
titles, _, _, err := ds.ListSoftwareTitles(
|
||||
ctx,
|
||||
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
|
||||
fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, titles, 1)
|
||||
assert.Empty(t, titles[0].DisplayName)
|
||||
|
||||
title, err := ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, title.DisplayName)
|
||||
|
||||
_, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
|
||||
Name: "vpp1", BundleIdentifier: "com.app.vpp1",
|
||||
VPPAppTeam: fleet.VPPAppTeam{
|
||||
VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform},
|
||||
DisplayName: "vpp_update1",
|
||||
},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Display name entry should be in join table
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
type result struct {
|
||||
DisplayName string `db:"display_name"`
|
||||
SoftwareTitleID uint `db:"software_title_id"`
|
||||
TeamID uint `db:"team_id"`
|
||||
}
|
||||
var r []result
|
||||
|
||||
err := sqlx.SelectContext(ctx, q, &r, "SELECT display_name, software_title_id, team_id FROM software_title_display_names")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, r, 1)
|
||||
assert.Equal(t, r[0], result{"vpp_update1", titleID, 0})
|
||||
return nil
|
||||
})
|
||||
|
||||
// List contains display name
|
||||
titles, _, _, err = ds.ListSoftwareTitles(
|
||||
ctx,
|
||||
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
|
||||
fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, titles, 1)
|
||||
assert.Equal(t, "vpp_update1", titles[0].DisplayName)
|
||||
|
||||
// Entity contains display name
|
||||
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "vpp_update1", title.DisplayName)
|
||||
|
||||
// Update the display name again, should see the change
|
||||
_, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
|
||||
Name: "vpp1", BundleIdentifier: "com.app.vpp1",
|
||||
VPPAppTeam: fleet.VPPAppTeam{
|
||||
VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform},
|
||||
DisplayName: "vpp_update2",
|
||||
},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List contains display name
|
||||
titles, _, _, err = ds.ListSoftwareTitles(
|
||||
ctx,
|
||||
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
|
||||
fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, titles, 1)
|
||||
assert.Equal(t, "vpp_update2", titles[0].DisplayName)
|
||||
|
||||
// Entity contains display name
|
||||
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "vpp_update2", title.DisplayName)
|
||||
|
||||
// Update display name to be empty
|
||||
_, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
|
||||
Name: "vpp1", BundleIdentifier: "com.app.vpp1",
|
||||
VPPAppTeam: fleet.VPPAppTeam{
|
||||
VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform},
|
||||
},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List contains display name
|
||||
titles, _, _, err = ds.ListSoftwareTitles(
|
||||
ctx,
|
||||
fleet.SoftwareTitleListOptions{TeamID: ptr.Uint(0)},
|
||||
fleet.TeamFilter{User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, titles, 1)
|
||||
assert.Empty(t, titles[0].DisplayName)
|
||||
|
||||
// Entity contains display name
|
||||
title, err = ds.SoftwareTitleByID(ctx, titleID, ptr.Uint(0), fleet.TeamFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, title.DisplayName)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ type Software struct {
|
|||
IsKernel bool `json:"-"`
|
||||
// ApplicationID is the unique identifier for Android software. Equivalent to the BundleIdentifier on Apple software.
|
||||
ApplicationID *string `json:"application_id,omitempty" db:"application_id"`
|
||||
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
func (Software) AuthzType() string {
|
||||
|
|
@ -249,6 +251,8 @@ type SoftwareTitle struct {
|
|||
IsKernel bool `json:"-" db:"is_kernel"`
|
||||
// ApplicationID is the unique identifier for Android software. Equivalent to the BundleIdentifier on Apple software.
|
||||
ApplicationID *string `json:"application_id,omitempty" db:"application_id"`
|
||||
// DisplayName is an end-user friendly name.
|
||||
DisplayName string `json:"display_name" db:"display_name"`
|
||||
}
|
||||
|
||||
// populateBrowserField populates the browser field for backwards compatibility
|
||||
|
|
@ -330,6 +334,7 @@ type SoftwareTitleListResult struct {
|
|||
HashSHA256 *string `json:"hash_sha256,omitempty" db:"package_storage_id"`
|
||||
// ApplicationID is the unique identifier for Android software. Equivalent to the BundleIdentifier on Apple software.
|
||||
ApplicationID *string `json:"application_id,omitempty" db:"application_id"`
|
||||
DisplayName string `json:"display_name" db:"display_name"`
|
||||
}
|
||||
|
||||
type SoftwareTitleListOptions struct {
|
||||
|
|
|
|||
|
|
@ -563,6 +563,8 @@ type UpdateSoftwareInstallerPayload struct {
|
|||
ValidatedLabels *LabelIdentsWithScope
|
||||
Categories []string
|
||||
CategoryIDs []uint
|
||||
// DisplayName is an end-user friendly name.
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// DownloadSoftwareInstallerPayload is the payload for downloading a software installer.
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ func TestHostSoftwareEntryMarshalJSON(t *testing.T) {
|
|||
"source": "chrome_extensions",
|
||||
"extension_id": "test-extension-id",
|
||||
"extension_for": "chrome",
|
||||
"display_name": "",
|
||||
"browser": "chrome",
|
||||
"release": "1",
|
||||
"vendor": "Test Vendor",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ type VPPAppTeam struct {
|
|||
// automatically created when a VPP app is added to Fleet. This field should be set after VPP
|
||||
// app creation if AddAutoInstallPolicy is true.
|
||||
AddedAutomaticInstallPolicy *Policy `json:"-"`
|
||||
DisplayName string `json:"-"`
|
||||
}
|
||||
|
||||
// VPPApp represents a VPP (Volume Purchase Program) application,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ func ElementsMatchSkipIDAndHostCount(t TestingT, listA, listB interface{}, msgAn
|
|||
|
||||
opt := cmp.FilterPath(func(p cmp.Path) bool {
|
||||
for _, ps := range p {
|
||||
if ps, ok := ps.(cmp.StructField); ok && (ps.Name() == "ID" || ps.Name() == "HostCount") {
|
||||
// We don't need the TitleID in most cases, only used for software title display names functionality.
|
||||
if ps, ok := ps.(cmp.StructField); ok && (ps.Name() == "ID" || ps.Name() == "HostCount" || ps.Name() == "TitleID") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue