mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:20:44 +00:00
Merge branch 'v30'
This commit is contained in:
@@ -126,8 +126,6 @@ jobs:
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
- name: 🚢 Deploy
|
||||
env:
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
|
||||
ENV: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }}
|
||||
@@ -147,10 +145,12 @@ jobs:
|
||||
echo "Deployment created in ${SECONDS} seconds" >> $GITHUB_STEP_SUMMARY
|
||||
echo "::endgroup::"
|
||||
- name: ⏳ Wait for deployment to start
|
||||
env:
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
run: |
|
||||
echo "::group::Wait for deployment to start"
|
||||
set -euxo pipefail
|
||||
while [ "$(curl -s https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
|
||||
while [ "$(curl -s -H "X-API-Key: ${API_KEY}" https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
|
||||
if [ "$SECONDS" -ge 300 ]; then
|
||||
echo "Timeout: deployment did not start within 5 minutes"
|
||||
exit 1
|
||||
|
||||
@@ -64,8 +64,6 @@ jobs:
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
- name: 🚀 Deploy image
|
||||
env:
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
GHCR_REPO: openfront-prod
|
||||
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
|
||||
DOMAIN: ${{ vars.DOMAIN }}
|
||||
@@ -82,10 +80,11 @@ jobs:
|
||||
- name: ⏳ Wait for deployment to start
|
||||
env:
|
||||
FQDN: alpha.${{ vars.DOMAIN }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
run: |
|
||||
echo "::group::Wait for deployment to start"
|
||||
set -euxo pipefail
|
||||
while [ "$(curl -s https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
|
||||
while [ "$(curl -s -H "X-API-Key: ${API_KEY}" https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
|
||||
if [ "$SECONDS" -ge 300 ]; then
|
||||
echo "Timeout: deployment did not start within 5 minutes"
|
||||
exit 1
|
||||
@@ -115,8 +114,6 @@ jobs:
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
- name: 🚀 Deploy image
|
||||
env:
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
|
||||
DOMAIN: ${{ vars.DOMAIN }}
|
||||
@@ -133,10 +130,11 @@ jobs:
|
||||
- name: ⏳ Wait for deployment to start
|
||||
env:
|
||||
FQDN: beta.${{ vars.DOMAIN }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
run: |
|
||||
echo "::group::Wait for deployment to start"
|
||||
set -euxo pipefail
|
||||
while [ "$(curl -s https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
|
||||
while [ "$(curl -s -H "X-API-Key: ${API_KEY}" https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
|
||||
if [ "$SECONDS" -ge 300 ]; then
|
||||
echo "Timeout: deployment did not start within 5 minutes"
|
||||
exit 1
|
||||
@@ -166,8 +164,6 @@ jobs:
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
- name: 🚀 Deploy image
|
||||
env:
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
|
||||
DOMAIN: ${{ vars.DOMAIN }}
|
||||
@@ -184,10 +180,11 @@ jobs:
|
||||
- name: ⏳ Wait for deployment to start
|
||||
env:
|
||||
FQDN: blue.${{ vars.DOMAIN }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
run: |
|
||||
echo "::group::Wait for deployment to start"
|
||||
set -euxo pipefail
|
||||
while [ "$(curl -s https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
|
||||
while [ "$(curl -s -H "X-API-Key: ${API_KEY}" https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
|
||||
if [ "$SECONDS" -ge 300 ]; then
|
||||
echo "Timeout: deployment did not start within 5 minutes"
|
||||
exit 1
|
||||
@@ -217,8 +214,6 @@ jobs:
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
- name: 🚀 Deploy image
|
||||
env:
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
|
||||
DOMAIN: ${{ vars.DOMAIN }}
|
||||
@@ -235,10 +230,11 @@ jobs:
|
||||
- name: ⏳ Wait for deployment to start
|
||||
env:
|
||||
FQDN: green.${{ vars.DOMAIN }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
run: |
|
||||
echo "::group::Wait for deployment to start"
|
||||
set -euxo pipefail
|
||||
while [ "$(curl -s https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
|
||||
while [ "$(curl -s -H "X-API-Key: ${API_KEY}" https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
|
||||
if [ "$SECONDS" -ge 300 ]; then
|
||||
echo "Timeout: deployment did not start within 5 minutes"
|
||||
exit 1
|
||||
|
||||
+10
-18
@@ -38,24 +38,14 @@ FROM base
|
||||
RUN apt-get update && apt-get install -y \
|
||||
nginx \
|
||||
curl \
|
||||
jq \
|
||||
wget \
|
||||
supervisor \
|
||||
apache2-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb > cloudflared.deb \
|
||||
&& dpkg -i cloudflared.deb \
|
||||
&& rm cloudflared.deb
|
||||
|
||||
# Update worker_connections in nginx.conf
|
||||
RUN sed -i 's/worker_connections [0-9]*/worker_connections 8192/' /etc/nginx/nginx.conf
|
||||
|
||||
# Create cloudflared directory with proper permissions
|
||||
RUN mkdir -p /etc/cloudflared && \
|
||||
chown -R node:node /etc/cloudflared && \
|
||||
chmod -R 755 /etc/cloudflared
|
||||
|
||||
# Setup supervisor configuration
|
||||
RUN mkdir -p /var/log/supervisor
|
||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
@@ -64,10 +54,6 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
RUN rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# Copy and make executable the startup script
|
||||
COPY startup.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/startup.sh
|
||||
|
||||
# Copy production node_modules from prod-deps stage (cached separately from build)
|
||||
COPY --from=prod-deps /usr/src/app/node_modules ./node_modules
|
||||
COPY package*.json ./
|
||||
@@ -87,8 +73,14 @@ ARG GIT_COMMIT=unknown
|
||||
RUN echo "$GIT_COMMIT" > static/commit.txt
|
||||
|
||||
ENV GIT_COMMIT="$GIT_COMMIT"
|
||||
ENV CF_CONFIG_PATH=/etc/cloudflared/config.yml
|
||||
ENV CF_CREDS_PATH=/etc/cloudflared/creds.json
|
||||
|
||||
# Use the startup script as the entrypoint
|
||||
ENTRYPOINT ["/usr/local/bin/startup.sh"]
|
||||
RUN <<'EOF' tee /usr/local/bin/start.sh
|
||||
#!/bin/sh
|
||||
if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then
|
||||
exec timeout 18h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
else
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
fi
|
||||
EOF
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
ENTRYPOINT ["/usr/local/bin/start.sh"]
|
||||
|
||||
@@ -134,8 +134,6 @@ ENV=$ENV
|
||||
HOST=$HOST
|
||||
GHCR_IMAGE=$GHCR_IMAGE
|
||||
GHCR_TOKEN=$GHCR_TOKEN
|
||||
CF_ACCOUNT_ID=$CF_ACCOUNT_ID
|
||||
CF_API_TOKEN=$CF_API_TOKEN
|
||||
TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY
|
||||
API_KEY=$API_KEY
|
||||
DOMAIN=$DOMAIN
|
||||
|
||||
@@ -6,9 +6,6 @@ GHCR_USERNAME=username
|
||||
GHCR_REPO=your-repo-name
|
||||
GHCR_TOKEN=your_docker_token_here
|
||||
|
||||
# Cloudflare Configuration
|
||||
CF_ACCOUNT_ID=your_cloudflare_account_id
|
||||
CF_API_TOKEN=your_cloudflare_api_token
|
||||
DOMAIN=your-domain.com
|
||||
|
||||
# API Key
|
||||
|
||||
@@ -13,6 +13,12 @@ proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=API_CACHE:10m inactiv
|
||||
server {
|
||||
listen 80 default_server;
|
||||
|
||||
# Large cookie support
|
||||
large_client_header_buffers 4 32k;
|
||||
proxy_buffer_size 32k;
|
||||
proxy_busy_buffers_size 48k;
|
||||
proxy_buffers 4 32k;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
@@ -420,18 +420,19 @@ function createMenuElements(
|
||||
: !BuildableAttacks.has(item.unitType)),
|
||||
)
|
||||
.map((item: BuildItemDisplay) => {
|
||||
const canBuildOrUpgrade = params.buildMenu.canBuildOrUpgrade(item);
|
||||
return {
|
||||
id: `${elementIdPrefix}_${item.unitType}`,
|
||||
name: item.key
|
||||
? item.key.replace("unit_type.", "")
|
||||
: item.unitType.toString(),
|
||||
disabled: () => !canBuildOrUpgrade,
|
||||
color: canBuildOrUpgrade
|
||||
? filterType === "attack"
|
||||
? COLORS.attack
|
||||
: COLORS.building
|
||||
: undefined,
|
||||
disabled: (p: MenuElementParams) =>
|
||||
!p.buildMenu.canBuildOrUpgrade(item),
|
||||
color: (p: MenuElementParams) =>
|
||||
p.buildMenu.canBuildOrUpgrade(item)
|
||||
? filterType === "attack"
|
||||
? COLORS.attack
|
||||
: COLORS.building
|
||||
: COLORS.building,
|
||||
icon: item.icon,
|
||||
tooltipItems: [
|
||||
{ text: translateText(item.key ?? ""), className: "title" },
|
||||
@@ -456,7 +457,7 @@ function createMenuElements(
|
||||
if (buildableUnit === undefined) {
|
||||
return;
|
||||
}
|
||||
if (canBuildOrUpgrade) {
|
||||
if (params.buildMenu.canBuildOrUpgrade(item)) {
|
||||
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
|
||||
}
|
||||
params.closeMenu();
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function readGameRecord(
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": config.apiKey(),
|
||||
},
|
||||
});
|
||||
const record = await response.json();
|
||||
|
||||
@@ -55,6 +55,9 @@ export function registerGamePreviewRoute(opts: {
|
||||
const apiDomain = config.jwtIssuer();
|
||||
const encodedID = encodeURIComponent(gameID);
|
||||
const response = await fetch(`${apiDomain}/game/${encodedID}`, {
|
||||
headers: {
|
||||
"x-api-key": config.apiKey(),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
@@ -74,6 +74,7 @@ export async function getUserMe(
|
||||
const response = await fetch(config.jwtIssuer() + "/users/@me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"x-api-key": config.apiKey(),
|
||||
},
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Check if required environment variables are set
|
||||
if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ACCOUNT_ID" ] || [ -z "$SUBDOMAIN" ] || [ -z "$DOMAIN" ]; then
|
||||
echo "Error: Required environment variables not set"
|
||||
echo "Please set CF_API_TOKEN, CF_ACCOUNT_ID, SUBDOMAIN, and DOMAIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate a unique tunnel name using timestamp
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
TUNNEL_NAME="${SUBDOMAIN}-tunnel-${TIMESTAMP}"
|
||||
echo "Using unique tunnel name: ${TUNNEL_NAME}"
|
||||
|
||||
# Create a new tunnel
|
||||
echo "Creating Cloudflare tunnel for subdomain ${SUBDOMAIN}..."
|
||||
TUNNEL_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"name\":\"${TUNNEL_NAME}\"}")
|
||||
|
||||
# Extract tunnel ID and token
|
||||
TUNNEL_ID=$(echo $TUNNEL_RESPONSE | jq -r '.result.id')
|
||||
TUNNEL_TOKEN=$(echo $TUNNEL_RESPONSE | jq -r '.result.token')
|
||||
|
||||
if [ -z "$TUNNEL_ID" ] || [ "$TUNNEL_ID" == "null" ]; then
|
||||
echo "Failed to create tunnel"
|
||||
echo $TUNNEL_RESPONSE
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Tunnel created with ID: ${TUNNEL_ID}"
|
||||
|
||||
# Configure the tunnel with hostname
|
||||
echo "Configuring tunnel to point to tunnel-${SUBDOMAIN}.${DOMAIN}..."
|
||||
curl -s -X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"config\":{\"ingress\":[{\"hostname\":\"tunnel-${SUBDOMAIN}.${DOMAIN}\",\"service\":\"http://localhost:80\"},{\"service\":\"http_status:404\"}]}}"
|
||||
|
||||
# Update DNS record to point to the new tunnel
|
||||
echo "Updating DNS record to point to the new tunnel..."
|
||||
|
||||
# First check if DNS record exists
|
||||
DNS_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=${DOMAIN}" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
ZONE_ID=$(echo $DNS_RECORDS | jq -r '.result[0].id')
|
||||
|
||||
if [ -z "$ZONE_ID" ] || [ "$ZONE_ID" == "null" ]; then
|
||||
echo "Could not find zone ID for domain ${DOMAIN}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for existing record
|
||||
EXISTING_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=tunnel-${SUBDOMAIN}.${DOMAIN}" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
RECORD_ID=$(echo $EXISTING_RECORDS | jq -r '.result[0].id')
|
||||
|
||||
# Create or update the DNS record
|
||||
if [ -z "$RECORD_ID" ] || [ "$RECORD_ID" == "null" ]; then
|
||||
# Create new record
|
||||
echo "Creating new DNS record..."
|
||||
DNS_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"CNAME\",\"name\":\"tunnel-${SUBDOMAIN}.${DOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}")
|
||||
else
|
||||
# Update existing record
|
||||
echo "Updating existing DNS record..."
|
||||
DNS_RESPONSE=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"CNAME\",\"name\":\"tunnel-${SUBDOMAIN}.${DOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}")
|
||||
fi
|
||||
|
||||
# Log the tunnel information
|
||||
echo "Tunnel configuration is set up! Site will be available at: https://tunnel-${SUBDOMAIN}.${DOMAIN}"
|
||||
|
||||
# Export the tunnel token for supervisord
|
||||
export CLOUDFLARE_TUNNEL_TOKEN=${TUNNEL_TOKEN}
|
||||
|
||||
# Start supervisord
|
||||
if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then
|
||||
exec timeout 18h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
else
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
fi
|
||||
@@ -23,11 +23,3 @@ stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:cloudflared]
|
||||
command=cloudflared tunnel run --token %(ENV_CLOUDFLARE_TUNNEL_TOKEN)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=node
|
||||
stdout_logfile=/var/log/cloudflared.log
|
||||
stderr_logfile=/var/log/cloudflared-err.log
|
||||
@@ -557,7 +557,11 @@ describe("RadialMenuElements", () => {
|
||||
const subMenu = buildMenuElement.subMenu!(mockParams);
|
||||
const cityElement = subMenu.find((item) => item.id === "build_City");
|
||||
|
||||
expect(cityElement!.color).toBe(COLORS.building);
|
||||
expect(
|
||||
(cityElement!.color as (params: MenuElementParams) => string)(
|
||||
mockParams,
|
||||
),
|
||||
).toBe(COLORS.building);
|
||||
});
|
||||
|
||||
it("should use correct colors for attack elements", () => {
|
||||
@@ -572,16 +576,24 @@ describe("RadialMenuElements", () => {
|
||||
(item) => item.id === "attack_Atom Bomb",
|
||||
);
|
||||
|
||||
expect(atomBombElement!.color).toBe(COLORS.attack);
|
||||
expect(
|
||||
(atomBombElement!.color as (params: MenuElementParams) => string)(
|
||||
mockParams,
|
||||
),
|
||||
).toBe(COLORS.attack);
|
||||
});
|
||||
|
||||
it("should not set color when element is disabled", () => {
|
||||
it("should use disabled color when element is disabled", () => {
|
||||
mockBuildMenu.canBuildOrUpgrade = vi.fn(() => false);
|
||||
|
||||
const subMenu = buildMenuElement.subMenu!(mockParams);
|
||||
const cityElement = subMenu.find((item) => item.id === "build_City");
|
||||
|
||||
expect(cityElement!.color).toBeUndefined();
|
||||
expect(
|
||||
(cityElement!.color as (params: MenuElementParams) => string)(
|
||||
mockParams,
|
||||
),
|
||||
).toBe(COLORS.building);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -62,14 +62,10 @@ echo "Starting new container for ${HOST} environment..."
|
||||
# Ensure the traefik network exists
|
||||
docker network create web 2> /dev/null || true
|
||||
|
||||
# Remove any existing volume for this container if it exists
|
||||
docker volume rm "cloudflared-${CONTAINER_NAME}" 2> /dev/null || true
|
||||
|
||||
docker run -d \
|
||||
--restart="${RESTART}" \
|
||||
--env-file "$ENV_FILE" \
|
||||
--name "${CONTAINER_NAME}" \
|
||||
-v "cloudflared-${CONTAINER_NAME}:/etc/cloudflared" \
|
||||
--network web \
|
||||
--label "traefik.enable=true" \
|
||||
--label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${SUBDOMAIN}.${DOMAIN}\`)" \
|
||||
|
||||
Reference in New Issue
Block a user