shared-sqs: Этапы 5+6 — Admin API, auth middleware, graceful shutdown
This commit is contained in:
@@ -0,0 +1,211 @@
|
|||||||
|
// app/admin/admin.go
|
||||||
|
// Admin API handlers for shared-sqs management
|
||||||
|
// Created: 2026-04-09
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"shared-sqs/app/models"
|
||||||
|
"shared-sqs/app/tenant"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler — admin API handler, holds TenantStore и admin token
|
||||||
|
type Handler struct {
|
||||||
|
store *tenant.TenantStore
|
||||||
|
adminToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler — создаёт admin handler
|
||||||
|
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
||||||
|
return &Handler{store: store, adminToken: adminToken}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes — регистрирует admin маршруты на переданном router
|
||||||
|
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
||||||
|
adminRouter := r.PathPrefix("/admin").Subrouter()
|
||||||
|
adminRouter.Use(h.bearerAuthMiddleware)
|
||||||
|
adminRouter.HandleFunc("/tenants", h.createTenant).Methods("POST")
|
||||||
|
adminRouter.HandleFunc("/tenants", h.listTenants).Methods("GET")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
|
||||||
|
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12)
|
||||||
|
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
expected := "Bearer " + h.adminToken
|
||||||
|
if authHeader != expected {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTenantRequest — тело запроса POST /admin/tenants
|
||||||
|
type createTenantRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MaxQueues int `json:"max_queues"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании)
|
||||||
|
type tenantCreateResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AccessKey string `json:"access_key"`
|
||||||
|
SecretKey string `json:"secret_key"`
|
||||||
|
MaxQueues int `json:"max_queues"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenantListItem — данные тенанта без secret_key (для List/Get)
|
||||||
|
type tenantListItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AccessKey string `json:"access_key"`
|
||||||
|
MaxQueues int `json:"max_queues"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTenant — POST /admin/tenants
|
||||||
|
func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createTenantRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.store.Create(req.Name, req.MaxQueues)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("admin: failed to create tenant: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "failed to create tenant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(tenantCreateResponse{
|
||||||
|
ID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
AccessKey: t.AccessKey,
|
||||||
|
SecretKey: t.SecretKey,
|
||||||
|
MaxQueues: t.MaxQueues,
|
||||||
|
CreatedAt: t.CreatedAt,
|
||||||
|
Active: t.Active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// listTenants — GET /admin/tenants
|
||||||
|
func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenants := h.store.List()
|
||||||
|
items := make([]tenantListItem, 0, len(tenants))
|
||||||
|
for _, t := range tenants {
|
||||||
|
items = append(items, tenantListItem{
|
||||||
|
ID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
AccessKey: t.AccessKey,
|
||||||
|
MaxQueues: t.MaxQueues,
|
||||||
|
CreatedAt: t.CreatedAt,
|
||||||
|
Active: t.Active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTenant — GET /admin/tenants/{id}
|
||||||
|
func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
t, ok := h.store.GetByID(id)
|
||||||
|
if !ok {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(tenantListItem{
|
||||||
|
ID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
AccessKey: t.AccessKey,
|
||||||
|
MaxQueues: t.MaxQueues,
|
||||||
|
CreatedAt: t.CreatedAt,
|
||||||
|
Active: t.Active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteTenant — DELETE /admin/tenants/{id}
|
||||||
|
// Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak)
|
||||||
|
func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
t, ok := h.store.GetByID(id)
|
||||||
|
if !ok {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Удаляем все очереди тенанта из SyncQueues
|
||||||
|
prefix := t.AccessKey + ":"
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
for key := range models.SyncQueues.Queues {
|
||||||
|
if strings.HasPrefix(key, prefix) {
|
||||||
|
delete(models.SyncQueues.Queues, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
|
||||||
|
h.store.Delete(id)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminHealthDetail — ответ GET /admin/health
|
||||||
|
type adminHealthDetail struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
TenantCount int `json:"tenant_count"`
|
||||||
|
QueueCount int `json:"queue_count"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailedHealth — GET /admin/health
|
||||||
|
func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenants := h.store.List()
|
||||||
|
models.SyncQueues.RLock()
|
||||||
|
queueCount := len(models.SyncQueues.Queues)
|
||||||
|
msgCount := 0
|
||||||
|
for _, q := range models.SyncQueues.Queues {
|
||||||
|
msgCount += len(q.Messages)
|
||||||
|
}
|
||||||
|
models.SyncQueues.RUnlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(adminHealthDetail{
|
||||||
|
Status: "ok",
|
||||||
|
TenantCount: len(tenants),
|
||||||
|
QueueCount: queueCount,
|
||||||
|
MessageCount: msgCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
+115
-68
@@ -1,80 +1,127 @@
|
|||||||
|
// app/cmd/goaws.go
|
||||||
|
// Entry point — shared-sqs server
|
||||||
|
// Updated: 2026-04-09 — добавлены TenantStore, admin token, graceful shutdown (Trap #13)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"context"
|
||||||
"net/http"
|
"flag"
|
||||||
"os"
|
"net/http"
|
||||||
"time"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/conf"
|
||||||
|
"shared-sqs/app/gosqs"
|
||||||
|
"shared-sqs/app/models"
|
||||||
|
"shared-sqs/app/router"
|
||||||
|
"shared-sqs/app/tenant"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
|
||||||
"shared-sqs/app/gosqs"
|
|
||||||
"shared-sqs/app/router"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var filename string
|
var configFile string
|
||||||
var debug bool
|
var adminToken string
|
||||||
var loglevel string
|
var port string
|
||||||
flag.StringVar(&filename, "config", "", "config file location + name")
|
var debug bool
|
||||||
flag.BoolVar(&debug, "debug", false, "set debug log level")
|
var loglevel string
|
||||||
flag.StringVar(&loglevel, "loglevel", "info", "log level (default info)")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
log.SetFormatter(&log.JSONFormatter{})
|
flag.StringVar(&configFile, "config", "", "config file location")
|
||||||
log.SetOutput(os.Stdout)
|
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
|
||||||
|
flag.StringVar(&port, "port", "4100", "listen port")
|
||||||
|
flag.BoolVar(&debug, "debug", false, "set debug log level")
|
||||||
|
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
if debug {
|
log.SetFormatter(&log.JSONFormatter{})
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetOutput(os.Stdout)
|
||||||
} else {
|
|
||||||
level, err := log.ParseLevel(loglevel)
|
|
||||||
if err != nil {
|
|
||||||
log.SetLevel(log.InfoLevel)
|
|
||||||
log.Warnf("Failed to parse loglevel %v, defaulting to info", loglevel)
|
|
||||||
} else {
|
|
||||||
log.SetLevel(level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
env := "Local"
|
if debug {
|
||||||
if flag.NArg() > 0 {
|
log.SetLevel(log.DebugLevel)
|
||||||
env = flag.Arg(0)
|
} else {
|
||||||
}
|
level, err := log.ParseLevel(loglevel)
|
||||||
|
if err != nil {
|
||||||
portNumbers := conf.LoadYamlConfig(filename, env)
|
log.SetLevel(log.InfoLevel)
|
||||||
|
log.Warnf("Failed to parse loglevel %v, defaulting to info", loglevel)
|
||||||
if models.CurrentEnvironment.LogToFile {
|
} else {
|
||||||
filename := models.CurrentEnvironment.LogFile
|
log.SetLevel(level)
|
||||||
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
}
|
||||||
if err == nil {
|
}
|
||||||
log.SetOutput(file)
|
|
||||||
} else {
|
// Admin token: flag > env SHARED_SQS_ADMIN_TOKEN > fatal (Trap #13)
|
||||||
log.Infof("Failed to log to file: %s, using default stderr", filename)
|
if adminToken == "" {
|
||||||
}
|
adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN")
|
||||||
}
|
}
|
||||||
|
if adminToken == "" {
|
||||||
r := router.New()
|
log.Fatal("admin token required: use --admin-token flag or SHARED_SQS_ADMIN_TOKEN env var")
|
||||||
|
}
|
||||||
quit := make(chan bool, 0)
|
|
||||||
go gosqs.PeriodicTasks(1*time.Second, quit)
|
// Загрузить конфиг (очереди, env — без SNS)
|
||||||
|
env := "Local"
|
||||||
if len(portNumbers) == 1 {
|
if flag.NArg() > 0 {
|
||||||
log.Warnf("GoAws listening on: 0.0.0.0:%s", portNumbers[0])
|
env = flag.Arg(0)
|
||||||
err := http.ListenAndServe("0.0.0.0:"+portNumbers[0], r)
|
}
|
||||||
log.Fatal(err)
|
conf.LoadYamlConfig(configFile, env)
|
||||||
} else if len(portNumbers) == 2 {
|
|
||||||
go func() {
|
if models.CurrentEnvironment.LogToFile {
|
||||||
log.Warnf("GoAws listening on: 0.0.0.0:%s", portNumbers[0])
|
filename := models.CurrentEnvironment.LogFile
|
||||||
err := http.ListenAndServe("0.0.0.0:"+portNumbers[0], r)
|
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||||
log.Fatal(err)
|
if err == nil {
|
||||||
}()
|
log.SetOutput(file)
|
||||||
log.Warnf("GoAws listening on: 0.0.0.0:%s", portNumbers[1])
|
} else {
|
||||||
err := http.ListenAndServe("0.0.0.0:"+portNumbers[1], r)
|
log.Infof("Failed to log to file: %s, using default stdout", filename)
|
||||||
log.Fatal(err)
|
}
|
||||||
} else {
|
}
|
||||||
log.Fatal("Not enough or too many ports defined to start GoAws.")
|
|
||||||
}
|
// Инициализация in-memory TenantStore
|
||||||
|
tenantStore := tenant.NewTenantStore()
|
||||||
|
|
||||||
|
// Роутер с tenant auth и admin API
|
||||||
|
r := router.New(tenantStore, adminToken)
|
||||||
|
|
||||||
|
// PeriodicTasks — visibility timeout, DLQ, deduplication
|
||||||
|
quit := make(chan bool)
|
||||||
|
go gosqs.PeriodicTasks(1*time.Second, quit)
|
||||||
|
|
||||||
|
// HTTP сервер с таймаутами
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: "0.0.0.0:" + port,
|
||||||
|
Handler: r,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s)
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск в горутине для graceful shutdown
|
||||||
|
serverErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
log.Infof("shared-sqs listening on 0.0.0.0:%s", port)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
serverErr <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Graceful shutdown по SIGTERM/SIGINT (Trap #13)
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case sig := <-sigCh:
|
||||||
|
log.Infof("Received signal %s, shutting down", sig)
|
||||||
|
case err := <-serverErr:
|
||||||
|
log.Fatalf("Server error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Остановить PeriodicTasks
|
||||||
|
close(quit)
|
||||||
|
|
||||||
|
// Дать 10 секунд на завершение текущих HTTP запросов
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Errorf("Server shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
log.Info("shared-sqs stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// app/router/router.go
|
||||||
|
// HTTP router для shared-sqs
|
||||||
|
// Updated: 2026-04-09 — добавлены TenantStore, admin API, auth middleware
|
||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,23 +11,34 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"shared-sqs/app/admin"
|
||||||
|
"shared-sqs/app/auth"
|
||||||
"shared-sqs/app/interfaces"
|
"shared-sqs/app/interfaces"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
sqs "shared-sqs/app/gosqs"
|
sqs "shared-sqs/app/gosqs"
|
||||||
|
"shared-sqs/app/tenant"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New returns a new router
|
// New — создаёт HTTP router с tenant auth и admin API
|
||||||
func New() http.Handler {
|
func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
|
||||||
r.HandleFunc("/", actionHandler).Methods("GET", "POST")
|
// /health — публичный, без auth
|
||||||
r.HandleFunc("/health", health).Methods("GET")
|
r.HandleFunc("/health", health).Methods("GET")
|
||||||
r.HandleFunc("/{account}", actionHandler).Methods("GET", "POST")
|
|
||||||
r.HandleFunc("/queue/{queueName}", actionHandler).Methods("GET", "POST")
|
// Admin API — Bearer token auth, регистрируется через AdminHandler
|
||||||
r.HandleFunc("/{account}/{queueName}", actionHandler).Methods("GET", "POST")
|
adminHandler := admin.NewHandler(tenantStore, adminToken)
|
||||||
|
adminHandler.RegisterRoutes(r)
|
||||||
|
|
||||||
|
// SQS API — tenant auth middleware
|
||||||
|
sqsRouter := r.NewRoute().Subrouter()
|
||||||
|
sqsRouter.Use(auth.AuthMiddleware(tenantStore))
|
||||||
|
sqsRouter.HandleFunc("/", actionHandler).Methods("GET", "POST")
|
||||||
|
sqsRouter.HandleFunc("/{account}", actionHandler).Methods("GET", "POST")
|
||||||
|
sqsRouter.HandleFunc("/queue/{queueName}", actionHandler).Methods("GET", "POST")
|
||||||
|
sqsRouter.HandleFunc("/{account}/{queueName}", actionHandler).Methods("GET", "POST")
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -56,7 +70,7 @@ _, _ = w.Write(result)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// routingTableV1 содержит только SQS actions — SNS удалён
|
// routingTableV1 — только SQS actions (SNS удалён)
|
||||||
var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
|
var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
|
||||||
"CreateQueue": sqs.CreateQueueV1,
|
"CreateQueue": sqs.CreateQueueV1,
|
||||||
"ListQueues": sqs.ListQueuesV1,
|
"ListQueues": sqs.ListQueuesV1,
|
||||||
@@ -80,8 +94,7 @@ fmt.Fprint(w, "OK")
|
|||||||
|
|
||||||
func actionHandler(w http.ResponseWriter, req *http.Request) {
|
func actionHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
action := extractAction(req)
|
action := extractAction(req)
|
||||||
log.WithFields(
|
log.WithFields(log.Fields{
|
||||||
log.Fields{
|
|
||||||
"action": action,
|
"action": action,
|
||||||
"url": req.URL,
|
"url": req.URL,
|
||||||
}).Debug("Handling URL request")
|
}).Debug("Handling URL request")
|
||||||
|
|||||||
Reference in New Issue
Block a user