Plan independent BFF and adapt selected Sendico foundations (#34)
This commit is contained in:
1 parent
1e191fca42
commit
45666d33fc
17 files changed
+854
-15
No files matched your search
@@ -0,0 +1,15 @@
|
||||
# BFF guidelines
|
||||
|
||||
Follow root AGENTS.md. This is an independent Go module; Sendico is an external
|
||||
provider, not a local module dependency. Never add sibling-path replacements,
|
||||
shared database access or internal Sendico discovery/messaging dependencies.
|
||||
|
||||
Keep HTTP DTOs/mappers, application models/use cases and provider adapters distinct.
|
||||
Resolve ownership from the authenticated SXCHNGE principal and server-side mappings.
|
||||
Only adapters may expose provider transport types internally. Adapted code belongs
|
||||
to SXCHNGE; record source revision and changes in PROVENANCE.md.
|
||||
|
||||
Review the complete backend contribution before delivery. Run `make test-backend`
|
||||
and `make lint-backend` at the repository root. Add behavioral tests for changed
|
||||
boundaries and failure paths. Errors returned publicly must be stable typed codes;
|
||||
never expose raw provider errors, secrets, document contents or credentials.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Sendico reuse provenance
|
||||
|
||||
Source repository: `S_I/sendico`; revision `8bb20dcd622dd8eb92d289b6b34411e7eccab083`.
|
||||
Inspected 2026-09-12. These are owned forks inside SXCHNGE, not runtime imports.
|
||||
Source files below match the committed revision; local dependency edits were not used.
|
||||
No generated Sendico SDK, shared database models, deployment configuration or credentials were copied.
|
||||
|
||||
## `internal/browser/policy.go`
|
||||
|
||||
- Source: [api/edge/shared/auth/browser/browser.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/auth/browser/browser.go)
|
||||
- Source Git blob: `3af7b54aea277d91e0d8c3eba9ee84c155f4b624`
|
||||
- Adaptation: Adapted cookie creation/clearing, origin validation and CSRF checks. Replaced Sendico names/errors/env path resolution with explicit SXCHNGE policy; checks do not depend on a client-controlled transport header. Host-only Secure/HttpOnly cookie, Lax same-site navigation, duplicate-cookie rejection and explicit expiry validation.
|
||||
- Tests are SXCHNGE-specific behavioral checks for the adapted boundary.
|
||||
|
||||
## `internal/credential/token.go`
|
||||
|
||||
- Source: [api/edge/shared/storage/verification/hash.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/verification/hash.go)
|
||||
- Source Git blob: `36fadcf404bfc77608b28cca1e669a4a62f31774`
|
||||
- Adaptation: Adapted generateMagic/tokenHash only: 32-byte crypto/rand, URL-safe encoding, SHA-256 digest. Added constant-time matching. Excluded deterministic token generation, Sendico models and all numeric OTP hashing.
|
||||
- Tests are SXCHNGE-specific behavioral checks for the adapted boundary.
|
||||
|
||||
## `internal/evidence/key.go`
|
||||
|
||||
- Source: [api/edge/shared/storage/s3key/key.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/s3key/key.go)
|
||||
- Source Git blob: `e3cca16304d2c4cb9f256f510d5a1a30a21e22a6`
|
||||
- Adaptation: Adapted relative object-key confinement. Replaced merrors with a local sentinel and rejected encoded sequences, backslashes and embedded control characters. Not a filesystem or customer-authorization utility.
|
||||
- Tests are SXCHNGE-specific behavioral checks for the adapted boundary.
|
||||
|
||||
## Maintenance
|
||||
|
||||
The command, operational HTTP shell, module configuration and tests are new SXCHNGE code.
|
||||
There is no automatic synchronization or promise of source compatibility with Sendico.
|
||||
For an upstream fix, review the pinned source difference, port only the relevant behavior,
|
||||
record a new provenance entry and run backend tests/vet. Provider contract changes belong
|
||||
in the future Sendico adapter and its transport fixtures, not application models or widgets.
|
||||
|
||||
These primitives do not create sessions, deliver email, validate TOTP or upload documents.
|
||||
Use them inside the durable flows described in the [BFF plan](../docs/architecture/bff.md).
|
||||
+33
-9
@@ -1,14 +1,38 @@
|
||||
# SXCHNGE backend
|
||||
# SXCHNGE BFF
|
||||
|
||||
Reserved for a small backend for frontend (BFF) using a supported Sendico API. No service, runtime, or dependencies have been initialized yet.
|
||||
Independent Go module with an operational HTTP shell and selected, adapted
|
||||
Sendico foundations. Sendico remains an external financial-services provider;
|
||||
there are no Sendico module imports, shared databases or sibling-path dependencies.
|
||||
See the [architecture and service plan](../docs/architecture/bff.md) and
|
||||
[reuse provenance](PROVENANCE.md).
|
||||
|
||||
Planned responsibilities:
|
||||
## Run and validate
|
||||
|
||||
- Consumer authentication and sessions, with a stable mapping to organization-scoped Sendico customer references.
|
||||
- Consumer-authorized access to onboarding, quotes, orders, and transaction history.
|
||||
- Product-specific orchestration and presentation of Sendico KYC, eligibility, and remaining-limit responses.
|
||||
- Server-side integration credentials and request correlation with Sendico.
|
||||
Requires Go 1.26 or later. From the repository root:
|
||||
|
||||
Sendico owns payment execution, authoritative KYC evidence and verification records, policy evaluation, and limit reservations/enforcement. Do not copy its existing BFF or import local Sendico implementation modules.
|
||||
```sh
|
||||
make test-backend
|
||||
make lint-backend
|
||||
make run-backend
|
||||
```
|
||||
|
||||
Before implementation, agree on customer registration and identity mapping, customer KYC operations, eligibility/limits reads, customer-scoped payment access, and retry/idempotency behavior. Validate consumer isolation even when multiple customers use the same sxchnge organization and integration principal.
|
||||
The default address is `127.0.0.1:8082`; override with `SXCHNGE_HTTP_ADDR`.
|
||||
`GET /healthz` reports process health. `GET /readyz` deliberately returns **503**
|
||||
with `not_configured`: persistence, authentication and Sendico are not connected.
|
||||
No product routes return dummy success. The process handles SIGINT/SIGTERM with
|
||||
bounded graceful shutdown. Production transport must terminate HTTPS.
|
||||
|
||||
## Present components
|
||||
|
||||
- `cmd/bff`, `internal/httpapi`: HTTP process, timeouts and operational endpoints.
|
||||
- `internal/browser`: explicit HTTPS origin/CSRF policy and Secure HttpOnly
|
||||
host-only session cookie. Apply this policy to every future browser mutation;
|
||||
it is not yet connected to product routes because those do not exist.
|
||||
- `internal/credential`: random opaque credentials and storage digests. Never use
|
||||
this helper for passwords or short OTP codes.
|
||||
- `internal/evidence`: confined server-generated object keys; not a substitute
|
||||
for ownership, upload validation or private storage controls.
|
||||
|
||||
Only the standard library is required. Larger adapted flows, persistence,
|
||||
provider DTOs/mappers, email and live integration follow the plan in separate
|
||||
contributions. Source-derived components evolve independently inside this repo.
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/internal/httpapi"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("BFF stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
addr := os.Getenv("SXCHNGE_HTTP_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:8082"
|
||||
}
|
||||
server := &http.Server{Addr: addr, Handler: httpapi.New(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
failures := make(chan error, 1)
|
||||
go func() { failures <- server.ListenAndServe() }()
|
||||
slog.Info("BFF operational shell starting", "address", addr, "provider_configured", false)
|
||||
select {
|
||||
case err := <-failures:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
slog.Info("BFF shutting down")
|
||||
return server.Shutdown(shutdown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.sendico.io/tech/sxchnge/bff
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,118 @@
|
||||
// Adapted from Sendico edge/shared/auth/browser; see backend/PROVENANCE.md.
|
||||
package browser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionCookie = "__Host-sxchnge_session"
|
||||
CSRFHeader = "X-CSRF-Protection"
|
||||
CSRFValue = "1"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOrigin = errors.New("browser origin is not allowed")
|
||||
ErrCSRF = errors.New("browser CSRF protection is required")
|
||||
ErrSession = errors.New("browser session is missing or invalid")
|
||||
)
|
||||
|
||||
type Policy struct{ origins map[string]struct{} }
|
||||
|
||||
// New accepts explicit HTTPS origins. Browser session transport is chosen by the
|
||||
// server route; a client-controlled transport header cannot turn protection off.
|
||||
func New(allowedOrigins []string) (*Policy, error) {
|
||||
p := &Policy{origins: make(map[string]struct{}, len(allowedOrigins))}
|
||||
for _, value := range allowedOrigins {
|
||||
origin, ok := canonicalOrigin(value)
|
||||
if !ok {
|
||||
return nil, ErrOrigin
|
||||
}
|
||||
p.origins[origin] = struct{}{}
|
||||
}
|
||||
if len(p.origins) == 0 {
|
||||
return nil, ErrOrigin
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func canonicalOrigin(value string) (string, bool) {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.Opaque != "" {
|
||||
return "", false
|
||||
}
|
||||
return "https://" + strings.ToLower(u.Host), true
|
||||
}
|
||||
|
||||
// Validate must guard every browser-session mutation, including login/logout.
|
||||
// It assumes CORS never grants credentialed access to untrusted origins.
|
||||
func (p *Policy) Validate(r *http.Request) error {
|
||||
if r == nil {
|
||||
return ErrCSRF
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return nil
|
||||
}
|
||||
if r.Header.Get(CSRFHeader) != CSRFValue {
|
||||
return ErrCSRF
|
||||
}
|
||||
origin, ok := canonicalOrigin(r.Header.Get("Origin"))
|
||||
if !ok || p == nil {
|
||||
return ErrOrigin
|
||||
}
|
||||
if _, allowed := p.origins[origin]; !allowed {
|
||||
return ErrOrigin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Credential(r *http.Request) (string, error) {
|
||||
if r == nil {
|
||||
return "", ErrSession
|
||||
}
|
||||
var value string
|
||||
for _, c := range r.Cookies() {
|
||||
if c.Name != SessionCookie {
|
||||
continue
|
||||
}
|
||||
if value != "" || c.Value == "" {
|
||||
return "", ErrSession
|
||||
}
|
||||
value = c.Value
|
||||
}
|
||||
if value == "" {
|
||||
return "", ErrSession
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func SetSession(w http.ResponseWriter, token string, now, expires time.Time) error {
|
||||
age := int(expires.Sub(now).Seconds())
|
||||
if token == "" || age < 1 {
|
||||
return ErrSession
|
||||
}
|
||||
c := cookie(token)
|
||||
c.Expires = expires.UTC()
|
||||
c.MaxAge = age
|
||||
if c.Valid() != nil {
|
||||
return ErrSession
|
||||
}
|
||||
http.SetCookie(w, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearSession(w http.ResponseWriter) {
|
||||
c := cookie("")
|
||||
c.Expires = time.Unix(1, 0).UTC()
|
||||
c.MaxAge = -1
|
||||
http.SetCookie(w, c)
|
||||
}
|
||||
|
||||
func cookie(value string) *http.Cookie {
|
||||
return &http.Cookie{Name: SessionCookie, Value: value, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMutationRequiresOriginAndHeader(t *testing.T) {
|
||||
p, err := New([]string{"https://app.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
origin, header, transport string
|
||||
valid bool
|
||||
}{
|
||||
{"https://app.example.com", "1", "", true},
|
||||
{"https://APP.example.com", "1", "", true},
|
||||
{"https://evil.example.com", "1", "", false},
|
||||
{"https://app.example.com.evil.test", "1", "", false},
|
||||
{"https://app.example.com/path", "1", "", false},
|
||||
{"https://app.example.com?query", "1", "", false},
|
||||
{"https://app.example.com#fragment", "1", "", false},
|
||||
{"https://app.example.com", "", "bearer", false},
|
||||
{"", "1", "bearer", false},
|
||||
{"null", "1", "cookie", false},
|
||||
} {
|
||||
r := httptest.NewRequest(http.MethodPost, "https://app.example.com/api/session", nil)
|
||||
r.Header.Set("Origin", tc.origin)
|
||||
r.Header.Set(CSRFHeader, tc.header)
|
||||
r.Header.Set("X-Auth-Transport", tc.transport)
|
||||
if got := p.Validate(r); (got == nil) != tc.valid {
|
||||
t.Errorf("%+v: %v", tc, got)
|
||||
}
|
||||
}
|
||||
if p.Validate(httptest.NewRequest(http.MethodGet, "https://app.example.com/api/session", nil)) != nil {
|
||||
t.Fatal("read rejected")
|
||||
}
|
||||
for _, origin := range []string{"*", "http://app.example.com", "https://user@app.example.com", "https://app.example.com/"} {
|
||||
if _, err := New([]string{origin}); err == nil {
|
||||
t.Errorf("accepted configuration %q", origin)
|
||||
}
|
||||
}
|
||||
if _, err := New(nil); err == nil {
|
||||
t.Fatal("empty origin policy accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookie(t *testing.T) {
|
||||
now := time.Date(2026, 9, 12, 0, 0, 0, 0, time.UTC)
|
||||
w := httptest.NewRecorder()
|
||||
if err := SetSession(w, "opaque-token", now, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := w.Result().Cookies()[0]
|
||||
if c.Name != SessionCookie || !c.Secure || !c.HttpOnly || c.Path != "/" || c.Domain != "" || c.SameSite != http.SameSiteLaxMode || c.MaxAge != 3600 {
|
||||
t.Fatalf("bad cookie: %+v", c)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, "https://app.example.com/api/session", nil)
|
||||
r.AddCookie(c)
|
||||
if value, err := Credential(r); err != nil || value != "opaque-token" {
|
||||
t.Fatal("cookie not read")
|
||||
}
|
||||
r.AddCookie(c)
|
||||
if _, err := Credential(r); err == nil {
|
||||
t.Fatal("ambiguous credential accepted")
|
||||
}
|
||||
if err := SetSession(httptest.NewRecorder(), "token", now, now); err == nil {
|
||||
t.Fatal("expired cookie accepted")
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
ClearSession(w)
|
||||
c = w.Result().Cookies()[0]
|
||||
if c.MaxAge != -1 || c.Value != "" || c.Path != "/" || !c.Secure {
|
||||
t.Fatal("cookie not cleared safely")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Adapted from Sendico verification/hash.go; see backend/PROVENANCE.md.
|
||||
package credential
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// New returns an opaque credential and its storage digest. Store only the digest.
|
||||
// This is for high-entropy tokens, never passwords or short numeric OTP codes.
|
||||
func New() (raw, digest string, err error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err = rand.Read(bytes); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
raw = base64.RawURLEncoding.EncodeToString(bytes)
|
||||
return raw, Digest(raw), nil
|
||||
}
|
||||
|
||||
func Digest(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func Matches(raw, digest string) bool {
|
||||
return raw != "" && subtle.ConstantTimeCompare([]byte(Digest(raw)), []byte(digest)) == 1
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpaqueCredential(t *testing.T) {
|
||||
raw, digest, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil || len(decoded) != 32 {
|
||||
t.Fatal("credential must contain 256 random bits")
|
||||
}
|
||||
if raw == digest || !Matches(raw, digest) || Matches("other", digest) || Matches("", Digest("")) {
|
||||
t.Fatal("invalid digest matching behavior")
|
||||
}
|
||||
other, _, err := New()
|
||||
if err != nil || other == raw {
|
||||
t.Fatal("credentials must be independently generated")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Adapted from Sendico edge/shared/storage/s3key; see backend/PROVENANCE.md.
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var ErrInvalidKey = errors.New("invalid evidence key")
|
||||
|
||||
// JoinKey confines a server-generated object reference to a relative prefix.
|
||||
// It is not an ownership check or a local-filesystem path sanitizer. Inputs must
|
||||
// not be URL-decoded or interpreted as filesystem paths after validation.
|
||||
func JoinKey(prefix, reference string) (string, error) {
|
||||
a, err := relative(prefix)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := relative(reference)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path.Join(a, b), nil
|
||||
}
|
||||
|
||||
func relative(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.ContainsAny(value, "\\%") || strings.ContainsFunc(value, unicode.IsControl) {
|
||||
return "", ErrInvalidKey
|
||||
}
|
||||
for _, segment := range strings.Split(value, "/") {
|
||||
if segment == ".." {
|
||||
return "", ErrInvalidKey
|
||||
}
|
||||
}
|
||||
clean := path.Clean(value)
|
||||
if value == "" || clean == "." || path.IsAbs(clean) {
|
||||
return "", ErrInvalidKey
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package evidence
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestKeyConfinement(t *testing.T) {
|
||||
for _, ref := range []string{"", ".", "..", "../other", "/other", "a/../../other", "a\\..\\other", "%2e%2e/other", "bad\x00key"} {
|
||||
if _, err := JoinKey("customer/documents", ref); err == nil {
|
||||
t.Errorf("accepted %q", ref)
|
||||
}
|
||||
if _, err := JoinKey(ref, "document"); err == nil {
|
||||
t.Errorf("accepted prefix %q", ref)
|
||||
}
|
||||
}
|
||||
got, err := JoinKey("customer/documents/", "passport/front")
|
||||
if err != nil || got != "customer/documents/passport/front" {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// New is the operational shell. Product routes will be added only with their
|
||||
// persistence, authentication and provider adapters, never simulated success.
|
||||
func New() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
write(w, http.StatusOK, "ok")
|
||||
})
|
||||
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
write(w, http.StatusServiceUnavailable, "not_configured")
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func write(w http.ResponseWriter, status int, value string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Status string `json:"status"`
|
||||
}{value})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOperationalShellDoesNotClaimReadiness(t *testing.T) {
|
||||
handler := New()
|
||||
for path, want := range map[string]int{"/healthz": 200, "/readyz": 503, "/api/v1/session": 404} {
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||
if w.Code != want {
|
||||
t.Errorf("%s: %d, want %d", path, w.Code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user