fix and update
This commit is contained in:
@@ -107,15 +107,13 @@ function applySessionState(
|
|||||||
|
|
||||||
function readInitialAuthState() {
|
function readInitialAuthState() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return { token: null as string | null, hasStoredSession: false, cachedUser: null as PublicUser | null };
|
return { token: null as string | null, hasStoredSession: false };
|
||||||
}
|
}
|
||||||
const token = window.localStorage.getItem(AUTH_TOKEN_KEY);
|
const token = window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
const refresh = window.localStorage.getItem(AUTH_REFRESH_KEY);
|
const refresh = window.localStorage.getItem(AUTH_REFRESH_KEY);
|
||||||
const cachedUser = readCachedUserProfile();
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
hasStoredSession: Boolean(refresh || token),
|
hasStoredSession: Boolean(refresh || token)
|
||||||
cachedUser
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +123,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const initialAuth = React.useMemo(() => readInitialAuthState(), []);
|
const initialAuth = React.useMemo(() => readInitialAuthState(), []);
|
||||||
const [token, setToken] = React.useState<string | null>(initialAuth.token);
|
const [token, setToken] = React.useState<string | null>(initialAuth.token);
|
||||||
const [user, setUser] = React.useState<PublicUser | null>(initialAuth.cachedUser);
|
const [user, setUser] = React.useState<PublicUser | null>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
const [isApiReady, setIsApiReady] = React.useState(false);
|
const [isApiReady, setIsApiReady] = React.useState(false);
|
||||||
const [isSessionReady, setIsSessionReady] = React.useState(false);
|
const [isSessionReady, setIsSessionReady] = React.useState(false);
|
||||||
@@ -269,8 +267,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setHasStoredSession(Boolean(refreshToken));
|
setHasStoredSession(Boolean(refreshToken));
|
||||||
|
|
||||||
if (!currentToken && !refreshToken) {
|
if (!currentToken && !refreshToken) {
|
||||||
|
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
initialBootstrapDoneRef.current = true;
|
initialBootstrapDoneRef.current = true;
|
||||||
setIsSessionReady(true);
|
setIsSessionReady(true);
|
||||||
|
setIsApiReady(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -282,7 +284,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) {
|
for (let bootstrapAttempt = 0; bootstrapAttempt < 4; bootstrapAttempt += 1) {
|
||||||
try {
|
try {
|
||||||
if (isInitialBootstrap) {
|
if (isInitialBootstrap) {
|
||||||
await waitForStableGateway(3);
|
await waitForStableGateway(3);
|
||||||
@@ -293,11 +295,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const session = await fetchAuthSession(currentToken);
|
const session = await fetchAuthSession(currentToken);
|
||||||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
||||||
|
setIsApiReady(true);
|
||||||
setIsSessionReady(true);
|
setIsSessionReady(true);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isPinRequiredError(error)) {
|
if (isPinRequiredError(error)) {
|
||||||
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||||||
|
setIsApiReady(true);
|
||||||
|
setIsSessionReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') {
|
if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') {
|
||||||
@@ -321,11 +326,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
cacheUserProfile(refreshed.user);
|
cacheUserProfile(refreshed.user);
|
||||||
}
|
}
|
||||||
activatePinLock(refreshed.sessionId ?? '');
|
activatePinLock(refreshed.sessionId ?? '');
|
||||||
|
setIsApiReady(true);
|
||||||
|
setIsSessionReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (refreshed.accessToken) {
|
if (refreshed.accessToken) {
|
||||||
const session = await fetchAuthSession(refreshed.accessToken);
|
const session = await fetchAuthSession(refreshed.accessToken);
|
||||||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
||||||
|
setIsApiReady(true);
|
||||||
setIsSessionReady(true);
|
setIsSessionReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -333,17 +341,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
throw new ApiError('Сессия недействительна', 401);
|
throw new ApiError('Сессия недействительна', 401);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 6) {
|
if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 4) {
|
||||||
invalidateApiGatewayReady();
|
invalidateApiGatewayReady();
|
||||||
|
if (bootstrapAttempt >= 1) {
|
||||||
|
setBootstrapError(
|
||||||
|
'Не удалось подключиться к серверу API (502). Подождите несколько секунд и нажмите «Повторить».'
|
||||||
|
);
|
||||||
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isPinRequiredError(error)) {
|
if (isPinRequiredError(error)) {
|
||||||
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||||||
|
setIsApiReady(true);
|
||||||
|
setIsSessionReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (refreshToken && error instanceof ApiError && error.status === 403) {
|
if (refreshToken && error instanceof ApiError && error.status === 403) {
|
||||||
activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||||||
|
setIsApiReady(true);
|
||||||
|
setIsSessionReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) {
|
if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) {
|
||||||
@@ -612,7 +629,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const isBootstrapping =
|
const isBootstrapping =
|
||||||
!isPinLocked &&
|
!isPinLocked &&
|
||||||
Boolean(hasStoredSession || token || user) &&
|
Boolean(hasStoredSession || token) &&
|
||||||
(isLoading || !isApiReady || !isSessionReady);
|
(isLoading || !isApiReady || !isSessionReady);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -649,6 +666,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
? () => {
|
? () => {
|
||||||
setBootstrapError(null);
|
setBootstrapError(null);
|
||||||
initialBootstrapDoneRef.current = false;
|
initialBootstrapDoneRef.current = false;
|
||||||
|
invalidateApiGatewayReady();
|
||||||
void refreshProfile();
|
void refreshProfile();
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ import { useAuth } from '@/components/id/auth-provider';
|
|||||||
export function useRequireAuth() {
|
export function useRequireAuth() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, isLoading, isApiReady, isSessionReady, isPinLocked, hasStoredSession } = useAuth();
|
const { user, isLoading, isApiReady, isSessionReady, isPinLocked, hasStoredSession, token } = useAuth();
|
||||||
|
|
||||||
|
const authResolved = !isLoading && isSessionReady;
|
||||||
|
const isAuthenticated = Boolean(user || isPinLocked);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || !isApiReady || !isSessionReady) return;
|
if (!authResolved) return;
|
||||||
if (user || isPinLocked || hasStoredSession) return;
|
if (isAuthenticated || hasStoredSession || token) return;
|
||||||
if (pathname.startsWith('/auth/')) return;
|
if (pathname.startsWith('/auth/')) return;
|
||||||
router.replace('/auth/login');
|
router.replace('/auth/login');
|
||||||
}, [hasStoredSession, isApiReady, isLoading, isPinLocked, isSessionReady, pathname, router, user]);
|
}, [authResolved, hasStoredSession, isAuthenticated, pathname, router, token]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
@@ -22,6 +25,7 @@ export function useRequireAuth() {
|
|||||||
isPinLocked,
|
isPinLocked,
|
||||||
isApiReady,
|
isApiReady,
|
||||||
isSessionReady,
|
isSessionReady,
|
||||||
isReady: !isLoading && isApiReady && isSessionReady && Boolean(user || isPinLocked || hasStoredSession)
|
authResolved,
|
||||||
|
isReady: authResolved && isAuthenticated && isApiReady
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,7 +449,10 @@ export function isPinRequiredError(error: unknown): error is ApiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isGatewayUnavailableError(error: unknown): boolean {
|
export function isGatewayUnavailableError(error: unknown): boolean {
|
||||||
return error instanceof ApiError && [502, 503, 504].includes(error.status);
|
return (
|
||||||
|
error instanceof ApiError &&
|
||||||
|
(error.code === 'GATEWAY_UNAVAILABLE' || [502, 503, 504].includes(error.status))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getApiErrorMessage(error: unknown, fallback: string): string | null {
|
export function getApiErrorMessage(error: unknown, fallback: string): string | null {
|
||||||
@@ -681,7 +684,7 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
|
|||||||
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
|
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
|
||||||
const GATEWAY_RETRY_ATTEMPTS = 4;
|
const GATEWAY_RETRY_ATTEMPTS = 4;
|
||||||
const GATEWAY_STABLE_PROBE_INTERVAL_MS = 800;
|
const GATEWAY_STABLE_PROBE_INTERVAL_MS = 800;
|
||||||
const GATEWAY_STABLE_MAX_ROUNDS = 18;
|
const GATEWAY_STABLE_MAX_ROUNDS = 12;
|
||||||
const API_MAX_CONCURRENT = 6;
|
const API_MAX_CONCURRENT = 6;
|
||||||
const API_BURST_MAX_CONCURRENT = 2;
|
const API_BURST_MAX_CONCURRENT = 2;
|
||||||
const API_BURST_WINDOW_MS = 5000;
|
const API_BURST_WINDOW_MS = 5000;
|
||||||
|
|||||||
@@ -267,12 +267,17 @@ services:
|
|||||||
image: nginx:1.27-alpine
|
image: nginx:1.27-alpine
|
||||||
container_name: lendry-id-nginx
|
container_name: lendry-id-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
IDP_API_GATEWAY_HOST: lendry-id-api-gateway
|
||||||
|
IDP_API_GATEWAY_PORT: "3000"
|
||||||
|
IDP_FRONTEND_DOMAIN: ${DOMAIN_FRONTEND:-}
|
||||||
ports:
|
ports:
|
||||||
- "${NGINX_HTTP_PORT:-80}:80"
|
- "${NGINX_HTTP_PORT:-80}:80"
|
||||||
- "${NGINX_HTTPS_PORT:-443}:443"
|
- "${NGINX_HTTPS_PORT:-443}:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
- ./nginx/certs:/etc/nginx/certs:ro
|
- ./nginx/certs:/etc/nginx/certs:ro
|
||||||
|
- ./infra/nginx/99-idp-gateway-watch.sh:/docker-entrypoint.d/99-idp-gateway-watch.sh:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
api-gateway:
|
api-gateway:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -286,12 +291,12 @@ services:
|
|||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
"CMD-SHELL",
|
"CMD-SHELL",
|
||||||
"wget -qO- --timeout=3 http://lendry-id-api-gateway:3000/health 2>/dev/null | grep -q '\"status\"' || exit 1",
|
"if [ -n \"$IDP_FRONTEND_DOMAIN\" ]; then wget -qO- --timeout=4 --no-check-certificate --header=\"Host: $IDP_FRONTEND_DOMAIN\" \"https://127.0.0.1/idp-api/health\" 2>/dev/null | grep -q '\"status\"' || wget -qO- --timeout=4 --header=\"Host: $IDP_FRONTEND_DOMAIN\" \"http://127.0.0.1/idp-api/health\" 2>/dev/null | grep -q '\"status\"' || exit 1; else wget -qO- --timeout=4 http://lendry-id-api-gateway:3000/health 2>/dev/null | grep -q '\"status\"' || exit 1; fi",
|
||||||
]
|
]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 8s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 45s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
52
infra/nginx/99-idp-gateway-watch.sh
Normal file
52
infra/nginx/99-idp-gateway-watch.sh
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# После recreate api-gateway Docker выдаёт новый IP; без reload nginx шлёт на старый адрес → 502.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
GW_HOST="${IDP_API_GATEWAY_HOST:-lendry-id-api-gateway}"
|
||||||
|
GW_PORT="${IDP_API_GATEWAY_PORT:-3000}"
|
||||||
|
WATCH_INTERVAL="${IDP_NGINX_WATCH_INTERVAL:-5}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[idp-nginx-watch] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_gateway() {
|
||||||
|
attempt=0
|
||||||
|
while [ "$attempt" -lt 120 ]; do
|
||||||
|
if wget -qO- --timeout=3 "http://${GW_HOST}:${GW_PORT}/health" 2>/dev/null | grep -q '"status"'; then
|
||||||
|
log "api-gateway доступен (${GW_HOST}:${GW_PORT})"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
log "WARN: api-gateway не ответил за 120с — nginx стартует без ожидания"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
watch_gateway() {
|
||||||
|
last_ip=""
|
||||||
|
while true; do
|
||||||
|
sleep "$WATCH_INTERVAL"
|
||||||
|
current_ip="$(getent hosts "$GW_HOST" 2>/dev/null | awk '{print $1}' | head -n1 || true)"
|
||||||
|
healthy=0
|
||||||
|
if wget -qO- --timeout=3 "http://${GW_HOST}:${GW_PORT}/health" 2>/dev/null | grep -q '"status"'; then
|
||||||
|
healthy=1
|
||||||
|
fi
|
||||||
|
if [ "$healthy" -eq 0 ]; then
|
||||||
|
log "api-gateway недоступен — reload nginx"
|
||||||
|
nginx -s reload 2>/dev/null || true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ -n "$current_ip" ] && [ -n "$last_ip" ] && [ "$current_ip" != "$last_ip" ]; then
|
||||||
|
log "IP ${GW_HOST} изменился (${last_ip} → ${current_ip}) — reload nginx"
|
||||||
|
nginx -s reload 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -n "$current_ip" ]; then
|
||||||
|
last_ip="$current_ip"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_gateway
|
||||||
|
watch_gateway &
|
||||||
39
install.sh
39
install.sh
@@ -419,7 +419,11 @@ sync_nginx_after_api_gateway_restart() {
|
|||||||
wait_for_api_gateway_http 90 || warn "api-gateway /health на :3000 не отвечает"
|
wait_for_api_gateway_http 90 || warn "api-gateway /health на :3000 не отвечает"
|
||||||
sleep 2
|
sleep 2
|
||||||
reload_docker_nginx
|
reload_docker_nginx
|
||||||
verify_nginx_reaches_api_gateway 24 5 || warn "Nginx → api-gateway: проверка не прошла, см. docker compose logs nginx api-gateway"
|
if ! verify_nginx_reaches_api_gateway 24 5; then
|
||||||
|
warn "Reload не помог — пересоздаём lendry-id-nginx..."
|
||||||
|
recreate_docker_nginx
|
||||||
|
verify_nginx_reaches_api_gateway 12 5 || warn "Nginx → api-gateway: проверка не прошла, см. docker compose logs nginx api-gateway"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
recreate_docker_nginx() {
|
recreate_docker_nginx() {
|
||||||
@@ -2658,6 +2662,7 @@ verify_nginx_reaches_api_gateway() {
|
|||||||
wget -qO- --timeout=5 http://lendry-id-api-gateway:3000/health 2>/dev/null || true)"
|
wget -qO- --timeout=5 http://lendry-id-api-gateway:3000/health 2>/dev/null || true)"
|
||||||
if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then
|
if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then
|
||||||
ok "Docker Nginx → lendry-id-api-gateway:3000/health OK (IP api-gateway: ${gw_ip:-?})"
|
ok "Docker Nginx → lendry-id-api-gateway:3000/health OK (IP api-gateway: ${gw_ip:-?})"
|
||||||
|
verify_nginx_idp_api_location || return 1
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
@@ -2671,6 +2676,38 @@ verify_nginx_reaches_api_gateway() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Тот же путь, что в браузере: location ^~ /idp-api/ + rewrite + proxy_pass $idp_up_api
|
||||||
|
verify_nginx_idp_api_location() {
|
||||||
|
docker_nginx_container_running || return 0
|
||||||
|
build_compose_stack_cmd
|
||||||
|
load_env
|
||||||
|
local frontend_domain body code
|
||||||
|
frontend_domain="$(env_get DOMAIN_FRONTEND "")"
|
||||||
|
[[ -n "$frontend_domain" ]] || return 0
|
||||||
|
|
||||||
|
body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \
|
||||||
|
wget -qO- --timeout=6 --header="Host: ${frontend_domain}" \
|
||||||
|
"http://127.0.0.1/idp-api/health" 2>/dev/null || true)"
|
||||||
|
if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then
|
||||||
|
ok "Docker Nginx location /idp-api/ → api-gateway OK (Host: ${frontend_domain})"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
reload_docker_nginx
|
||||||
|
sleep 2
|
||||||
|
body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \
|
||||||
|
wget -qO- --timeout=6 --header="Host: ${frontend_domain}" \
|
||||||
|
"http://127.0.0.1/idp-api/health" 2>/dev/null || true)"
|
||||||
|
if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then
|
||||||
|
ok "Docker Nginx location /idp-api/ → api-gateway OK после reload"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "Nginx location /idp-api/ не проксирует на api-gateway (Host: ${frontend_domain})"
|
||||||
|
warn "Проверьте: grep -E 'idp-api|idp_up_api|resolver' nginx/conf.d/${NGINX_PREFIX}-frontend.conf"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
verify_nginx_reaches_media_ws() {
|
verify_nginx_reaches_media_ws() {
|
||||||
docker_nginx_container_running || return 0
|
docker_nginx_container_running || return 0
|
||||||
build_compose_stack_cmd
|
build_compose_stack_cmd
|
||||||
|
|||||||
Reference in New Issue
Block a user