satellite/console: return edge URL overrides in project info responses
API responses containing project information now contain the edge service URL overrides configured for that project. The overrides are based on the project's default placement. References #6188 Change-Id: Ifc3dc74e75c0f5daf0419ac3be184415c65b202e
This commit is contained in:
parent
89d682f49f
commit
c8f4f5210d
@ -36,10 +36,6 @@ func TestAdminProjectGeofenceAPI(t *testing.T) {
|
||||
project, err := sat.DB.Console().Projects().Get(ctx, uplink.Projects[0].ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// update project set default placement to EEA
|
||||
project.DefaultPlacement = storj.EEA
|
||||
require.NoError(t, sat.DB.Console().Projects().Update(ctx, project))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
project uuid.UUID
|
||||
|
113
satellite/console/config.go
Normal file
113
satellite/console/config.go
Normal file
@ -0,0 +1,113 @@
|
||||
// Copyright (C) 2023 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package console
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"storj.io/common/storj"
|
||||
)
|
||||
|
||||
// Config keeps track of core console service configuration parameters.
|
||||
type Config struct {
|
||||
PasswordCost int `help:"password hashing cost (0=automatic)" testDefault:"4" default:"0"`
|
||||
OpenRegistrationEnabled bool `help:"enable open registration" default:"false" testDefault:"true"`
|
||||
DefaultProjectLimit int `help:"default project limits for users" default:"1" testDefault:"5"`
|
||||
AsOfSystemTimeDuration time.Duration `help:"default duration for AS OF SYSTEM TIME" devDefault:"-5m" releaseDefault:"-5m" testDefault:"0"`
|
||||
LoginAttemptsWithoutPenalty int `help:"number of times user can try to login without penalty" default:"3"`
|
||||
FailedLoginPenalty float64 `help:"incremental duration of penalty for failed login attempts in minutes" default:"2.0"`
|
||||
ProjectInvitationExpiration time.Duration `help:"duration that project member invitations are valid for" default:"168h"`
|
||||
UserBalanceForUpgrade int64 `help:"amount of base units of US micro dollars needed to upgrade user's tier status" default:"10000000"`
|
||||
PlacementEdgeURLOverrides PlacementEdgeURLOverrides `help:"placement-specific edge service URL overrides in the format {\"placementID\": {\"authService\": \"...\", \"publicLinksharing\": \"...\", \"internalLinksharing\": \"...\"}, \"placementID2\": ...}"`
|
||||
UsageLimits UsageLimitsConfig
|
||||
Captcha CaptchaConfig
|
||||
Session SessionConfig
|
||||
}
|
||||
|
||||
// CaptchaConfig contains configurations for login/registration captcha system.
|
||||
type CaptchaConfig struct {
|
||||
Login MultiCaptchaConfig `json:"login"`
|
||||
Registration MultiCaptchaConfig `json:"registration"`
|
||||
}
|
||||
|
||||
// MultiCaptchaConfig contains configurations for Recaptcha and Hcaptcha systems.
|
||||
type MultiCaptchaConfig struct {
|
||||
Recaptcha SingleCaptchaConfig `json:"recaptcha"`
|
||||
Hcaptcha SingleCaptchaConfig `json:"hcaptcha"`
|
||||
}
|
||||
|
||||
// SingleCaptchaConfig contains configurations abstract captcha system.
|
||||
type SingleCaptchaConfig struct {
|
||||
Enabled bool `help:"whether or not captcha is enabled" default:"false" json:"enabled"`
|
||||
SiteKey string `help:"captcha site key" json:"siteKey"`
|
||||
SecretKey string `help:"captcha secret key" json:"-"`
|
||||
}
|
||||
|
||||
// SessionConfig contains configurations for session management.
|
||||
type SessionConfig struct {
|
||||
InactivityTimerEnabled bool `help:"indicates if session can be timed out due inactivity" default:"true"`
|
||||
InactivityTimerDuration int `help:"inactivity timer delay in seconds" default:"600"`
|
||||
InactivityTimerViewerEnabled bool `help:"indicates whether remaining session time is shown for debugging" default:"false"`
|
||||
Duration time.Duration `help:"duration a session is valid for (superseded by inactivity timer delay if inactivity timer is enabled)" default:"168h"`
|
||||
}
|
||||
|
||||
// EdgeURLOverrides contains edge service URL overrides.
|
||||
type EdgeURLOverrides struct {
|
||||
AuthService string `json:"authService,omitempty"`
|
||||
PublicLinksharing string `json:"publicLinksharing,omitempty"`
|
||||
InternalLinksharing string `json:"internalLinksharing,omitempty"`
|
||||
}
|
||||
|
||||
// PlacementEdgeURLOverrides represents a mapping between placement IDs and edge service URL overrides.
|
||||
type PlacementEdgeURLOverrides struct {
|
||||
overrideMap map[storj.PlacementConstraint]EdgeURLOverrides
|
||||
}
|
||||
|
||||
// Ensure that PlacementEdgeOverrides implements pflag.Value.
|
||||
var _ pflag.Value = (*PlacementEdgeURLOverrides)(nil)
|
||||
|
||||
// Type implements pflag.Value.
|
||||
func (PlacementEdgeURLOverrides) Type() string { return "console.PlacementEdgeURLOverrides" }
|
||||
|
||||
// String implements pflag.Value.
|
||||
func (ov *PlacementEdgeURLOverrides) String() string {
|
||||
if ov == nil || len(ov.overrideMap) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
overrides, err := json.Marshal(ov.overrideMap)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(overrides)
|
||||
}
|
||||
|
||||
// Set implements pflag.Value.
|
||||
func (ov *PlacementEdgeURLOverrides) Set(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
overrides := make(map[storj.PlacementConstraint]EdgeURLOverrides)
|
||||
err := json.Unmarshal([]byte(s), &overrides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ov.overrideMap = overrides
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the edge service URL overrides for the given placement ID.
|
||||
func (ov *PlacementEdgeURLOverrides) Get(placement storj.PlacementConstraint) (overrides EdgeURLOverrides, ok bool) {
|
||||
if ov == nil {
|
||||
return EdgeURLOverrides{}, false
|
||||
}
|
||||
overrides, ok = ov.overrideMap[placement]
|
||||
return overrides, ok
|
||||
}
|
@ -83,7 +83,7 @@ func (p *Projects) GetUserProjects(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
response := make([]console.ProjectInfo, 0)
|
||||
for _, project := range projects {
|
||||
response = append(response, project.GetMinimal())
|
||||
response = append(response, p.service.GetMinimalProject(&project))
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
@ -156,7 +156,7 @@ func (p *Projects) GetPagedProjects(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
for _, project := range projectsPage.Projects {
|
||||
pageToSend.Projects = append(pageToSend.Projects, project.GetMinimal())
|
||||
pageToSend.Projects = append(pageToSend.Projects, p.service.GetMinimalProject(&project))
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(pageToSend)
|
||||
@ -287,7 +287,7 @@ func (p *Projects) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
err = json.NewEncoder(w).Encode(project.GetMinimal())
|
||||
err = json.NewEncoder(w).Encode(p.service.GetMinimalProject(project))
|
||||
if err != nil {
|
||||
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
@ -14,12 +14,15 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"storj.io/common/memory"
|
||||
"storj.io/common/storj"
|
||||
"storj.io/common/testcontext"
|
||||
"storj.io/common/testrand"
|
||||
"storj.io/common/uuid"
|
||||
"storj.io/storj/private/testplanet"
|
||||
"storj.io/storj/satellite"
|
||||
"storj.io/storj/satellite/console"
|
||||
"storj.io/storj/satellite/console/consoleweb/consoleapi"
|
||||
)
|
||||
@ -373,3 +376,95 @@ func TestDeleteProjectMembers(t *testing.T) {
|
||||
require.Contains(t, string(body), "error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEdgeURLOverrides(t *testing.T) {
|
||||
var (
|
||||
noOverridePlacementID storj.PlacementConstraint
|
||||
partialOverridePlacementID storj.PlacementConstraint = 1
|
||||
fullOverridePlacementID storj.PlacementConstraint = 2
|
||||
|
||||
authServiceURL = "auth.storj.io"
|
||||
publicLinksharingURL = "public-link.storj.io"
|
||||
internalLinksharingURL = "link.storj.io"
|
||||
)
|
||||
|
||||
testplanet.Run(t, testplanet.Config{
|
||||
SatelliteCount: 1, UplinkCount: 1,
|
||||
Reconfigure: testplanet.Reconfigure{
|
||||
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
|
||||
err := config.Console.PlacementEdgeURLOverrides.Set(
|
||||
fmt.Sprintf(
|
||||
`{
|
||||
"%d": {"authService": "%s"},
|
||||
"%d": {
|
||||
"authService": "%s",
|
||||
"publicLinksharing": "%s",
|
||||
"internalLinksharing": "%s"
|
||||
}
|
||||
}`,
|
||||
partialOverridePlacementID, authServiceURL,
|
||||
fullOverridePlacementID, authServiceURL, publicLinksharingURL, internalLinksharingURL,
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
},
|
||||
},
|
||||
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||
sat := planet.Satellites[0]
|
||||
|
||||
project, err := sat.DB.Console().Projects().Get(ctx, planet.Uplinks[0].Projects[0].ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
user, err := sat.API.Console.Service.GetUser(ctx, project.OwnerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
placement *storj.PlacementConstraint
|
||||
expectedEdgeURLs *console.EdgeURLOverrides
|
||||
}{
|
||||
{"nil placement", nil, nil},
|
||||
{"placement with no overrides", &noOverridePlacementID, nil},
|
||||
{
|
||||
"placement with partial override",
|
||||
&partialOverridePlacementID,
|
||||
&console.EdgeURLOverrides{AuthService: authServiceURL},
|
||||
}, {
|
||||
"placement with full override",
|
||||
&fullOverridePlacementID,
|
||||
&console.EdgeURLOverrides{
|
||||
AuthService: authServiceURL,
|
||||
PublicLinksharing: publicLinksharingURL,
|
||||
InternalLinksharing: internalLinksharingURL,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := sat.DB.Testing().RawDB().ExecContext(ctx,
|
||||
"UPDATE projects SET default_placement = $1 WHERE id = $2",
|
||||
tt.placement, project.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, count)
|
||||
|
||||
body, status, err := doRequestWithAuth(ctx, t, sat, user, http.MethodGet, "projects", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
|
||||
var infos []console.ProjectInfo
|
||||
require.NoError(t, json.Unmarshal(body, &infos))
|
||||
require.NotEmpty(t, infos)
|
||||
|
||||
if tt.expectedEdgeURLs == nil {
|
||||
require.Nil(t, infos[0].EdgeURLOverrides)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, infos[0].EdgeURLOverrides)
|
||||
require.Equal(t, *tt.expectedEdgeURLs, *infos[0].EdgeURLOverrides)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -125,12 +125,13 @@ type UpsertProjectInfo struct {
|
||||
|
||||
// ProjectInfo holds data sent via user facing http endpoints.
|
||||
type ProjectInfo struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID uuid.UUID `json:"ownerId"`
|
||||
Description string `json:"description"`
|
||||
MemberCount int `json:"memberCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID uuid.UUID `json:"ownerId"`
|
||||
Description string `json:"description"`
|
||||
MemberCount int `json:"memberCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
EdgeURLOverrides *EdgeURLOverrides `json:"edgeURLOverrides,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectsCursor holds info for project
|
||||
@ -194,15 +195,3 @@ func ValidateNameAndDescription(name string, description string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMinimal returns a ProjectInfo copy of a project.
|
||||
func (p Project) GetMinimal() ProjectInfo {
|
||||
return ProjectInfo{
|
||||
ID: p.PublicID,
|
||||
Name: p.Name,
|
||||
OwnerID: p.OwnerID,
|
||||
Description: p.Description,
|
||||
MemberCount: p.MemberCount,
|
||||
CreatedAt: p.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
@ -187,48 +187,6 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// Config keeps track of core console service configuration parameters.
|
||||
type Config struct {
|
||||
PasswordCost int `help:"password hashing cost (0=automatic)" testDefault:"4" default:"0"`
|
||||
OpenRegistrationEnabled bool `help:"enable open registration" default:"false" testDefault:"true"`
|
||||
DefaultProjectLimit int `help:"default project limits for users" default:"1" testDefault:"5"`
|
||||
AsOfSystemTimeDuration time.Duration `help:"default duration for AS OF SYSTEM TIME" devDefault:"-5m" releaseDefault:"-5m" testDefault:"0"`
|
||||
LoginAttemptsWithoutPenalty int `help:"number of times user can try to login without penalty" default:"3"`
|
||||
FailedLoginPenalty float64 `help:"incremental duration of penalty for failed login attempts in minutes" default:"2.0"`
|
||||
ProjectInvitationExpiration time.Duration `help:"duration that project member invitations are valid for" default:"168h"`
|
||||
UserBalanceForUpgrade int64 `help:"amount of base units of US micro dollars needed to upgrade user's tier status" default:"10000000"`
|
||||
UsageLimits UsageLimitsConfig
|
||||
Captcha CaptchaConfig
|
||||
Session SessionConfig
|
||||
}
|
||||
|
||||
// CaptchaConfig contains configurations for login/registration captcha system.
|
||||
type CaptchaConfig struct {
|
||||
Login MultiCaptchaConfig `json:"login"`
|
||||
Registration MultiCaptchaConfig `json:"registration"`
|
||||
}
|
||||
|
||||
// MultiCaptchaConfig contains configurations for Recaptcha and Hcaptcha systems.
|
||||
type MultiCaptchaConfig struct {
|
||||
Recaptcha SingleCaptchaConfig `json:"recaptcha"`
|
||||
Hcaptcha SingleCaptchaConfig `json:"hcaptcha"`
|
||||
}
|
||||
|
||||
// SingleCaptchaConfig contains configurations abstract captcha system.
|
||||
type SingleCaptchaConfig struct {
|
||||
Enabled bool `help:"whether or not captcha is enabled" default:"false" json:"enabled"`
|
||||
SiteKey string `help:"captcha site key" json:"siteKey"`
|
||||
SecretKey string `help:"captcha secret key" json:"-"`
|
||||
}
|
||||
|
||||
// SessionConfig contains configurations for session management.
|
||||
type SessionConfig struct {
|
||||
InactivityTimerEnabled bool `help:"indicates if session can be timed out due inactivity" default:"true"`
|
||||
InactivityTimerDuration int `help:"inactivity timer delay in seconds" default:"600"`
|
||||
InactivityTimerViewerEnabled bool `help:"indicates whether remaining session time is shown for debugging" default:"false"`
|
||||
Duration time.Duration `help:"duration a session is valid for (superseded by inactivity timer delay if inactivity timer is enabled)" default:"168h"`
|
||||
}
|
||||
|
||||
// Payments separates all payment related functionality.
|
||||
type Payments struct {
|
||||
service *Service
|
||||
@ -1614,6 +1572,24 @@ func (s *Service) GetUsersProjects(ctx context.Context) (ps []Project, err error
|
||||
return
|
||||
}
|
||||
|
||||
// GetMinimalProject returns a ProjectInfo copy of a project.
|
||||
func (s *Service) GetMinimalProject(project *Project) ProjectInfo {
|
||||
info := ProjectInfo{
|
||||
ID: project.PublicID,
|
||||
Name: project.Name,
|
||||
OwnerID: project.OwnerID,
|
||||
Description: project.Description,
|
||||
MemberCount: project.MemberCount,
|
||||
CreatedAt: project.CreatedAt,
|
||||
}
|
||||
|
||||
if edgeURLs, ok := s.config.PlacementEdgeURLOverrides.Get(project.DefaultPlacement); ok {
|
||||
info.EdgeURLOverrides = &edgeURLs
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// GenGetUsersProjects is a method for querying all projects for generated api.
|
||||
func (s *Service) GenGetUsersProjects(ctx context.Context) (ps []Project, httpErr api.HTTPError) {
|
||||
var err error
|
||||
|
@ -68,13 +68,22 @@ func (projects *projects) GetByUserID(ctx context.Context, userID uuid.UUID) (_
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
rows, err := projects.sdb.Query(ctx, projects.sdb.Rebind(`
|
||||
SELECT projects.id, projects.public_id, projects.name, projects.description, projects.owner_id, projects.rate_limit, projects.max_buckets, projects.created_at,
|
||||
SELECT
|
||||
projects.id,
|
||||
projects.public_id,
|
||||
projects.name,
|
||||
projects.description,
|
||||
projects.owner_id,
|
||||
projects.rate_limit,
|
||||
projects.max_buckets,
|
||||
projects.created_at,
|
||||
COALESCE(projects.default_placement, 0),
|
||||
(SELECT COUNT(*) FROM project_members WHERE project_id = projects.id) AS member_count
|
||||
FROM projects
|
||||
JOIN project_members ON projects.id = project_members.project_id
|
||||
WHERE project_members.member_id = ?
|
||||
ORDER BY name ASC
|
||||
`), userID)
|
||||
FROM projects
|
||||
JOIN project_members ON projects.id = project_members.project_id
|
||||
WHERE project_members.member_id = ?
|
||||
ORDER BY name ASC
|
||||
`), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -84,7 +93,18 @@ func (projects *projects) GetByUserID(ctx context.Context, userID uuid.UUID) (_
|
||||
var rateLimit, maxBuckets sql.NullInt32
|
||||
projectsToSend := make([]console.Project, 0)
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&nextProject.ID, &nextProject.PublicID, &nextProject.Name, &nextProject.Description, &nextProject.OwnerID, &rateLimit, &maxBuckets, &nextProject.CreatedAt, &nextProject.MemberCount)
|
||||
err = rows.Scan(
|
||||
&nextProject.ID,
|
||||
&nextProject.PublicID,
|
||||
&nextProject.Name,
|
||||
&nextProject.Description,
|
||||
&nextProject.OwnerID,
|
||||
&rateLimit,
|
||||
&maxBuckets,
|
||||
&nextProject.CreatedAt,
|
||||
&nextProject.DefaultPlacement,
|
||||
&nextProject.MemberCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -366,14 +386,23 @@ func (projects *projects) ListByOwnerID(ctx context.Context, ownerID uuid.UUID,
|
||||
}
|
||||
|
||||
rows, err := projects.sdb.Query(ctx, projects.sdb.Rebind(`
|
||||
SELECT id, public_id, name, description, owner_id, rate_limit, max_buckets, created_at,
|
||||
SELECT
|
||||
id,
|
||||
public_id,
|
||||
name,
|
||||
description,
|
||||
owner_id,
|
||||
rate_limit,
|
||||
max_buckets,
|
||||
created_at,
|
||||
COALESCE(default_placement, 0),
|
||||
(SELECT COUNT(*) FROM project_members WHERE project_id = projects.id) AS member_count
|
||||
FROM projects
|
||||
WHERE owner_id = ?
|
||||
ORDER BY name ASC
|
||||
OFFSET ? ROWS
|
||||
LIMIT ?
|
||||
`), ownerID, page.Offset, page.Limit+1) // add 1 to limit to see if there is another page
|
||||
FROM projects
|
||||
WHERE owner_id = ?
|
||||
ORDER BY name ASC
|
||||
OFFSET ? ROWS
|
||||
LIMIT ?
|
||||
`), ownerID, page.Offset, page.Limit+1) // add 1 to limit to see if there is another page
|
||||
if err != nil {
|
||||
return console.ProjectsPage{}, err
|
||||
}
|
||||
@ -391,7 +420,18 @@ func (projects *projects) ListByOwnerID(ctx context.Context, ownerID uuid.UUID,
|
||||
}
|
||||
var rateLimit, maxBuckets sql.NullInt32
|
||||
nextProject := &console.Project{}
|
||||
err = rows.Scan(&nextProject.ID, &nextProject.PublicID, &nextProject.Name, &nextProject.Description, &nextProject.OwnerID, &rateLimit, &maxBuckets, &nextProject.CreatedAt, &nextProject.MemberCount)
|
||||
err = rows.Scan(
|
||||
&nextProject.ID,
|
||||
&nextProject.PublicID,
|
||||
&nextProject.Name,
|
||||
&nextProject.Description,
|
||||
&nextProject.OwnerID,
|
||||
&rateLimit,
|
||||
&maxBuckets,
|
||||
&nextProject.CreatedAt,
|
||||
&nextProject.DefaultPlacement,
|
||||
&nextProject.MemberCount,
|
||||
)
|
||||
if err != nil {
|
||||
return console.ProjectsPage{}, err
|
||||
}
|
||||
|
3
scripts/testdata/satellite-config.yaml.lock
vendored
3
scripts/testdata/satellite-config.yaml.lock
vendored
@ -337,6 +337,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
|
||||
# indicates if the overview onboarding step should render with pathways
|
||||
# console.pathway-overview-enabled: true
|
||||
|
||||
# placement-specific edge service URL overrides in the format {"placementID": {"authService": "...", "publicLinksharing": "...", "internalLinksharing": "..."}, "placementID2": ...}
|
||||
# console.placement-edge-url-overrides: ""
|
||||
|
||||
# whether to allow purchasing pricing packages
|
||||
# console.pricing-packages-enabled: false
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user