@@ -0,0 +1 @@
|
||||
Build apps/admin to populate this directory before building the release binary.
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package adminui serves the compiled management console from the API binary.
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var assets embed.FS
|
||||
|
||||
func Handler() http.Handler {
|
||||
root, _ := fs.Sub(assets, "dist")
|
||||
files := http.FileServer(http.FS(root))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
if strings.HasPrefix(name, ".") || strings.Contains(name, "/.") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := fs.Stat(root, "index.html"); err != nil {
|
||||
http.Error(w, "Management console not built. Run make admin-build, then rebuild the API.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if name != "" && name != "." {
|
||||
if info, err := fs.Stat(root, name); err == nil && !info.IsDir() {
|
||||
files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
if path.Ext(name) != "" || strings.HasPrefix(name, "assets/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
index, err := fs.ReadFile(root, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "Management console unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(index)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Package apperror defines safe application errors shared across module boundaries.
|
||||
package apperror
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrUnauthenticated = errors.New("authentication required")
|
||||
ErrForbidden = errors.New("permission denied")
|
||||
ErrConflict = errors.New("already exists")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUnavailable = errors.New("service unavailable")
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
// Package config loads and validates application configuration without logging secrets.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment, HTTPAddr string
|
||||
PGHost, PGPort, PGDatabase, PGUser, PGPassword, PGSSLMode, PGAdminDatabase string
|
||||
RedisAddr, RedisUsername, RedisPassword string
|
||||
RedisDB int
|
||||
}
|
||||
|
||||
// LoadEnvFile reads literal KEY=VALUE entries. Existing environment values win.
|
||||
// It never evaluates shell expressions or prints values.
|
||||
func LoadEnvFile(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open configuration file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
s := bufio.NewScanner(f)
|
||||
for line := 1; s.Scan(); line++ {
|
||||
v := strings.TrimSpace(s.Text())
|
||||
if v == "" || strings.HasPrefix(v, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(v, "=")
|
||||
key = strings.TrimSpace(key)
|
||||
if !ok || key == "" {
|
||||
return fmt.Errorf("invalid configuration at line %d", line)
|
||||
}
|
||||
for i, r := range key {
|
||||
if !(r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9') {
|
||||
return fmt.Errorf("invalid key at line %d", line)
|
||||
}
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 && (value[0] == '\'' && value[len(value)-1] == '\'' || value[0] == '"' && value[len(value)-1] == '"') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("set configuration at line %d: %w", line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.Err()
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
c := Config{Environment: env("APP_ENV", "local"), HTTPAddr: env("HTTP_ADDR", "127.0.0.1:8080"), PGHost: env("PGHOST", "192.168.100.127"), PGPort: env("PGPORT", "5432"), PGDatabase: env("PGDATABASE", "veyra"), PGUser: os.Getenv("PGUSER"), PGPassword: os.Getenv("PGPASSWORD"), PGSSLMode: env("PGSSLMODE", "disable"), PGAdminDatabase: env("PGADMIN_DATABASE", "postgres"), RedisAddr: env("REDIS_ADDR", "192.168.100.127:6379"), RedisUsername: os.Getenv("REDIS_USERNAME"), RedisPassword: os.Getenv("REDIS_PASSWORD")}
|
||||
var err error
|
||||
c.RedisDB, err = strconv.Atoi(env("REDIS_DB", "0"))
|
||||
if err != nil || c.RedisDB < 0 {
|
||||
return Config{}, fmt.Errorf("REDIS_DB must be a nonnegative integer")
|
||||
}
|
||||
port, err := strconv.Atoi(c.PGPort)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return Config{}, fmt.Errorf("PGPORT must be a valid port")
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(c.HTTPAddr); err != nil {
|
||||
return Config{}, fmt.Errorf("invalid HTTP_ADDR")
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(c.RedisAddr); err != nil {
|
||||
return Config{}, fmt.Errorf("invalid REDIS_ADDR")
|
||||
}
|
||||
switch c.PGSSLMode {
|
||||
case "disable", "allow", "prefer", "require", "verify-ca", "verify-full":
|
||||
default:
|
||||
return Config{}, fmt.Errorf("invalid PGSSLMODE")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c Config) ValidateDatabase() error {
|
||||
if c.PGUser == "" {
|
||||
return fmt.Errorf("PGUSER is required; configure the local .env file")
|
||||
}
|
||||
if c.PGHost == "" || c.PGDatabase == "" || c.PGAdminDatabase == "" {
|
||||
return fmt.Errorf("database host and names are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) DatabaseURL(database string) string {
|
||||
u := url.URL{Scheme: "postgres", Host: net.JoinHostPort(c.PGHost, c.PGPort), User: url.UserPassword(c.PGUser, c.PGPassword), Path: "/" + database}
|
||||
q := url.Values{"sslmode": {c.PGSSLMode}, "connect_timeout": {"5"}}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDatabaseURLPreservesCredentials(t *testing.T) {
|
||||
c := Config{PGHost: "192.168.100.127", PGPort: "5432", PGUser: "user@name", PGPassword: "p@ss:/?#&", PGSSLMode: "require"}
|
||||
u, err := url.Parse(c.DatabaseURL("veyra"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
password, _ := u.User.Password()
|
||||
if password != c.PGPassword || u.User.Username() != c.PGUser || u.Path != "/veyra" || u.Query().Get("sslmode") != "require" {
|
||||
t.Fatal("connection URL changed configuration values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectInvalidRedisDB(t *testing.T) {
|
||||
t.Setenv("REDIS_DB", "-1")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("negative database must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"veyra/services/api/internal/platform/config"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
func Open(ctx context.Context, c config.Config) (*pgxpool.Pool, error) {
|
||||
if err := c.ValidateDatabase(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := pgxpool.New(ctx, c.DatabaseURL(c.PGDatabase))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configure PostgreSQL pool")
|
||||
}
|
||||
if err := p.Ping(ctx); err != nil {
|
||||
p.Close()
|
||||
return nil, fmt.Errorf("PostgreSQL connection unavailable")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Create creates only the configured database and never drops or resets data.
|
||||
func Create(ctx context.Context, c config.Config) (bool, error) {
|
||||
if err := c.ValidateDatabase(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
conn, err := pgx.Connect(ctx, c.DatabaseURL(c.PGAdminDatabase))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("administrative PostgreSQL connection unavailable")
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock(hashtext($1)::bigint)", "veyra:create:"+c.PGDatabase); err != nil {
|
||||
return false, fmt.Errorf("lock database creation: %w", err)
|
||||
}
|
||||
var exists bool
|
||||
if err := conn.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", c.PGDatabase).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("inspect database: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{c.PGDatabase}.Sanitize()); err != nil {
|
||||
return false, fmt.Errorf("create database failed; verify role CREATEDB permission")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Migrate serializes concurrent runners and atomically records each migration.
|
||||
// Even the migration ledger follows the veyra_ prefix and string UUID ID rules.
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
conn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Release()
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(727198321)"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS veyra_schema_migration (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
version varchar(120) NOT NULL UNIQUE, checksum varchar(64) NOT NULL, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||
return fmt.Errorf("create migration ledger: %w", err)
|
||||
}
|
||||
entries, err := migrations.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
for _, entry := range entries {
|
||||
sql, err := migrations.ReadFile("migrations/" + entry.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
checksum := fmt.Sprintf("%x", sha256.Sum256(sql))
|
||||
var stored string
|
||||
err = tx.QueryRow(ctx, "SELECT checksum FROM veyra_schema_migration WHERE version=$1", entry.Name()).Scan(&stored)
|
||||
if err == nil {
|
||||
if stored != checksum {
|
||||
return fmt.Errorf("migration %s checksum mismatch", entry.Name())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != pgx.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(sql)); err != nil {
|
||||
return fmt.Errorf("apply %s: %w", entry.Name(), err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "INSERT INTO veyra_schema_migration(id,version,checksum) VALUES($1,$2,$3)", uuid.NewString(), entry.Name(), checksum); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Integration testing uses a disposable schema on an explicitly provided test database.
|
||||
func TestMigrationIntegration(t *testing.T) {
|
||||
dsn := os.Getenv("VEYRA_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set VEYRA_TEST_DATABASE_URL to run PostgreSQL integration tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
admin, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
schema := "veyra_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if _, err := admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.ConnConfig.RuntimeParams["search_path"] = schema
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_schema_migration").Scan(&count); err != nil || count != 2 {
|
||||
t.Fatalf("migration ledger count=%d err=%v", count, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES($1,$2)", uuid.NewString(), "Test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES('invalid','Test')"); err == nil {
|
||||
t.Fatal("invalid UUID was accepted")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "UPDATE veyra_schema_migration SET checksum='changed'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(ctx, pool); err == nil {
|
||||
t.Fatal("modified migration checksum was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE veyra_user (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
display_name varchar(80) NOT NULL CHECK (length(trim(display_name)) > 0),
|
||||
status varchar(16) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'DISABLED')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE veyra_account (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
user_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
mode varchar(16) NOT NULL CHECK (mode IN ('simulation', 'manual_real')),
|
||||
name varchar(80) NOT NULL CHECK (length(trim(name)) > 0),
|
||||
currency varchar(3) NOT NULL DEFAULT 'CNY' CHECK (currency = 'CNY'),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, mode)
|
||||
);
|
||||
CREATE INDEX veyra_account_user_id_idx ON veyra_account(user_id);
|
||||
@@ -0,0 +1,32 @@
|
||||
ALTER TABLE veyra_user ADD COLUMN role varchar(16) NOT NULL DEFAULT 'USER' CHECK (role IN ('USER', 'ADMIN'));
|
||||
|
||||
CREATE TABLE veyra_credential (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
user_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_user(id),
|
||||
login_name varchar(64) NOT NULL UNIQUE CHECK (login_name ~ '^[a-z0-9_]{3,64}$'),
|
||||
password_hash text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE veyra_session (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
user_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
token_hash varchar(64) NOT NULL UNIQUE,
|
||||
audience varchar(8) NOT NULL CHECK (audience IN ('app', 'admin')),
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (expires_at > created_at)
|
||||
);
|
||||
CREATE INDEX veyra_session_user_id_idx ON veyra_session(user_id);
|
||||
|
||||
ALTER TABLE veyra_account ADD COLUMN ledger_status varchar(16) NOT NULL DEFAULT 'INCOMPLETE' CHECK (ledger_status IN ('COMPLETE', 'INCOMPLETE'));
|
||||
|
||||
CREATE TABLE veyra_cash_entry (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
|
||||
entry_type varchar(16) NOT NULL CHECK (entry_type IN ('INITIAL')),
|
||||
amount_minor bigint NOT NULL CHECK (amount_minor >= 0 AND amount_minor <= 100000000000),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, entry_type)
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
package database
|
||||
|
||||
import "time"
|
||||
|
||||
type SchemaMigration struct {
|
||||
ID string `db:"id"`
|
||||
Version string `db:"version"`
|
||||
Checksum string `db:"checksum"`
|
||||
AppliedAt time.Time `db:"applied_at"`
|
||||
}
|
||||
|
||||
func (SchemaMigration) TableName() string { return "veyra_schema_migration" }
|
||||
@@ -0,0 +1,34 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Redis struct{ client *redis.Client }
|
||||
|
||||
func NewRedis(client *redis.Client) *Redis { return &Redis{client: client} }
|
||||
|
||||
var loginCounter = redis.NewScript(`local n = redis.call('INCR', KEYS[1]); if n == 1 then redis.call('EXPIRE', KEYS[1], 900) end; return n`)
|
||||
|
||||
// Login permits at most 20 attempts per IP and 10 per login in a 15-minute window.
|
||||
// Redis errors fail closed. Neither credential nor IP is stored in plaintext keys.
|
||||
func (r *Redis) Login(ctx context.Context, ip, login string) (bool, error) {
|
||||
for _, item := range []struct {
|
||||
kind, value string
|
||||
limit int64
|
||||
}{{"ip", ip, 20}, {"name", login, 10}} {
|
||||
key := fmt.Sprintf("veyra:login:%s:%x", item.kind, sha256.Sum256([]byte(item.value)))
|
||||
n, err := loginCounter.Run(ctx, r.client, []string{key}).Int64()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if n > item.limit {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user