Merge branch 'main' of http://serezade.ujaen.es:8030/fjmimbre/deck-of-cards into style/footer
This commit is contained in:
+2
-3
@@ -4,11 +4,10 @@ __pycache__/
|
|||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# Variables de entorno (solo ignoramos las locales/sobre-escrituras)
|
# Variables de entorno (solo ignoramos las locales/sobre-escrituras)
|
||||||
# Mantenemos .env y .env.example versionados para compartir la configuración
|
# Mantenemos .env y .env.example versionados en este repo privado.
|
||||||
# de producción y el esqueleto de variables. Nunca subimos .env.local.
|
# Nunca subimos .env.local.
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.env
|
|
||||||
|
|
||||||
# Override de Docker Compose para desarrollo local (no debe llegar a producción)
|
# Override de Docker Compose para desarrollo local (no debe llegar a producción)
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
+11
-5
@@ -16,10 +16,10 @@
|
|||||||
GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com
|
||||||
GOOGLE_CLIENT_SECRET=tu-client-secret
|
GOOGLE_CLIENT_SECRET=tu-client-secret
|
||||||
# URI a la que Google redirige tras el login (debe estar registrada en Google Cloud)
|
# URI a la que Google redirige tras el login (debe estar registrada en Google Cloud)
|
||||||
# Con Docker: suele ser el frontend (8071) porque /api hace proxy al backend
|
# Con Docker local: el frontend (8071) hace proxy de /api al backend
|
||||||
GOOGLE_REDIRECT_URI=http://localhost:8071/api/auth/google/callback
|
GOOGLE_REDIRECT_URI=http://localhost:8071/api/auth/google/callback
|
||||||
# Producción (añade la misma URI en Google Console → Credenciales OAuth):
|
# Producción Sinbad2 (HTTPS vía Apache, prefijo /deckofcards):
|
||||||
# GOOGLE_REDIRECT_URI=http://tu-servidor:8071/api/auth/google/callback
|
# GOOGLE_REDIRECT_URI=https://sinbad2.ujaen.es/deckofcards/api/auth/google/callback
|
||||||
|
|
||||||
# Clave para firmar los JWT (usa algo largo y aleatorio en producción)
|
# Clave para firmar los JWT (usa algo largo y aleatorio en producción)
|
||||||
SECRET_KEY=cambia-esta-clave-en-produccion
|
SECRET_KEY=cambia-esta-clave-en-produccion
|
||||||
@@ -27,8 +27,14 @@ SECRET_KEY=cambia-esta-clave-en-produccion
|
|||||||
# URL del frontend a la que se redirige tras el login con Google
|
# URL del frontend a la que se redirige tras el login con Google
|
||||||
# Con docker-compose en local: http://localhost:8071
|
# Con docker-compose en local: http://localhost:8071
|
||||||
# Con Vite directo en local: http://localhost:5173
|
# Con Vite directo en local: http://localhost:5173
|
||||||
# En producción: https://tu-dominio.com
|
# Producción Sinbad2: https://sinbad2.ujaen.es/deckofcards
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:8071
|
||||||
|
|
||||||
|
# Entorno y seguridad HTTPS (producción en Sinbad2)
|
||||||
|
# ENVIRONMENT=production
|
||||||
|
# CORS_ALLOWED_ORIGINS=https://sinbad2.ujaen.es
|
||||||
|
# TRUSTED_HOSTS=sinbad2.ujaen.es,backend,localhost
|
||||||
|
# SECURITY_HSTS_SECONDS=31536000
|
||||||
|
|
||||||
# Verificación de email por código numérico
|
# Verificación de email por código numérico
|
||||||
# El destinatario puede ser cualquier proveedor (Gmail, Hotmail, Outlook, etc.).
|
# El destinatario puede ser cualquier proveedor (Gmail, Hotmail, Outlook, etc.).
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.10-slim
|
FROM python:3.10-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -8,4 +8,15 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
FROM base AS development
|
||||||
|
|
||||||
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|
||||||
|
FROM base AS production
|
||||||
|
|
||||||
|
CMD ["uvicorn", "api.main:app", \
|
||||||
|
"--host", "0.0.0.0", \
|
||||||
|
"--port", "8000", \
|
||||||
|
"--proxy-headers", \
|
||||||
|
"--forwarded-allow-ips", "*", \
|
||||||
|
"--no-server-header"]
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _split_csv(value: str | None) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
||||||
|
SECURITY_HSTS_SECONDS: int = int(os.getenv("SECURITY_HSTS_SECONDS", "31536000"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.ENVIRONMENT.strip().lower() == "production"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_allowed_origins(self) -> list[str]:
|
||||||
|
configured = _split_csv(os.getenv("CORS_ALLOWED_ORIGINS"))
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
if self.is_production:
|
||||||
|
frontend = os.getenv("FRONTEND_URL", "").rstrip("/")
|
||||||
|
return [frontend] if frontend else []
|
||||||
|
return ["*"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trusted_hosts(self) -> list[str]:
|
||||||
|
configured = _split_csv(os.getenv("TRUSTED_HOSTS"))
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
if self.is_production:
|
||||||
|
return ["sinbad2.ujaen.es"]
|
||||||
|
return ["*"]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
+27
-6
@@ -1,7 +1,12 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
|
|
||||||
|
from api.config.settings import get_settings
|
||||||
from api.database.mongodb import db
|
from api.database.mongodb import db
|
||||||
|
from api.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
|
|
||||||
# Routers
|
# Routers
|
||||||
from api.routers.test_mongo import router as test_mongo_router
|
from api.routers.test_mongo import router as test_mongo_router
|
||||||
@@ -20,16 +25,32 @@ from api.routers.google_auth import router as google_auth_router
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware, settings=settings)
|
||||||
|
|
||||||
|
if settings.is_production:
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
TrustedHostMiddleware,
|
||||||
allow_origins=["*"],
|
allowed_hosts=settings.trusted_hosts,
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cors_origins = settings.cors_allowed_origins
|
||||||
|
cors_kwargs = {
|
||||||
|
"allow_methods": ["*"],
|
||||||
|
"allow_headers": ["*"],
|
||||||
|
}
|
||||||
|
if cors_origins == ["*"]:
|
||||||
|
cors_kwargs["allow_origins"] = ["*"]
|
||||||
|
cors_kwargs["allow_credentials"] = False
|
||||||
|
else:
|
||||||
|
cors_kwargs["allow_origins"] = cors_origins
|
||||||
|
cors_kwargs["allow_credentials"] = True
|
||||||
|
|
||||||
|
app.add_middleware(CORSMiddleware, **cors_kwargs)
|
||||||
|
|
||||||
app.include_router(test_mongo_router, prefix="/api")
|
app.include_router(test_mongo_router, prefix="/api")
|
||||||
app.include_router(value_router, prefix="/api/criteria/doc")
|
app.include_router(value_router, prefix="/api/criteria/doc")
|
||||||
app.include_router(docmf_build_router, prefix="/api/criteria/doc-mf")
|
app.include_router(docmf_build_router, prefix="/api/criteria/doc-mf")
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from api.config.settings import Settings, get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(self, app, settings: Settings | None = None):
|
||||||
|
super().__init__(app)
|
||||||
|
self._settings = settings or get_settings()
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
|
||||||
|
if request.url.scheme == "https" or self._settings.is_production:
|
||||||
|
hsts = f"max-age={self._settings.SECURITY_HSTS_SECONDS}"
|
||||||
|
response.headers.setdefault("Strict-Transport-Security", hsts)
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
target: production
|
||||||
|
container_name: backend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8070:8000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
env_file:
|
||||||
|
- backend/.env
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
target: production
|
||||||
|
args:
|
||||||
|
VITE_BASE_PATH: /deckofcards/
|
||||||
|
VITE_API_URL: /deckofcards/api
|
||||||
|
container_name: frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8071:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mongo:4.4
|
||||||
|
container_name: mongo
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "27018:27017"
|
||||||
|
volumes:
|
||||||
|
- mongo_data:/data/db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo_data:
|
||||||
@@ -2,6 +2,7 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
|
target: development
|
||||||
container_name: backend
|
container_name: backend
|
||||||
ports:
|
ports:
|
||||||
- "8070:8000"
|
- "8070:8000"
|
||||||
@@ -15,6 +16,7 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
target: development
|
||||||
container_name: frontend
|
container_name: frontend
|
||||||
ports:
|
ports:
|
||||||
- "8071:5173"
|
- "8071:5173"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
# - Backend ejecutado fuera de Docker (uvicorn --port 8000):
|
# - Backend ejecutado fuera de Docker (uvicorn --port 8000):
|
||||||
# VITE_API_URL=http://localhost:8000/api
|
# VITE_API_URL=http://localhost:8000/api
|
||||||
#
|
#
|
||||||
# - Producción (ya definido en .env):
|
# - Producción Sinbad2 (HTTPS, prefijo /deckofcards; build en docker-compose.prod.yaml):
|
||||||
# VITE_API_URL=http://sinbad2.ujaen.es:8070/api
|
# VITE_BASE_PATH=/deckofcards/
|
||||||
|
# VITE_API_URL=/deckofcards/api
|
||||||
VITE_API_URL=http://localhost:8070/api
|
VITE_API_URL=http://localhost:8070/api
|
||||||
|
|||||||
+32
-1
@@ -1,5 +1,36 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine AS development
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host"]
|
CMD ["npm", "run", "dev", "--", "--host"]
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG VITE_BASE_PATH=/deckofcards/
|
||||||
|
ARG VITE_API_URL=/deckofcards/api
|
||||||
|
|
||||||
|
ENV VITE_BASE_PATH=$VITE_BASE_PATH
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS production
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Deck of Cards</title>
|
<title>Deck of Cards</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Apache en Sinbad2 termina TLS en https://sinbad2.ujaen.es/deckofcards
|
||||||
|
# y reenvía el tráfico a este contenedor (puerto 8071) sin el prefijo público:
|
||||||
|
# ProxyPass /deckofcards http://host.docker.internal:8071/
|
||||||
|
# El navegador sigue viendo /deckofcards/...; aquí llegan rutas como /, /api/, /assets/.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_x_forwarded_host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ export default function Header() {
|
|||||||
className="flex items-center gap-3 whitespace-nowrap transition-opacity hover:opacity-80"
|
className="flex items-center gap-3 whitespace-nowrap transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/favicon.svg"
|
src={`${import.meta.env.BASE_URL}favicon.svg`}
|
||||||
alt="Deck of Cards Logo"
|
alt="Deck of Cards Logo"
|
||||||
className="h-10 w-10 rounded-xl object-contain shadow-sm"
|
className="h-10 w-10 rounded-xl object-contain shadow-sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export const API_BASE_URL = import.meta.env.VITE_API_URL;
|
import { APP_BASE_PATH } from './lib/paths';
|
||||||
|
|
||||||
|
const configuredApiUrl = import.meta.env.VITE_API_URL;
|
||||||
|
export const API_BASE_URL = configuredApiUrl || (APP_BASE_PATH ? `${APP_BASE_PATH}/api` : '/api');
|
||||||
|
|
||||||
export const CHART_COLORS = [
|
export const CHART_COLORS = [
|
||||||
'#ef4444', '#f59e0b', '#10b981', '#3b82f6',
|
'#ef4444', '#f59e0b', '#10b981', '#3b82f6',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Axios from 'axios';
|
import Axios from 'axios';
|
||||||
import { API_BASE_URL } from '../config';
|
import { API_BASE_URL } from '../config';
|
||||||
|
import { isLoginPath, toAppPath } from './paths';
|
||||||
|
|
||||||
const api = Axios.create({
|
const api = Axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@@ -28,8 +29,8 @@ api.interceptors.response.use(
|
|||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
|
|
||||||
// SOLUCIÓN: Solo recargamos y redirigimos si NO estamos ya en /login
|
// SOLUCIÓN: Solo recargamos y redirigimos si NO estamos ya en /login
|
||||||
if (window.location.pathname !== '/login') {
|
if (!isLoginPath(window.location.pathname)) {
|
||||||
window.location.href = '/login';
|
window.location.href = toAppPath('/login');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const normalizeBasePath = (value) => {
|
||||||
|
if (!value || value === '/') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const withLeadingSlash = value.startsWith('/') ? value : `/${value}`;
|
||||||
|
return withLeadingSlash.replace(/\/$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APP_BASE_PATH = normalizeBasePath(import.meta.env.VITE_BASE_PATH);
|
||||||
|
|
||||||
|
export const toAppPath = (path) => {
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${APP_BASE_PATH}${normalizedPath}` || normalizedPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isLoginPath = (pathname) => {
|
||||||
|
const loginPath = toAppPath('/login');
|
||||||
|
return pathname === loginPath || pathname === '/login';
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { APP_BASE_PATH } from '../lib/paths';
|
||||||
import MainLayout from '../components/layout/MainLayout';
|
import MainLayout from '../components/layout/MainLayout';
|
||||||
import DocEditor from '../pages/DocEditor';
|
import DocEditor from '../pages/DocEditor';
|
||||||
import Login from '../pages/Login';
|
import Login from '../pages/Login';
|
||||||
@@ -18,7 +19,7 @@ function ProtectedHistoryRoute() {
|
|||||||
|
|
||||||
export default function AppRouter() {
|
export default function AppRouter() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router basename={APP_BASE_PATH || undefined}>
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/editor" replace />} />
|
<Route path="/" element={<Navigate to="/editor" replace />} />
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
const basePath = process.env.VITE_BASE_PATH || '/'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: basePath,
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|||||||
Reference in New Issue
Block a user