mirror of
https://github.com/taigrr/jety.git
synced 2026-04-02 11:29:05 -07:00
Compare commits
5 Commits
cd/getters
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ec3d79700 | |||
| a81a2027ae | |||
| ff8a3444f1 | |||
| c8cbb72ed7 | |||
| 8b154b58ba |
21
.github/workflows/codeql-analysis.yml
vendored
21
.github/workflows/codeql-analysis.yml
vendored
@@ -36,31 +36,20 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
|
||||||
# 📚 https://git.io/JvXDl
|
|
||||||
|
|
||||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
|
||||||
# and modify them (or add more) to build your code if your project
|
|
||||||
# uses a compiled language
|
|
||||||
|
|
||||||
#- run: |
|
|
||||||
# make bootstrap
|
|
||||||
# make release
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v3
|
||||||
|
|||||||
2
.github/workflows/govulncheck.yml
vendored
2
.github/workflows/govulncheck.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Run govulncheck
|
name: Run govulncheck
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- id: govulncheck
|
- id: govulncheck
|
||||||
uses: golang/govulncheck-action@v1
|
uses: golang/govulncheck-action@v1
|
||||||
with:
|
with:
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -68,16 +68,25 @@ timeout = "30s"
|
|||||||
client_id = "abc123"
|
client_id = "abc123"
|
||||||
```
|
```
|
||||||
|
|
||||||
Access nested values using `GetStringMap` and type assertions:
|
Use `Sub()` to get a scoped ConfigManager for a nested section:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cloud := jety.Sub("services")
|
||||||
|
if cloud != nil {
|
||||||
|
inner := cloud.Sub("cloud")
|
||||||
|
if inner != nil {
|
||||||
|
varValue := inner.GetString("var") // "xyz"
|
||||||
|
timeout := inner.GetDuration("timeout") // 30s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or access nested values directly with `GetStringMap` and type assertions:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
services := jety.GetStringMap("services")
|
services := jety.GetStringMap("services")
|
||||||
cloud := services["cloud"].(map[string]any)
|
cloud := services["cloud"].(map[string]any)
|
||||||
varValue := cloud["var"].(string) // "xyz"
|
varValue := cloud["var"].(string) // "xyz"
|
||||||
|
|
||||||
// For deeper nesting
|
|
||||||
auth := cloud["auth"].(map[string]any)
|
|
||||||
clientID := auth["client_id"].(string) // "abc123"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variable Overrides
|
### Environment Variable Overrides
|
||||||
@@ -124,6 +133,8 @@ export MYAPP_SERVICES_CLOUD_VAR=override_value
|
|||||||
| ------------------------ | ------------------------ |
|
| ------------------------ | ------------------------ |
|
||||||
| `Set(key, value)` | Set a value |
|
| `Set(key, value)` | Set a value |
|
||||||
| `SetDefault(key, value)` | Set a default value |
|
| `SetDefault(key, value)` | Set a default value |
|
||||||
|
| `Delete(key)` | Remove a key |
|
||||||
|
| `Sub(key)` | Get scoped sub-config |
|
||||||
| `Get(key)` | Get raw value |
|
| `Get(key)` | Get raw value |
|
||||||
| `GetString(key)` | Get as string |
|
| `GetString(key)` | Get as string |
|
||||||
| `GetInt(key)` | Get as int |
|
| `GetInt(key)` | Get as int |
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ func SetString(key string, value string) {
|
|||||||
defaultConfigManager.SetString(key, value)
|
defaultConfigManager.SetString(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Sub(key string) *ConfigManager {
|
||||||
|
return defaultConfigManager.Sub(key)
|
||||||
|
}
|
||||||
|
|
||||||
func SetConfigDir(path string) {
|
func SetConfigDir(path string) {
|
||||||
defaultConfigManager.SetConfigDir(path)
|
defaultConfigManager.SetConfigDir(path)
|
||||||
}
|
}
|
||||||
|
|||||||
54
getters.go
54
getters.go
@@ -8,17 +8,71 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// resolve looks up a key in combinedConfig, falling back to envConfig.
|
// resolve looks up a key in combinedConfig, falling back to envConfig.
|
||||||
|
// It supports dot notation (e.g., "services.mas.server") to traverse nested maps.
|
||||||
func (c *ConfigManager) resolve(key string) (ConfigMap, bool) {
|
func (c *ConfigManager) resolve(key string) (ConfigMap, bool) {
|
||||||
lower := strings.ToLower(key)
|
lower := strings.ToLower(key)
|
||||||
|
|
||||||
|
// First, try direct lookup (for top-level keys or keys without dots)
|
||||||
if v, ok := c.combinedConfig[lower]; ok {
|
if v, ok := c.combinedConfig[lower]; ok {
|
||||||
return v, true
|
return v, true
|
||||||
}
|
}
|
||||||
if v, ok := c.envConfig[lower]; ok {
|
if v, ok := c.envConfig[lower]; ok {
|
||||||
return v, true
|
return v, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If key contains dots, try traversing nested maps
|
||||||
|
if strings.Contains(lower, ".") {
|
||||||
|
if v, ok := c.resolveNested(lower, c.combinedConfig); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
if v, ok := c.resolveNested(lower, c.envConfig); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ConfigMap{}, false
|
return ConfigMap{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveNested traverses nested maps using dot-separated key paths.
|
||||||
|
func (c *ConfigManager) resolveNested(key string, config map[string]ConfigMap) (ConfigMap, bool) {
|
||||||
|
parts := strings.Split(key, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ConfigMap{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the first part in the config
|
||||||
|
firstPart := parts[0]
|
||||||
|
entry, ok := config[firstPart]
|
||||||
|
if !ok {
|
||||||
|
return ConfigMap{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse the remaining parts through nested maps
|
||||||
|
current := entry.Value
|
||||||
|
for i := 1; i < len(parts); i++ {
|
||||||
|
m, ok := current.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ConfigMap{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try case-insensitive lookup in the nested map
|
||||||
|
part := parts[i]
|
||||||
|
found := false
|
||||||
|
for k, v := range m {
|
||||||
|
if strings.EqualFold(k, part) {
|
||||||
|
current = v
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return ConfigMap{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigMap{Key: key, Value: current}, true
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ConfigManager) Get(key string) any {
|
func (c *ConfigManager) Get(key string) any {
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
defer c.mutex.RUnlock()
|
defer c.mutex.RUnlock()
|
||||||
|
|||||||
25
jety.go
25
jety.go
@@ -308,6 +308,31 @@ func readFile(filename string, fileType configType) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sub returns a new ConfigManager rooted at the given key. The key must
|
||||||
|
// refer to a map value (e.g., from a nested TOML/YAML/JSON section).
|
||||||
|
// The returned ConfigManager has the nested map loaded as its default
|
||||||
|
// config, and does not inherit the parent's environment prefix, overrides,
|
||||||
|
// or config file. Returns nil if the key does not exist or is not a map.
|
||||||
|
func (c *ConfigManager) Sub(key string) *ConfigManager {
|
||||||
|
c.mutex.RLock()
|
||||||
|
defer c.mutex.RUnlock()
|
||||||
|
v, ok := c.resolve(key)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, ok := v.Value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sub := NewConfigManager()
|
||||||
|
for k, val := range m {
|
||||||
|
lower := strings.ToLower(k)
|
||||||
|
sub.defaultConfig[lower] = ConfigMap{Key: k, Value: val}
|
||||||
|
sub.combinedConfig[lower] = ConfigMap{Key: k, Value: val}
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ConfigManager) SetConfigDir(path string) {
|
func (c *ConfigManager) SetConfigDir(path string) {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|||||||
242
jety_test.go
242
jety_test.go
@@ -1554,3 +1554,245 @@ func TestPackageLevelGetInt64(t *testing.T) {
|
|||||||
t.Errorf("GetInt64(bignum) = %d, want 9223372036854775807", got)
|
t.Errorf("GetInt64(bignum) = %d, want 9223372036854775807", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSub(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "config.toml")
|
||||||
|
if err := os.WriteFile(configFile, []byte(tomlConfig), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.SetConfigFile(configFile)
|
||||||
|
if err := cm.SetConfigType("toml"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cm.ReadInConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := cm.Sub("database")
|
||||||
|
if sub == nil {
|
||||||
|
t.Fatal("Sub(database) returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
host := sub.GetString("host")
|
||||||
|
if host != "db.example.com" {
|
||||||
|
t.Errorf("Sub(database).GetString(host) = %q, want %q", host, "db.example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
port := sub.GetInt("port")
|
||||||
|
if port != 5432 {
|
||||||
|
t.Errorf("Sub(database).GetInt(port) = %d, want 5432", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubNonExistentKey(t *testing.T) {
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.SetDefault("simple", "value")
|
||||||
|
|
||||||
|
sub := cm.Sub("nonexistent")
|
||||||
|
if sub != nil {
|
||||||
|
t.Error("Sub(nonexistent) should return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubNonMapKey(t *testing.T) {
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.Set("name", "plain-string")
|
||||||
|
|
||||||
|
sub := cm.Sub("name")
|
||||||
|
if sub != nil {
|
||||||
|
t.Error("Sub on a non-map key should return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubIsIndependent(t *testing.T) {
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.Set("section", map[string]any{
|
||||||
|
"key1": "val1",
|
||||||
|
"key2": "val2",
|
||||||
|
})
|
||||||
|
|
||||||
|
sub := cm.Sub("section")
|
||||||
|
if sub == nil {
|
||||||
|
t.Fatal("Sub(section) returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modifying sub should not affect parent
|
||||||
|
sub.Set("key1", "modified")
|
||||||
|
if sub.GetString("key1") != "modified" {
|
||||||
|
t.Error("sub should reflect the Set")
|
||||||
|
}
|
||||||
|
|
||||||
|
parentSection := cm.Get("section").(map[string]any)
|
||||||
|
if parentSection["key1"] != "val1" {
|
||||||
|
t.Error("modifying sub should not affect parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageLevelSub(t *testing.T) {
|
||||||
|
defaultConfigManager = NewConfigManager()
|
||||||
|
Set("db", map[string]any{"host": "localhost", "port": 5432})
|
||||||
|
|
||||||
|
sub := Sub("db")
|
||||||
|
if sub == nil {
|
||||||
|
t.Fatal("package-level Sub returned nil")
|
||||||
|
}
|
||||||
|
if sub.GetString("host") != "localhost" {
|
||||||
|
t.Errorf("Sub(db).GetString(host) = %q, want %q", sub.GetString("host"), "localhost")
|
||||||
|
}
|
||||||
|
if sub.GetInt("port") != 5432 {
|
||||||
|
t.Errorf("Sub(db).GetInt(port) = %d, want 5432", sub.GetInt("port"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDotNotationAccess(t *testing.T) {
|
||||||
|
const tomlWithNested = `
|
||||||
|
port = 8080
|
||||||
|
|
||||||
|
[services.mas]
|
||||||
|
server = "192.168.1.15"
|
||||||
|
user = "central"
|
||||||
|
disabled = false
|
||||||
|
|
||||||
|
[services.salesforce.prod]
|
||||||
|
host = "sf.example.com"
|
||||||
|
port = 443
|
||||||
|
`
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
configFile := filepath.Join(dir, "nested.toml")
|
||||||
|
if err := os.WriteFile(configFile, []byte(tomlWithNested), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.SetConfigFile(configFile)
|
||||||
|
if err := cm.SetConfigType("toml"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cm.ReadInConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level key should still work
|
||||||
|
if got := cm.GetInt("port"); got != 8080 {
|
||||||
|
t.Errorf("GetInt(port) = %d, want 8080", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dot notation for nested string
|
||||||
|
if got := cm.GetString("services.mas.server"); got != "192.168.1.15" {
|
||||||
|
t.Errorf("GetString(services.mas.server) = %q, want %q", got, "192.168.1.15")
|
||||||
|
}
|
||||||
|
if got := cm.GetString("services.mas.user"); got != "central" {
|
||||||
|
t.Errorf("GetString(services.mas.user) = %q, want %q", got, "central")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dot notation for nested bool
|
||||||
|
if got := cm.GetBool("services.mas.disabled"); got != false {
|
||||||
|
t.Errorf("GetBool(services.mas.disabled) = %v, want false", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dot notation for nested map
|
||||||
|
masMap := cm.GetStringMap("services.mas")
|
||||||
|
if masMap == nil {
|
||||||
|
t.Fatal("GetStringMap(services.mas) = nil")
|
||||||
|
}
|
||||||
|
if masMap["server"] != "192.168.1.15" {
|
||||||
|
t.Errorf("services.mas map server = %v, want %q", masMap["server"], "192.168.1.15")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three levels deep
|
||||||
|
if got := cm.GetString("services.salesforce.prod.host"); got != "sf.example.com" {
|
||||||
|
t.Errorf("GetString(services.salesforce.prod.host) = %q, want %q", got, "sf.example.com")
|
||||||
|
}
|
||||||
|
if got := cm.GetInt("services.salesforce.prod.port"); got != 443 {
|
||||||
|
t.Errorf("GetInt(services.salesforce.prod.port) = %d, want 443", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStringMap at intermediate level
|
||||||
|
sfProdMap := cm.GetStringMap("services.salesforce.prod")
|
||||||
|
if sfProdMap == nil {
|
||||||
|
t.Fatal("GetStringMap(services.salesforce.prod) = nil")
|
||||||
|
}
|
||||||
|
if sfProdMap["host"] != "sf.example.com" {
|
||||||
|
t.Errorf("salesforce.prod map host = %v, want %q", sfProdMap["host"], "sf.example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nonexistent nested key should return zero value
|
||||||
|
if got := cm.GetString("services.mas.nonexistent"); got != "" {
|
||||||
|
t.Errorf("GetString(services.mas.nonexistent) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetStringMap("services.nonexistent"); got != nil {
|
||||||
|
t.Errorf("GetStringMap(services.nonexistent) = %v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDotNotationCaseInsensitive(t *testing.T) {
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.Set("services", map[string]any{
|
||||||
|
"MAS": map[string]any{
|
||||||
|
"Server": "192.168.1.15",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should work regardless of case
|
||||||
|
if got := cm.GetString("services.mas.server"); got != "192.168.1.15" {
|
||||||
|
t.Errorf("GetString(services.mas.server) = %q, want %q", got, "192.168.1.15")
|
||||||
|
}
|
||||||
|
if got := cm.GetString("SERVICES.MAS.SERVER"); got != "192.168.1.15" {
|
||||||
|
t.Errorf("GetString(SERVICES.MAS.SERVER) = %q, want %q", got, "192.168.1.15")
|
||||||
|
}
|
||||||
|
if got := cm.GetString("Services.Mas.Server"); got != "192.168.1.15" {
|
||||||
|
t.Errorf("GetString(Services.Mas.Server) = %q, want %q", got, "192.168.1.15")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDotNotationWithAllGetters(t *testing.T) {
|
||||||
|
cm := NewConfigManager()
|
||||||
|
cm.Set("config", map[string]any{
|
||||||
|
"nested": map[string]any{
|
||||||
|
"string": "hello",
|
||||||
|
"int": 42,
|
||||||
|
"int64": int64(9223372036854775807),
|
||||||
|
"float": 3.14,
|
||||||
|
"bool": true,
|
||||||
|
"dur": "30s",
|
||||||
|
"strings": []string{"a", "b", "c"},
|
||||||
|
"ints": []any{1, 2, 3},
|
||||||
|
"map": map[string]any{"key": "value"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := cm.Get("config.nested.string"); got != "hello" {
|
||||||
|
t.Errorf("Get(config.nested.string) = %v, want %q", got, "hello")
|
||||||
|
}
|
||||||
|
if got := cm.GetString("config.nested.string"); got != "hello" {
|
||||||
|
t.Errorf("GetString(config.nested.string) = %q, want %q", got, "hello")
|
||||||
|
}
|
||||||
|
if got := cm.GetInt("config.nested.int"); got != 42 {
|
||||||
|
t.Errorf("GetInt(config.nested.int) = %d, want 42", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetInt64("config.nested.int64"); got != 9223372036854775807 {
|
||||||
|
t.Errorf("GetInt64(config.nested.int64) = %d, want 9223372036854775807", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetFloat64("config.nested.float"); got != 3.14 {
|
||||||
|
t.Errorf("GetFloat64(config.nested.float) = %f, want 3.14", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetBool("config.nested.bool"); got != true {
|
||||||
|
t.Errorf("GetBool(config.nested.bool) = %v, want true", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetDuration("config.nested.dur"); got != 30*time.Second {
|
||||||
|
t.Errorf("GetDuration(config.nested.dur) = %v, want 30s", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetStringSlice("config.nested.strings"); len(got) != 3 || got[0] != "a" {
|
||||||
|
t.Errorf("GetStringSlice(config.nested.strings) = %v, want [a b c]", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetIntSlice("config.nested.ints"); len(got) != 3 || got[0] != 1 {
|
||||||
|
t.Errorf("GetIntSlice(config.nested.ints) = %v, want [1 2 3]", got)
|
||||||
|
}
|
||||||
|
if got := cm.GetStringMap("config.nested.map"); got == nil || got["key"] != "value" {
|
||||||
|
t.Errorf("GetStringMap(config.nested.map) = %v, want map[key:value]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user