fix(fn-run): propagar stdout/stderr de bash functions library-style #1
@@ -0,0 +1,58 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// jwtCtxKey es el tipo no exportado usado como key del context para las claims
|
||||
// inyectadas por JWTMiddleware. Usar un tipo dedicado evita colisiones con
|
||||
// otros middlewares que guarden valores en el context.
|
||||
type jwtCtxKey struct{}
|
||||
|
||||
// JWTClaimsFromContext extrae las claims inyectadas por JWTMiddleware.
|
||||
// Retorna (claims, true) si existen en el context, o (zero, false) si no.
|
||||
func JWTClaimsFromContext(ctx context.Context) (JWTClaims, bool) {
|
||||
v, ok := ctx.Value(jwtCtxKey{}).(JWTClaims)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// JWTMiddleware retorna un Middleware que extrae el JWT del header
|
||||
// Authorization: Bearer <token>, lo valida con JWTValidate, e inyecta las
|
||||
// claims en el context para que handlers posteriores las lean con
|
||||
// JWTClaimsFromContext. Responde 401 si el header falta, tiene formato
|
||||
// incorrecto o el token es invalido/expirado.
|
||||
func JWTMiddleware(secret string) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusUnauthorized, Code: "missing_token",
|
||||
Message: "falta header Authorization",
|
||||
})
|
||||
return
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusUnauthorized, Code: "invalid_token",
|
||||
Message: "se esperaba Authorization: Bearer <token>",
|
||||
})
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(auth, prefix))
|
||||
claims, err := JWTValidate(token, secret)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusUnauthorized, Code: "invalid_token",
|
||||
Message: "token invalido",
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), jwtCtxKey{}, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: jwt_middleware
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func JWTMiddleware(secret string) Middleware"
|
||||
description: "Middleware HTTP que extrae el JWT del header Authorization: Bearer y valida con JWTValidate. Inyecta las claims en el context del request (recuperables con JWTClaimsFromContext). Responde 401 si falta el header, formato incorrecto o token invalido."
|
||||
tags: [jwt, auth, middleware, http, server, infra]
|
||||
uses_functions: [jwt_validate_go_infra, http_error_response_go_infra]
|
||||
uses_types: [JWTClaims_go_infra, Middleware_go_infra, HTTPError_go_infra]
|
||||
returns: [Middleware_go_infra]
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [context, net/http, strings]
|
||||
params:
|
||||
- name: secret
|
||||
desc: "clave HMAC para JWTValidate. Debe ser la misma usada en JWTGenerate"
|
||||
output: "Middleware que protege handlers con validacion JWT. Las claims se inyectan en r.Context() con una key privada"
|
||||
tested: true
|
||||
tests: ["pasa con token valido", "401 sin header Authorization", "401 con formato distinto de Bearer", "401 con token invalido", "claims accesibles via JWTClaimsFromContext"]
|
||||
test_file_path: "functions/infra/jwt_middleware_test.go"
|
||||
file_path: "functions/infra/jwt_middleware.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
protected := HTTPMiddlewareChain(
|
||||
HTTPLoggerMiddleware(os.Stderr),
|
||||
JWTMiddleware(os.Getenv("JWT_SECRET")),
|
||||
)
|
||||
mux.Handle("GET /api/me", protected(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, _ := JWTClaimsFromContext(r.Context())
|
||||
HTTPJSONResponse(w, 200, map[string]string{"user_id": claims.Subject})
|
||||
})))
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — lee headers y modifica el request. Expone el helper JWTClaimsFromContext(ctx) que devuelve (JWTClaims, bool) — el bool permite distinguir "no autenticado" de "subject vacio". Usa `context.WithValue` con una key de tipo privado `jwtCtxKey struct{}` para evitar colisiones con otros middlewares. Solo soporta cabecera `Authorization: Bearer`; para leer token desde cookie se crearia un middleware separado. En las respuestas 401 no se da detalle del motivo (token expirado vs firma invalida) para no filtrar informacion, el motivo real esta en los logs si se compone con HTTPLoggerMiddleware.
|
||||
@@ -0,0 +1,94 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeTokenFor(t *testing.T, subject, secret string) string {
|
||||
t.Helper()
|
||||
tok, err := JWTGenerate(JWTClaims{
|
||||
Subject: subject,
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
}, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("JWTGenerate: %v", err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func TestJWTMiddleware_ValidToken(t *testing.T) {
|
||||
secret := "test-sec"
|
||||
token := makeTokenFor(t, "alice", secret)
|
||||
|
||||
var gotSubject string
|
||||
handler := JWTMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := JWTClaimsFromContext(r.Context())
|
||||
if !ok {
|
||||
t.Error("JWTClaimsFromContext no encontro claims")
|
||||
}
|
||||
gotSubject = claims.Subject
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
if gotSubject != "alice" {
|
||||
t.Errorf("subject = %q", gotSubject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTMiddleware_MissingAuthHeader(t *testing.T) {
|
||||
handler := JWTMiddleware("s")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("no deberia ejecutarse")
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTMiddleware_WrongFormat(t *testing.T) {
|
||||
handler := JWTMiddleware("s")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("no deberia ejecutarse")
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Basic abcdef")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTMiddleware_InvalidToken(t *testing.T) {
|
||||
handler := JWTMiddleware("secret-a")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("no deberia ejecutarse")
|
||||
}))
|
||||
tok := makeTokenFor(t, "x", "secret-b") // firmado con otro secret
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTClaimsFromContext_NotPresent(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
_, ok := JWTClaimsFromContext(req.Context())
|
||||
if ok {
|
||||
t.Fatal("no deberia haber claims en un context nuevo")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package infra
|
||||
|
||||
// RBACCheck verifica si un rol tiene un permiso concreto.
|
||||
// Busca el rol por Name en la slice de roles; si existe itera sus permisos
|
||||
// y retorna true si alguno hace match exacto de Resource + Action.
|
||||
// Funcion pura — evaluacion de datos en memoria, sin I/O.
|
||||
func RBACCheck(roles []Role, roleName string, perm Permission) bool {
|
||||
for _, r := range roles {
|
||||
if r.Name != roleName {
|
||||
continue
|
||||
}
|
||||
for _, p := range r.Permissions {
|
||||
if p.Resource == perm.Resource && p.Action == perm.Action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: rbac_check
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func RBACCheck(roles []Role, roleName string, perm Permission) bool"
|
||||
description: "Verifica si un rol tiene un permiso concreto. Busca el rol por Name e itera sus permisos buscando match exacto de Resource + Action. Funcion pura."
|
||||
tags: [rbac, role, permission, check, auth, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Role_go_infra, Permission_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: roles
|
||||
desc: "slice de roles disponibles en el sistema, cada uno con su lista de permisos"
|
||||
- name: roleName
|
||||
desc: "nombre del rol a verificar (ej: admin, viewer). Comparacion exacta"
|
||||
- name: perm
|
||||
desc: "permiso requerido: par Resource + Action (ej: users/delete)"
|
||||
output: "true si el rol existe y tiene el permiso; false si el rol no existe o no tiene el permiso"
|
||||
tested: true
|
||||
tests: ["true cuando el rol tiene el permiso", "false cuando no tiene el permiso", "false cuando el rol no existe", "match exacto de Resource y Action"]
|
||||
test_file_path: "functions/infra/rbac_check_test.go"
|
||||
file_path: "functions/infra/rbac_check.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
roles := []Role{
|
||||
{Name: "admin", Permissions: []Permission{{Resource: "users", Action: "delete"}}},
|
||||
{Name: "viewer", Permissions: []Permission{{Resource: "users", Action: "read"}}},
|
||||
}
|
||||
can := RBACCheck(roles, "admin", Permission{Resource: "users", Action: "delete"})
|
||||
// can == true
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Pura — solo recorre slices, sin I/O. No hay herencia ni wildcards: un rol "admin" sin el permiso explicito `users/delete` no lo tiene. Esto es deliberado para que la matriz rol→permisos sea auditable con un grep. Para wildcards o herencia, construir la lista de roles expandida antes de pasarla (ej: funcion helper que toma {admin: [viewer, ...]} y expande). Si hay dos Roles con el mismo Name solo se evalua el primero — considerar invariante de unicidad al construir la lista.
|
||||
@@ -0,0 +1,58 @@
|
||||
package infra
|
||||
|
||||
import "testing"
|
||||
|
||||
func makeRoles() []Role {
|
||||
return []Role{
|
||||
{
|
||||
Name: "admin",
|
||||
Permissions: []Permission{
|
||||
{Resource: "users", Action: "read"},
|
||||
{Resource: "users", Action: "write"},
|
||||
{Resource: "users", Action: "delete"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "viewer",
|
||||
Permissions: []Permission{
|
||||
{Resource: "users", Action: "read"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACCheck_Granted(t *testing.T) {
|
||||
roles := makeRoles()
|
||||
if !RBACCheck(roles, "admin", Permission{Resource: "users", Action: "delete"}) {
|
||||
t.Fatal("admin deberia tener users/delete")
|
||||
}
|
||||
if !RBACCheck(roles, "viewer", Permission{Resource: "users", Action: "read"}) {
|
||||
t.Fatal("viewer deberia tener users/read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACCheck_Denied(t *testing.T) {
|
||||
roles := makeRoles()
|
||||
if RBACCheck(roles, "viewer", Permission{Resource: "users", Action: "delete"}) {
|
||||
t.Fatal("viewer NO deberia tener users/delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACCheck_UnknownRole(t *testing.T) {
|
||||
roles := makeRoles()
|
||||
if RBACCheck(roles, "ghost", Permission{Resource: "users", Action: "read"}) {
|
||||
t.Fatal("rol inexistente no deberia tener permisos")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACCheck_ExactMatch(t *testing.T) {
|
||||
roles := makeRoles()
|
||||
// Resource distinto
|
||||
if RBACCheck(roles, "admin", Permission{Resource: "billing", Action: "read"}) {
|
||||
t.Fatal("admin no tiene billing/read")
|
||||
}
|
||||
// Action distinta
|
||||
if RBACCheck(roles, "viewer", Permission{Resource: "users", Action: "write"}) {
|
||||
t.Fatal("viewer no tiene users/write")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package infra
|
||||
|
||||
import "net/http"
|
||||
|
||||
// RBACMiddleware retorna un Middleware que verifica que el usuario autenticado
|
||||
// tenga un permiso concreto. Requiere que JWTMiddleware se ejecute antes en la
|
||||
// chain (lee las claims del context). El rol se extrae de claims.Custom["role"].
|
||||
// Responde 403 si el rol no tiene el permiso; 401 si no hay claims en el context.
|
||||
func RBACMiddleware(roles []Role, required Permission) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := JWTClaimsFromContext(r.Context())
|
||||
if !ok {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusUnauthorized, Code: "no_claims",
|
||||
Message: "rbac_middleware requiere jwt_middleware antes en la chain",
|
||||
})
|
||||
return
|
||||
}
|
||||
roleName, _ := claims.Custom["role"].(string)
|
||||
if roleName == "" || !RBACCheck(roles, roleName, required) {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusForbidden, Code: "forbidden",
|
||||
Message: "no tienes permiso para esta accion",
|
||||
})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: rbac_middleware
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func RBACMiddleware(roles []Role, required Permission) Middleware"
|
||||
description: "Middleware HTTP que verifica que el usuario autenticado tenga un permiso concreto. Lee las claims del context (puestas por JWTMiddleware), extrae el rol de claims.Custom[role] y evalua con RBACCheck. Responde 403 si no tiene permiso, 401 si no hay claims."
|
||||
tags: [rbac, auth, middleware, http, server, infra]
|
||||
uses_functions: [rbac_check_go_infra, http_error_response_go_infra]
|
||||
uses_types: [Role_go_infra, Permission_go_infra, Middleware_go_infra, HTTPError_go_infra]
|
||||
returns: [Middleware_go_infra]
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [net/http]
|
||||
params:
|
||||
- name: roles
|
||||
desc: "matriz de roles y sus permisos, usualmente hardcoded o leida de config al arrancar"
|
||||
- name: required
|
||||
desc: "permiso requerido para acceder al handler (Resource + Action)"
|
||||
output: "Middleware que bloquea el request con 403 si el rol del usuario no tiene el permiso requerido"
|
||||
tested: true
|
||||
tests: ["pasa con rol que tiene el permiso", "403 con rol sin permiso", "403 con rol inexistente", "401 si no hay claims en el context"]
|
||||
test_file_path: "functions/infra/rbac_middleware_test.go"
|
||||
file_path: "functions/infra/rbac_middleware.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
roles := []Role{
|
||||
{Name: "admin", Permissions: []Permission{{Resource: "users", Action: "delete"}}},
|
||||
}
|
||||
adminProtected := HTTPMiddlewareChain(
|
||||
JWTMiddleware(secret),
|
||||
RBACMiddleware(roles, Permission{Resource: "users", Action: "delete"}),
|
||||
)
|
||||
mux.Handle("DELETE /api/users/{id}", adminProtected(deleteUserHandler))
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — depende del estado del request y de JWTMiddleware. El rol se lee de `claims.Custom["role"]` como string: si la app usa otro campo (ej: `claims.Custom["roles"]` como slice) conviene crear un middleware variante. Si el usuario tiene multiples roles, extenderse iterando `RBACCheck` sobre cada uno. Orden en la chain: SIEMPRE JWTMiddleware antes — si se olvida, el middleware retorna 401 con code `no_claims` para facilitar debugging. No cachea la evaluacion: RBACCheck es O(roles*permisos) pero para listas pequeñas (<100) es despreciable.
|
||||
@@ -0,0 +1,98 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func buildRBACRoles() []Role {
|
||||
return []Role{
|
||||
{Name: "admin", Permissions: []Permission{{Resource: "users", Action: "delete"}}},
|
||||
{Name: "viewer", Permissions: []Permission{{Resource: "users", Action: "read"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func buildAuthedRequest(t *testing.T, role, secret string) *http.Request {
|
||||
t.Helper()
|
||||
claims := JWTClaims{
|
||||
Subject: "user-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
Custom: map[string]any{"role": role},
|
||||
}
|
||||
tok, err := JWTGenerate(claims, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("JWTGenerate: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
return req
|
||||
}
|
||||
|
||||
func TestRBACMiddleware_PermissionGranted(t *testing.T) {
|
||||
secret := "s"
|
||||
chain := HTTPMiddlewareChain(
|
||||
JWTMiddleware(secret),
|
||||
RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "delete"}),
|
||||
)
|
||||
var called bool
|
||||
handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, buildAuthedRequest(t, "admin", secret))
|
||||
if rec.Code != 200 || !called {
|
||||
t.Errorf("admin deberia pasar: status=%d called=%v", rec.Code, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACMiddleware_PermissionDenied(t *testing.T) {
|
||||
secret := "s"
|
||||
chain := HTTPMiddlewareChain(
|
||||
JWTMiddleware(secret),
|
||||
RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "delete"}),
|
||||
)
|
||||
handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("viewer no deberia pasar")
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, buildAuthedRequest(t, "viewer", secret))
|
||||
if rec.Code != 403 {
|
||||
t.Errorf("status = %d, esperaba 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACMiddleware_UnknownRole(t *testing.T) {
|
||||
secret := "s"
|
||||
chain := HTTPMiddlewareChain(
|
||||
JWTMiddleware(secret),
|
||||
RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "read"}),
|
||||
)
|
||||
handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("rol ghost no deberia pasar")
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, buildAuthedRequest(t, "ghost", secret))
|
||||
if rec.Code != 403 {
|
||||
t.Errorf("status = %d, esperaba 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACMiddleware_NoClaims(t *testing.T) {
|
||||
// Sin JWTMiddleware delante → no hay claims en context
|
||||
mw := RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "read"})
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("no deberia ejecutarse")
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("status = %d, esperaba 401", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user