fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled
Some checks failed
Deploy Application / deploy (push) Has been cancelled
This commit is contained in:
756
docs/livecomponents/security-guide-complete.md
Normal file
756
docs/livecomponents/security-guide-complete.md
Normal file
@@ -0,0 +1,756 @@
|
||||
# LiveComponents Security Guide
|
||||
|
||||
**Complete security guide for LiveComponents covering CSRF protection, rate limiting, idempotency, authorization, and best practices.**
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [CSRF Protection](#csrf-protection)
|
||||
2. [Rate Limiting](#rate-limiting)
|
||||
3. [Idempotency](#idempotency)
|
||||
4. [Action Allow-List](#action-allow-list)
|
||||
5. [Authorization](#authorization)
|
||||
6. [Input Validation](#input-validation)
|
||||
7. [XSS Prevention](#xss-prevention)
|
||||
8. [Secure State Management](#secure-state-management)
|
||||
9. [Security Checklist](#security-checklist)
|
||||
|
||||
---
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
### How It Works
|
||||
|
||||
LiveComponents automatically protect all actions with CSRF tokens:
|
||||
|
||||
1. **Token Generation**: Unique token generated per component instance
|
||||
2. **Token Validation**: Token validated on every action request
|
||||
3. **Token Regeneration**: Token regenerated after each action
|
||||
|
||||
### Implementation
|
||||
|
||||
**Server-Side** (Automatic):
|
||||
|
||||
```php
|
||||
// CSRF token is automatically generated and validated
|
||||
#[Action]
|
||||
public function increment(): CounterState
|
||||
{
|
||||
// CSRF validation happens automatically before this method is called
|
||||
return $this->state->increment();
|
||||
}
|
||||
```
|
||||
|
||||
**Client-Side** (Automatic):
|
||||
|
||||
```html
|
||||
<!-- CSRF token is automatically included in action requests -->
|
||||
<button data-live-action="increment">Increment</button>
|
||||
```
|
||||
|
||||
### Manual CSRF Token Access
|
||||
|
||||
If you need to access CSRF tokens manually:
|
||||
|
||||
```php
|
||||
use App\Framework\LiveComponents\ComponentRegistry;
|
||||
|
||||
$registry = container()->get(ComponentRegistry::class);
|
||||
$csrfToken = $registry->generateCsrfToken($componentId);
|
||||
```
|
||||
|
||||
### CSRF Token Format
|
||||
|
||||
- **Length**: 32 hexadecimal characters
|
||||
- **Format**: `[a-f0-9]{32}`
|
||||
- **Scope**: Per component instance (not global)
|
||||
|
||||
### Best Practices
|
||||
|
||||
✅ **DO**:
|
||||
- Let the framework handle CSRF automatically
|
||||
- Include CSRF meta tag in base layout: `<meta name="csrf-token" content="{csrf_token}">`
|
||||
- Use HTTPS in production
|
||||
|
||||
❌ **DON'T**:
|
||||
- Disable CSRF protection
|
||||
- Share CSRF tokens between components
|
||||
- Store CSRF tokens in localStorage (use session)
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Overview
|
||||
|
||||
Rate limiting prevents abuse by limiting the number of actions per time period.
|
||||
|
||||
### Configuration
|
||||
|
||||
**Global Rate Limit** (`.env`):
|
||||
```env
|
||||
LIVECOMPONENT_RATE_LIMIT=60 # 60 requests per minute per component
|
||||
```
|
||||
|
||||
**Per-Action Rate Limit**:
|
||||
```php
|
||||
#[Action(rateLimit: 10)] // 10 requests per minute for this action
|
||||
public function expensiveOperation(): State
|
||||
{
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Rate Limit Headers
|
||||
|
||||
When rate limit is exceeded, response includes:
|
||||
|
||||
```
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
X-RateLimit-Limit: 60
|
||||
X-RateLimit-Remaining: 0
|
||||
X-RateLimit-Reset: 1640995200
|
||||
Retry-After: 30
|
||||
```
|
||||
|
||||
### Client-Side Handling
|
||||
|
||||
```javascript
|
||||
// Automatic retry after Retry-After header
|
||||
LiveComponent.executeAction('counter:demo', 'increment')
|
||||
.catch(error => {
|
||||
if (error.status === 429) {
|
||||
const retryAfter = error.headers['retry-after'];
|
||||
console.log(`Rate limited. Retry after ${retryAfter} seconds`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
✅ **DO**:
|
||||
- Set appropriate rate limits based on action cost
|
||||
- Use higher limits for read operations, lower for write operations
|
||||
- Monitor rate limit violations
|
||||
|
||||
❌ **DON'T**:
|
||||
- Set rate limits too low (hurts UX)
|
||||
- Set rate limits too high (allows abuse)
|
||||
- Ignore rate limit violations
|
||||
|
||||
---
|
||||
|
||||
## Idempotency
|
||||
|
||||
### Overview
|
||||
|
||||
Idempotency ensures that repeating the same action multiple times has the same effect as performing it once.
|
||||
|
||||
### Configuration
|
||||
|
||||
**Per-Action Idempotency**:
|
||||
```php
|
||||
#[Action(idempotencyTTL: 60)] // Idempotent for 60 seconds
|
||||
public function processPayment(float $amount): State
|
||||
{
|
||||
// This action will return cached result if called again within 60 seconds
|
||||
return $this->state->processPayment($amount);
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **First Request**: Action executes, result cached with idempotency key
|
||||
2. **Subsequent Requests**: Same idempotency key returns cached result
|
||||
3. **After TTL**: Cache expires, action can execute again
|
||||
|
||||
### Idempotency Key
|
||||
|
||||
**Client-Side**:
|
||||
```javascript
|
||||
// Generate unique idempotency key
|
||||
const idempotencyKey = `payment-${Date.now()}-${Math.random()}`;
|
||||
|
||||
LiveComponent.executeAction('payment:demo', 'processPayment', {
|
||||
amount: 100.00,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
```
|
||||
|
||||
**Server-Side** (Automatic):
|
||||
- Idempotency key extracted from request
|
||||
- Key includes: component ID + action name + parameters
|
||||
- Cached result returned if key matches
|
||||
|
||||
### Use Cases
|
||||
|
||||
✅ **Good for**:
|
||||
- Payment processing
|
||||
- Order creation
|
||||
- Email sending
|
||||
- External API calls
|
||||
|
||||
❌ **Not suitable for**:
|
||||
- Incrementing counters
|
||||
- Appending to lists
|
||||
- Time-sensitive operations
|
||||
|
||||
### Best Practices
|
||||
|
||||
✅ **DO**:
|
||||
- Use idempotency for critical operations
|
||||
- Set appropriate TTL (long enough to prevent duplicates, short enough to allow retries)
|
||||
- Include idempotency key in client requests
|
||||
|
||||
❌ **DON'T**:
|
||||
- Use idempotency for operations that should execute multiple times
|
||||
- Set TTL too long (prevents legitimate retries)
|
||||
- Rely solely on idempotency for security
|
||||
|
||||
---
|
||||
|
||||
## Action Allow-List
|
||||
|
||||
### Overview
|
||||
|
||||
Only methods marked with `#[Action]` can be called from the client.
|
||||
|
||||
### Implementation
|
||||
|
||||
```php
|
||||
#[LiveComponent('user-profile')]
|
||||
final readonly class UserProfileComponent implements LiveComponentContract
|
||||
{
|
||||
// ✅ Can be called from client
|
||||
#[Action]
|
||||
public function updateProfile(array $data): State
|
||||
{
|
||||
return $this->state->updateProfile($data);
|
||||
}
|
||||
|
||||
// ❌ Cannot be called from client (no #[Action] attribute)
|
||||
public function internalMethod(): void
|
||||
{
|
||||
// This method is not exposed to the client
|
||||
}
|
||||
|
||||
// ❌ Reserved methods cannot be actions
|
||||
public function mount(): State
|
||||
{
|
||||
// Reserved method - cannot be called as action
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reserved Methods
|
||||
|
||||
These methods cannot be actions:
|
||||
- `mount()`
|
||||
- `getRenderData()`
|
||||
- `getId()`
|
||||
- `getState()`
|
||||
- `getData()`
|
||||
- Methods starting with `_` (private convention)
|
||||
|
||||
### Security Benefits
|
||||
|
||||
- **Explicit API**: Only intended methods are callable
|
||||
- **No Accidental Exposure**: Internal methods stay internal
|
||||
- **Clear Intent**: `#[Action]` makes API surface explicit
|
||||
|
||||
### Best Practices
|
||||
|
||||
✅ **DO**:
|
||||
- Mark all public methods that should be callable with `#[Action]`
|
||||
- Keep internal methods without `#[Action]`
|
||||
- Use descriptive action names
|
||||
|
||||
❌ **DON'T**:
|
||||
- Mark internal methods with `#[Action]`
|
||||
- Expose sensitive operations without proper authorization
|
||||
- Use generic action names like `do()` or `execute()`
|
||||
|
||||
---
|
||||
|
||||
## Authorization
|
||||
|
||||
### Overview
|
||||
|
||||
Use `#[RequiresPermission]` to restrict actions to authorized users.
|
||||
|
||||
### Implementation
|
||||
|
||||
```php
|
||||
#[LiveComponent('post-editor')]
|
||||
final readonly class PostEditorComponent implements LiveComponentContract
|
||||
{
|
||||
#[Action]
|
||||
#[RequiresPermission('posts.edit')]
|
||||
public function updatePost(array $data): State
|
||||
{
|
||||
return $this->state->updatePost($data);
|
||||
}
|
||||
|
||||
#[Action]
|
||||
#[RequiresPermission('posts.delete')]
|
||||
public function deletePost(): State
|
||||
{
|
||||
return $this->state->deletePost();
|
||||
}
|
||||
|
||||
// Multiple permissions (user needs ALL)
|
||||
#[Action]
|
||||
#[RequiresPermission('posts.edit', 'posts.publish')]
|
||||
public function publishPost(): State
|
||||
{
|
||||
return $this->state->publishPost();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Checking
|
||||
|
||||
**Custom Authorization Checker**:
|
||||
|
||||
```php
|
||||
use App\Framework\LiveComponents\Security\AuthorizationCheckerInterface;
|
||||
|
||||
final readonly class CustomAuthorizationChecker implements AuthorizationCheckerInterface
|
||||
{
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
// Check if current user has permission
|
||||
return $this->user->hasPermission($permission);
|
||||
}
|
||||
|
||||
public function hasAllPermissions(array $permissions): bool
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
if (!$this->hasPermission($permission)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Unauthorized Access
|
||||
|
||||
When user lacks required permission:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "User does not have required permission: posts.edit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
✅ **DO**:
|
||||
- Use authorization for sensitive operations
|
||||
- Check permissions server-side (never trust client)
|
||||
- Use specific permissions (not generic like 'admin')
|
||||
- Log authorization failures
|
||||
|
||||
❌ **DON'T**:
|
||||
- Rely on client-side authorization checks
|
||||
- Use overly broad permissions
|
||||
- Skip authorization for write operations
|
||||
- Expose permission names in error messages
|
||||
|
||||
---
|
||||
|
||||
## Input Validation
|
||||
|
||||
### Overview
|
||||
|
||||
Always validate input in actions before processing.
|
||||
|
||||
### Implementation
|
||||
|
||||
```php
|
||||
#[Action]
|
||||
public function updateProfile(array $data): State
|
||||
{
|
||||
// Validate input
|
||||
$errors = [];
|
||||
|
||||
if (empty($data['name'])) {
|
||||
$errors[] = 'Name is required';
|
||||
}
|
||||
|
||||
if (isset($data['email']) && !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Invalid email address';
|
||||
}
|
||||
|
||||
if (isset($data['age']) && ($data['age'] < 0 || $data['age'] > 150)) {
|
||||
$errors[] = 'Age must be between 0 and 150';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
throw new ValidationException('Validation failed', $errors);
|
||||
}
|
||||
|
||||
// Process valid data
|
||||
return $this->state->updateProfile($data);
|
||||
}
|
||||
```
|
||||
|
||||
### Type Safety
|
||||
|
||||
Use type hints for automatic validation:
|
||||
|
||||
```php
|
||||
#[Action]
|
||||
public function addAmount(int $amount): State
|
||||
{
|
||||
// PHP automatically validates $amount is an integer
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('Amount must be positive');
|
||||
}
|
||||
|
||||
return $this->state->addAmount($amount);
|
||||
}
|
||||
```
|
||||
|
||||
### Value Objects
|
||||
|
||||
Use Value Objects for complex validation:
|
||||
|
||||
```php
|
||||
#[Action]
|
||||
public function updateEmail(Email $email): State
|
||||
{
|
||||
// Email Value Object validates format automatically
|
||||
return $this->state->updateEmail($email);
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
✅ **DO**:
|
||||
- Validate all input in actions
|
||||
- Use type hints for automatic validation
|
||||
- Use Value Objects for complex data
|
||||
- Return clear error messages
|
||||
|
||||
❌ **DON'T**:
|
||||
- Trust client input
|
||||
- Skip validation for "internal" actions
|
||||
- Expose internal validation details
|
||||
- Use generic error messages
|
||||
|
||||
---
|
||||
|
||||
## XSS Prevention
|
||||
|
||||
### Overview
|
||||
|
||||
LiveComponents automatically escape output in templates.
|
||||
|
||||
### Template Escaping
|
||||
|
||||
**Automatic Escaping**:
|
||||
```html
|
||||
<!-- Automatically escaped -->
|
||||
<div>{user_input}</div>
|
||||
|
||||
<!-- Safe HTML (if needed) -->
|
||||
<div>{html_content|raw}</div>
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
✅ **DO**:
|
||||
- Let the framework escape output automatically
|
||||
- Use `|raw` filter only when necessary
|
||||
- Validate HTML content before storing
|
||||
- Use Content Security Policy (CSP)
|
||||
|
||||
❌ **DON'T**:
|
||||
- Disable automatic escaping
|
||||
- Use `|raw` with user input
|
||||
- Store unvalidated HTML
|
||||
- Trust client-provided HTML
|
||||
|
||||
---
|
||||
|
||||
## Secure State Management
|
||||
|
||||
### Overview
|
||||
|
||||
Component state should never contain sensitive data.
|
||||
|
||||
### Sensitive Data
|
||||
|
||||
❌ **DON'T Store**:
|
||||
- Passwords
|
||||
- API keys
|
||||
- Credit card numbers
|
||||
- Session tokens
|
||||
- Private keys
|
||||
|
||||
✅ **DO Store**:
|
||||
- User IDs (not passwords)
|
||||
- Display preferences
|
||||
- UI state
|
||||
- Non-sensitive configuration
|
||||
|
||||
### State Encryption
|
||||
|
||||
For sensitive state (if absolutely necessary):
|
||||
|
||||
```php
|
||||
use App\Framework\Encryption\EncryptionService;
|
||||
|
||||
final readonly class SecureComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
private EncryptionService $encryption
|
||||
) {
|
||||
}
|
||||
|
||||
public function storeSensitiveData(string $data): State
|
||||
{
|
||||
$encrypted = $this->encryption->encrypt($data);
|
||||
return $this->state->withEncryptedData($encrypted);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
✅ **DO**:
|
||||
- Keep state minimal
|
||||
- Store only necessary data
|
||||
- Use server-side storage for sensitive data
|
||||
- Encrypt sensitive state if needed
|
||||
|
||||
❌ **DON'T**:
|
||||
- Store passwords or secrets in state
|
||||
- Include sensitive data in state
|
||||
- Trust client-provided state
|
||||
- Expose internal implementation details
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
### Component Development
|
||||
|
||||
- [ ] All actions marked with `#[Action]`
|
||||
- [ ] No sensitive data in component state
|
||||
- [ ] Input validation in all actions
|
||||
- [ ] Authorization checks for sensitive operations
|
||||
- [ ] Rate limiting configured appropriately
|
||||
- [ ] Idempotency for critical operations
|
||||
- [ ] CSRF protection enabled (automatic)
|
||||
- [ ] Error messages don't expose sensitive information
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] CSRF token validation tested
|
||||
- [ ] Rate limiting tested
|
||||
- [ ] Authorization tested
|
||||
- [ ] Input validation tested
|
||||
- [ ] XSS prevention tested
|
||||
- [ ] Error handling tested
|
||||
|
||||
### Deployment
|
||||
|
||||
- [ ] HTTPS enabled
|
||||
- [ ] CSRF protection enabled
|
||||
- [ ] Rate limits configured
|
||||
- [ ] Error reporting configured
|
||||
- [ ] Security headers configured
|
||||
- [ ] Content Security Policy configured
|
||||
|
||||
---
|
||||
|
||||
## Common Security Pitfalls
|
||||
|
||||
### 1. Trusting Client State
|
||||
|
||||
❌ **WRONG**:
|
||||
```php
|
||||
#[Action]
|
||||
public function updateBalance(float $newBalance): State
|
||||
{
|
||||
// Don't trust client-provided balance!
|
||||
return $this->state->withBalance($newBalance);
|
||||
}
|
||||
```
|
||||
|
||||
✅ **CORRECT**:
|
||||
```php
|
||||
#[Action]
|
||||
public function addToBalance(float $amount): State
|
||||
{
|
||||
// Calculate new balance server-side
|
||||
$newBalance = $this->state->balance + $amount;
|
||||
return $this->state->withBalance($newBalance);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Skipping Authorization
|
||||
|
||||
❌ **WRONG**:
|
||||
```php
|
||||
#[Action]
|
||||
public function deleteUser(int $userId): State
|
||||
{
|
||||
// No authorization check!
|
||||
$this->userService->delete($userId);
|
||||
return $this->state;
|
||||
}
|
||||
```
|
||||
|
||||
✅ **CORRECT**:
|
||||
```php
|
||||
#[Action]
|
||||
#[RequiresPermission('users.delete')]
|
||||
public function deleteUser(int $userId): State
|
||||
{
|
||||
$this->userService->delete($userId);
|
||||
return $this->state;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Exposing Sensitive Data
|
||||
|
||||
❌ **WRONG**:
|
||||
```php
|
||||
public function getRenderData(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(
|
||||
templatePath: 'user-profile',
|
||||
data: [
|
||||
'password' => $this->user->password, // ❌ Never expose passwords!
|
||||
'api_key' => $this->user->apiKey, // ❌ Never expose API keys!
|
||||
]
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
✅ **CORRECT**:
|
||||
```php
|
||||
public function getRenderData(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(
|
||||
templatePath: 'user-profile',
|
||||
data: [
|
||||
'username' => $this->user->username,
|
||||
'email' => $this->user->email,
|
||||
// Only include safe, display data
|
||||
]
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Patterns
|
||||
|
||||
### Pattern 1: Secure File Upload
|
||||
|
||||
```php
|
||||
#[LiveComponent('file-uploader')]
|
||||
final readonly class FileUploaderComponent implements LiveComponentContract, SupportsFileUpload
|
||||
{
|
||||
public function validateUpload(UploadedFile $file): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Check file type
|
||||
$allowedTypes = ['image/jpeg', 'image/png'];
|
||||
if (!in_array($file->getMimeType(), $allowedTypes)) {
|
||||
$errors[] = 'Invalid file type';
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if ($file->getSize() > 5 * 1024 * 1024) {
|
||||
$errors[] = 'File too large';
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
$extension = pathinfo($file->getName(), PATHINFO_EXTENSION);
|
||||
$allowedExtensions = ['jpg', 'jpeg', 'png'];
|
||||
if (!in_array(strtolower($extension), $allowedExtensions)) {
|
||||
$errors[] = 'Invalid file extension';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
#[Action]
|
||||
#[RequiresPermission('files.upload')]
|
||||
public function handleUpload(UploadedFile $file): State
|
||||
{
|
||||
// Additional server-side validation
|
||||
$errors = $this->validateUpload($file);
|
||||
if (!empty($errors)) {
|
||||
throw new ValidationException('Upload validation failed', $errors);
|
||||
}
|
||||
|
||||
// Process upload securely
|
||||
$path = $this->storage->store($file);
|
||||
return $this->state->withUploadedFile($path);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Secure Payment Processing
|
||||
|
||||
```php
|
||||
#[LiveComponent('payment')]
|
||||
final readonly class PaymentComponent implements LiveComponentContract
|
||||
{
|
||||
#[Action]
|
||||
#[RequiresPermission('payments.process')]
|
||||
#[Action(rateLimit: 5, idempotencyTTL: 300)] // 5 per minute, 5 min idempotency
|
||||
public function processPayment(
|
||||
float $amount,
|
||||
string $paymentMethod,
|
||||
string $idempotencyKey
|
||||
): State {
|
||||
// Validate amount
|
||||
if ($amount <= 0 || $amount > 10000) {
|
||||
throw new ValidationException('Invalid amount');
|
||||
}
|
||||
|
||||
// Validate payment method
|
||||
$allowedMethods = ['credit_card', 'paypal'];
|
||||
if (!in_array($paymentMethod, $allowedMethods)) {
|
||||
throw new ValidationException('Invalid payment method');
|
||||
}
|
||||
|
||||
// Process payment (idempotent)
|
||||
$transaction = $this->paymentService->process(
|
||||
$amount,
|
||||
$paymentMethod,
|
||||
$idempotencyKey
|
||||
);
|
||||
|
||||
return $this->state->withTransaction($transaction);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Performance Guide](performance-guide-complete.md) - Optimization techniques
|
||||
- [End-to-End Guide](end-to-end-guide.md) - Complete development guide
|
||||
- [API Reference](api-reference-complete.md) - Complete API documentation
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user