Release v1.14.0 — monetization launch (E5-E8) #237
@@ -308,6 +308,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
Notifier: hub,
|
Notifier: hub,
|
||||||
ExportSignKey: cfg.ExportSignKey,
|
ExportSignKey: cfg.ExportSignKey,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
|
Robokassa: cfg.Robokassa,
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||||
|
|
||||||
@@ -316,6 +317,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
logger.Info("servers starting",
|
logger.Info("servers starting",
|
||||||
zap.String("http_addr", cfg.HTTPAddr),
|
zap.String("http_addr", cfg.HTTPAddr),
|
||||||
zap.String("grpc_addr", cfg.GRPCAddr))
|
zap.String("grpc_addr", cfg.GRPCAddr))
|
||||||
|
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
|
||||||
|
// still credits). Runs until ctx is cancelled.
|
||||||
|
go runOrderReaper(ctx, paymentsSvc, logger)
|
||||||
|
|
||||||
errc := make(chan error, 2)
|
errc := make(chan error, 2)
|
||||||
go func() { errc <- pushSrv.Run(ctx) }()
|
go func() { errc <- pushSrv.Run(ctx) }()
|
||||||
go func() { errc <- srv.Run(ctx) }()
|
go func() { errc <- srv.Run(ctx) }()
|
||||||
@@ -325,6 +330,26 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runOrderReaper periodically expires pending payment orders past their configured lifetime, until
|
||||||
|
// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired
|
||||||
|
// order.
|
||||||
|
func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
|
||||||
|
t := time.NewTicker(5 * time.Minute)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if n, err := p.ExpireOrders(ctx); err != nil {
|
||||||
|
log.Warn("order reaper: sweep failed", zap.Error(err))
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Info("order reaper: expired pending orders", zap.Int("count", n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// newMailer builds the confirm-code mailer: an SMTP relay when a host is
|
// newMailer builds the confirm-code mailer: an SMTP relay when a host is
|
||||||
// configured, otherwise the development log mailer (the code is logged, not sent).
|
// configured, otherwise the development log mailer (the code is logged, not sent).
|
||||||
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
|
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
|
||||||
|
|||||||
@@ -393,6 +393,21 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
|
||||||
|
// recovery anchor a first purchase requires (D36).
|
||||||
|
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||||
|
ids, err := s.Identities(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id.Kind == "email" && id.Confirmed {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListAccounts returns accounts for the admin user list, newest first, paginated
|
// ListAccounts returns accounts for the admin user list, newest first, paginated
|
||||||
// by limit and offset.
|
// by limit and offset.
|
||||||
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
|
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
|
"scrabble/backend/internal/robokassa"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/telemetry"
|
"scrabble/backend/internal/telemetry"
|
||||||
)
|
)
|
||||||
@@ -64,6 +65,9 @@ type Config struct {
|
|||||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||||
RendererURL string
|
RendererURL string
|
||||||
|
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
|
||||||
|
// leaves the direct order and Result-callback endpoints unregistered.
|
||||||
|
Robokassa robokassa.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults applied when the corresponding environment variable is unset.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
@@ -153,6 +157,13 @@ func Load() (Config, error) {
|
|||||||
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
robo := robokassa.Config{
|
||||||
|
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
|
||||||
|
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
|
||||||
|
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
|
||||||
|
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
|
||||||
|
}
|
||||||
|
|
||||||
c := Config{
|
c := Config{
|
||||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||||
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
||||||
@@ -170,6 +181,7 @@ func Load() (Config, error) {
|
|||||||
GuestRetention: guestRetention,
|
GuestRetention: guestRetention,
|
||||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||||
|
Robokassa: robo,
|
||||||
}
|
}
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -222,6 +234,9 @@ func (c Config) validate() error {
|
|||||||
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
|
||||||
|
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,3 +71,9 @@ func (s *Service) ExpireOrders(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
return s.store.expirePending(ctx, ttl, s.clock())
|
return s.store.expirePending(ctx, ttl, s.clock())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordPaymentEvent appends a payment lifecycle event (succeeded/failed/refunded) for the
|
||||||
|
// dispatcher to deliver to the user (live stream, botlink or email).
|
||||||
|
func (s *Service) RecordPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte) error {
|
||||||
|
return s.store.insertPaymentEvent(ctx, accountID, orderID, eventType, payload, s.clock())
|
||||||
|
}
|
||||||
|
|||||||
@@ -271,6 +271,27 @@ func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerP
|
|||||||
return outcome, nil
|
return outcome, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the
|
||||||
|
// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional.
|
||||||
|
func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte, now time.Time) error {
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("payments: event id: %w", err)
|
||||||
|
}
|
||||||
|
var pl any = postgres.NULL
|
||||||
|
if payload != nil {
|
||||||
|
pl = string(payload)
|
||||||
|
}
|
||||||
|
stmt := table.PaymentEvents.INSERT(
|
||||||
|
table.PaymentEvents.EventID, table.PaymentEvents.AccountID, table.PaymentEvents.OrderID,
|
||||||
|
table.PaymentEvents.Type, table.PaymentEvents.Payload, table.PaymentEvents.CreatedAt,
|
||||||
|
).VALUES(id, accountID, uuidOrNull(orderID), eventType, pl, now)
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("payments: insert %s event: %w", eventType, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// orderTTL reads the configured pending-order lifetime in whole seconds.
|
// orderTTL reads the configured pending-order lifetime in whole seconds.
|
||||||
func (s *Store) orderTTL(ctx context.Context) (int, error) {
|
func (s *Store) orderTTL(ctx context.Context) (int, error) {
|
||||||
var cfg model.Config
|
var cfg model.Config
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ func (s *Server) registerRoutes() {
|
|||||||
u.GET("/wallet/catalog", s.handleWalletCatalog)
|
u.GET("/wallet/catalog", s.handleWalletCatalog)
|
||||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||||
}
|
}
|
||||||
|
if s.payments != nil && s.robokassa.MerchantLogin != "" {
|
||||||
|
// Direct-rail (Robokassa): open a pending order (user group), and receive the verified
|
||||||
|
// Result callback the gateway forwards (internal group, gateway-only — the single writer).
|
||||||
|
u.POST("/wallet/order", s.handleWalletOrder)
|
||||||
|
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||||
|
}
|
||||||
if s.links != nil {
|
if s.links != nil {
|
||||||
// Account linking & merge. The request step always mails a code;
|
// Account linking & merge. The request step always mails a code;
|
||||||
// a required merge is revealed only after the code is verified, and the
|
// a required merge is revealed only after the code is verified, and the
|
||||||
@@ -266,6 +272,8 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusNotFound, "product_not_found"
|
return http.StatusNotFound, "product_not_found"
|
||||||
case errors.Is(err, payments.ErrNotAValue):
|
case errors.Is(err, payments.ErrNotAValue):
|
||||||
return http.StatusBadRequest, "not_a_value"
|
return http.StatusBadRequest, "not_a_value"
|
||||||
|
case errors.Is(err, payments.ErrNotAPack):
|
||||||
|
return http.StatusBadRequest, "not_a_pack"
|
||||||
case errors.Is(err, account.ErrInvalidEmail):
|
case errors.Is(err, account.ErrInvalidEmail):
|
||||||
return http.StatusBadRequest, "invalid_email"
|
return http.StatusBadRequest, "invalid_email"
|
||||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// providerRobokassa is the ledger/order provider tag for the direct (RUB) rail.
|
||||||
|
const providerRobokassa = "robokassa"
|
||||||
|
|
||||||
|
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
|
||||||
|
type walletOrderRequest struct {
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// walletOrderResponse returns the created order id and the provider launch URL the client opens.
|
||||||
|
type walletOrderResponse struct {
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWalletOrder opens a pending order to fund a chip pack and returns the Robokassa
|
||||||
|
// hosted-payment URL for the client to open. It is direct-rail only for now (VK/TG land later);
|
||||||
|
// it enforces the wallet gate and D36 (a direct purchase requires a confirmed email anchor). No
|
||||||
|
// chips are credited here — only later, by the verified Result callback.
|
||||||
|
func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req walletOrderRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productID, err := uuid.Parse(req.ProductID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
cxt, present, err := s.walletGate(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cxt.Kind != payments.SourceDirect {
|
||||||
|
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasEmail {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, walletOrderResponse{
|
||||||
|
OrderID: res.OrderID.String(),
|
||||||
|
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRobokassaResult is the verified Robokassa Result callback, reached only through the gateway
|
||||||
|
// (which forwards the provider's form parameters as a JSON object on the internal, gateway-only
|
||||||
|
// route). It verifies the Password2 signature, credits the matched order exactly once (idempotent,
|
||||||
|
// and honoured even if the order expired), records a succeeded event, and answers Robokassa's
|
||||||
|
// expected "OK<InvId>". A duplicate callback credits nothing but still answers OK.
|
||||||
|
func (s *Server) handleRobokassaResult(c *gin.Context) {
|
||||||
|
var params map[string]string
|
||||||
|
if err := c.ShouldBindJSON(¶ms); err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := url.Values{}
|
||||||
|
for k, val := range params {
|
||||||
|
v.Set(k, val)
|
||||||
|
}
|
||||||
|
orderID, outSum, ok := s.robokassa.VerifyResult(v)
|
||||||
|
if !ok {
|
||||||
|
s.log.Warn("robokassa result: bad signature")
|
||||||
|
c.String(http.StatusBadRequest, "bad sign")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
paid, err := payments.ParseMoney(outSum, payments.CurrencyRUB)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "bad amount")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
outcome, err := s.payments.Fund(ctx, orderID, providerRobokassa, orderID.String(), paid)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
|
||||||
|
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
|
||||||
|
s.log.Warn("robokassa result rejected", zap.String("order", orderID.String()), zap.Error(err))
|
||||||
|
c.String(http.StatusBadRequest, "rejected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Error("robokassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||||
|
c.String(http.StatusInternalServerError, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !outcome.AlreadyCredited {
|
||||||
|
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
|
||||||
|
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
|
||||||
|
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.String(http.StatusOK, "OK"+v.Get("InvId"))
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"scrabble/backend/internal/payments"
|
"scrabble/backend/internal/payments"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/render"
|
"scrabble/backend/internal/render"
|
||||||
|
"scrabble/backend/internal/robokassa"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
"scrabble/backend/internal/telemetry"
|
"scrabble/backend/internal/telemetry"
|
||||||
@@ -109,6 +110,9 @@ type Deps struct {
|
|||||||
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
||||||
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||||
Renderer *render.Client
|
Renderer *render.Client
|
||||||
|
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
|
||||||
|
// order and Result-callback endpoints unregistered.
|
||||||
|
Robokassa robokassa.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||||
@@ -136,6 +140,7 @@ type Server struct {
|
|||||||
banview *banview.View
|
banview *banview.View
|
||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
payments *payments.Service
|
payments *payments.Service
|
||||||
|
robokassa robokassa.Config
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
exportKey []byte
|
exportKey []byte
|
||||||
@@ -189,6 +194,7 @@ func New(addr string, deps Deps) *Server {
|
|||||||
banview: deps.BanView,
|
banview: deps.BanView,
|
||||||
ads: deps.Ads,
|
ads: deps.Ads,
|
||||||
payments: deps.Payments,
|
payments: deps.Payments,
|
||||||
|
robokassa: deps.Robokassa,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
renderer: deps.Renderer,
|
renderer: deps.Renderer,
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
|
|||||||
Reference in New Issue
Block a user