- /api/deploy/webhook: X-Deploy-Token 검증 후 백그라운드로 deploy.sh spawn - scripts/deploy.sh: git pull + docker compose up --build + migrate - docker-compose.prod.yml: docker.sock + 소스 디렉토리 마운트 - deploy.yml: webhook 호출 + 헬스체크 폴링 (시크릿 의존성 제거) - 미들웨어 공개 경로에 /api/deploy/webhook 추가 서버 1회 셋업 (docker-compose.prod.yml 갱신본을 한 번 배포하기만 하면 이후 push 시 자동 재배포 영구 동작)
This commit is contained in:
+28
-51
@@ -1,4 +1,4 @@
|
||||
name: Deploy momo-erp to production
|
||||
name: Deploy momo-erp via webhook
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -9,56 +9,33 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH
|
||||
- name: Trigger deploy webhook
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Deploy via SSH
|
||||
env:
|
||||
SSH_USER: ${{ secrets.DEPLOY_USER }}
|
||||
SSH_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||
MASTER_PWD: ${{ secrets.MASTER_PWD }}
|
||||
AES_KEY: ${{ secrets.AES_KEY }}
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" bash -s << 'REMOTE'
|
||||
set -e
|
||||
DEPLOY_DIR="$HOME/momo-erp/source"
|
||||
mkdir -p "$HOME/momo-erp"
|
||||
if [ -d "$DEPLOY_DIR/.git" ]; then
|
||||
cd "$DEPLOY_DIR" && git fetch origin && git reset --hard origin/main
|
||||
else
|
||||
git clone https://git.junggomoa.com/chpark/distribution_erp.git "$DEPLOY_DIR"
|
||||
cd "$DEPLOY_DIR"
|
||||
# 운영 서버에 webhook 호출 — 시크릿 없이 동작
|
||||
# 토큰은 .env.production 의 DEPLOY_WEBHOOK_TOKEN 과 일치
|
||||
TOKEN="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}"
|
||||
if [ -z "$TOKEN" ]; then TOKEN="momo-deploy-2026-secure"; fi
|
||||
echo "::notice::POST https://momo.junggomoa.com/api/deploy/webhook"
|
||||
HTTP_CODE=$(curl -sS -o /tmp/resp.json -w "%{http_code}" -X POST \
|
||||
-H "X-Deploy-Token: $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"branch":"main","commit":"${{ github.sha }}"}' \
|
||||
https://momo.junggomoa.com/api/deploy/webhook)
|
||||
cat /tmp/resp.json
|
||||
echo ""
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::Webhook 실패: HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
cat > .env.production <<EOF
|
||||
DATABASE_URL="$DATABASE_URL"
|
||||
NEXTAUTH_URL="$NEXTAUTH_URL"
|
||||
NEXTAUTH_SECRET="$NEXTAUTH_SECRET"
|
||||
NEXT_PUBLIC_APP_NAME="유통관리 ERP"
|
||||
NEXT_PUBLIC_COMPANY_NAME="모모유통"
|
||||
MASTER_PWD="$MASTER_PWD"
|
||||
AES_KEY="$AES_KEY"
|
||||
FILE_STORAGE_PATH="/data_storage"
|
||||
LOG_LEVEL=info
|
||||
SMTP_HOST="mail.coa-soft.com"
|
||||
SMTP_PORT="465"
|
||||
SMTP_USER="chpark@coa-soft.com"
|
||||
SMTP_PASS="1321Qkrckd!!!!!!"
|
||||
SMTP_FROM="모모유통 <chpark@coa-soft.com>"
|
||||
MOMO_BANK_ACCOUNT="기업은행 434-115361-01-016"
|
||||
MOMO_PHONE="010-6624-5315"
|
||||
EOF
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
# DB 마이그레이션 (idempotent)
|
||||
docker compose -f docker-compose.prod.yml exec -T momo-erp npm run migrate:momo || true
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
REMOTE
|
||||
echo "::notice::배포 webhook 트리거 성공 — 서버에서 백그라운드 빌드 진행 중"
|
||||
# 서버가 git pull + docker build 하는 동안 60s 대기 후 헬스체크
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
sleep 30
|
||||
if curl -fsS -o /dev/null https://momo.junggomoa.com/; then
|
||||
echo "::notice::헬스체크 OK ($((i * 30))s)"
|
||||
exit 0
|
||||
fi
|
||||
echo "헬스체크 대기 ${i}/10..."
|
||||
done
|
||||
echo "::warning::5분 안에 사이트가 응답하지 않음 — 수동 확인 필요"
|
||||
|
||||
@@ -13,6 +13,12 @@ services:
|
||||
- .env.production
|
||||
volumes:
|
||||
- ./data_storage:/data_storage
|
||||
# 자가 배포: webhook 이 호스트의 deploy.sh 를 실행하기 위함
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./scripts/deploy.sh:/deploy/deploy.sh:ro
|
||||
# source 디렉토리를 컨테이너 안에서 git pull 하기 위해 호스트의 소스를 마운트
|
||||
# (호스트 ~/momo-erp/source 를 /deploy/source 로)
|
||||
- $PWD:/deploy/source
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# 운영 서버에서 실행되는 배포 스크립트
|
||||
# webhook 이 이걸 spawn 함. 또는 cron/수동 실행 가능
|
||||
# 호스트에 미리 배치: cp scripts/deploy.sh /deploy/deploy.sh && chmod +x
|
||||
|
||||
set -e
|
||||
cd /deploy/source 2>/dev/null || cd "$HOME/momo-erp/source"
|
||||
|
||||
echo "[$(date)] git fetch + reset --hard origin/main"
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
|
||||
echo "[$(date)] docker compose up --build"
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
echo "[$(date)] migrate:momo (idempotent)"
|
||||
docker compose -f docker-compose.prod.yml exec -T momo-erp npm run migrate:momo || true
|
||||
|
||||
echo "[$(date)] docker ps"
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
echo "[$(date)] ✔ 배포 완료"
|
||||
@@ -0,0 +1,212 @@
|
||||
// 운영 환경(momo.junggomoa.com) E2E 테스트
|
||||
// 1. 프로덕션 시드 (품목, 재고)
|
||||
// 2. chpark@wace.me / 박창현유통 가입
|
||||
// 3. 엑셀에서 추가 업체 5개 시드 가입
|
||||
// 4. 각 업체별 발주
|
||||
// 5. 관리자 로그인 → 승인 → 메일 발송 검증
|
||||
|
||||
import pg from "pg";
|
||||
import bcrypt from "bcryptjs";
|
||||
import * as XLSX from "xlsx";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const BASE = "https://momo.junggomoa.com";
|
||||
const DB_URL = "postgresql://momo_app:qlalfqjsgh11@183.99.177.40:5432/distribution";
|
||||
const ADMIN_EMAIL = "admin@momo.com";
|
||||
const ADMIN_PASS = "admin1234";
|
||||
|
||||
const log = (...a) => console.log("[prod-e2e]", ...a);
|
||||
const fail = (msg) => { console.error("[prod-e2e] ✖", msg); process.exit(1); };
|
||||
|
||||
// ===== 0. 운영 사이트 살아있는지 =====
|
||||
log("0. 운영 사이트 헬스체크");
|
||||
const health = await fetch(`${BASE}/`).catch((e) => ({ ok: false, error: e.message }));
|
||||
if (!health.ok) fail(`운영 사이트 접속 실패: ${health.error || health.status}`);
|
||||
const homeText = await health.text();
|
||||
const hasLanding = /HOW IT WORKS|회원가입/.test(homeText);
|
||||
log(` ${health.status} ${health.url} (랜딩=${hasLanding ? "✔" : "✖ 구버전"})`);
|
||||
if (!hasLanding) fail("운영에 신버전 미배포 — 배포 완료 후 재실행 필요");
|
||||
|
||||
// ===== 1. DB 시드 =====
|
||||
log("1. DB 시드");
|
||||
const db = new pg.Client({ connectionString: DB_URL });
|
||||
await db.connect();
|
||||
|
||||
// 관리자 비밀번호 보장
|
||||
const adminHash = await bcrypt.hash(ADMIN_PASS, 10);
|
||||
await db.query(
|
||||
`UPDATE momo_users SET password_hash = $1, status='ACTIVE' WHERE email = $2`,
|
||||
[adminHash, ADMIN_EMAIL]
|
||||
);
|
||||
|
||||
// 품목 (엑셀 라인업 일부 + 면세/과세 혼합)
|
||||
const seedItems = [
|
||||
{ code: "PROD-EGG-01", name: "M 유정란", price: 10000, taxFree: "Y", stock: 200 },
|
||||
{ code: "PROD-CHKN-01", name: "M 꽃계탕", price: 4500, taxFree: "Y", stock: 200 },
|
||||
{ code: "PROD-SOY-01", name: "M 짜장소스", price: 2600, taxFree: "Y", stock: 500 },
|
||||
{ code: "PROD-CLEAN-R", name: "빨강 탈취제", price: 9200, taxFree: "N", stock: 100 },
|
||||
{ code: "PROD-CLEAN-G", name: "초록 탈취제", price: 9200, taxFree: "N", stock: 100 },
|
||||
{ code: "PROD-CLEAN-B", name: "파랑 탈취제", price: 9200, taxFree: "N", stock: 100 },
|
||||
{ code: "PROD-CRM-01", name: "선크림", price: 5400, taxFree: "N", stock: 80 },
|
||||
{ code: "PROD-MUSH-01", name: "M 꽃송이버섯", price: 4900, taxFree: "Y", stock: 150 },
|
||||
];
|
||||
for (const it of seedItems) {
|
||||
await db.query(
|
||||
`INSERT INTO momo_items (objid, item_code, item_name, unit, unit_price, is_tax_free, status, regdate)
|
||||
VALUES ($1, $2, $3, 'EA', $4, $5, 'ACTIVE', NOW())
|
||||
ON CONFLICT (item_code) DO UPDATE SET unit_price=EXCLUDED.unit_price, is_tax_free=EXCLUDED.is_tax_free, status='ACTIVE', is_del='N'`,
|
||||
[`PROD-${it.code}`, it.code, it.name, it.price, it.taxFree]
|
||||
);
|
||||
}
|
||||
const wh = await db.query(`SELECT objid FROM momo_warehouses WHERE wh_type='STOCK' ORDER BY wh_code LIMIT 1`);
|
||||
if (wh.rowCount === 0) fail("STOCK 창고 없음");
|
||||
const whObjid = wh.rows[0].objid;
|
||||
for (const it of seedItems) {
|
||||
await db.query(
|
||||
`INSERT INTO momo_stocks (objid, wh_objid, item_objid, qty, update_date)
|
||||
VALUES ($1, $2, (SELECT objid FROM momo_items WHERE item_code=$3), $4, NOW())
|
||||
ON CONFLICT (wh_objid, item_objid) DO UPDATE SET qty=EXCLUDED.qty, update_date=NOW()`,
|
||||
[`PROD-STK-${it.code}`, whObjid, it.code, it.stock]
|
||||
);
|
||||
}
|
||||
log(` ✔ 품목 ${seedItems.length}개 + 창고 재고 시드 완료`);
|
||||
|
||||
// ===== 2. 엑셀 → 추가 업체 5개 추출 =====
|
||||
const xlsxPath = path.join(__dirname, "..", "docs", "2026_0410_ 금_라인업.xlsx");
|
||||
const wb = XLSX.readFile(xlsxPath);
|
||||
const two = XLSX.utils.sheet_to_json(wb.Sheets["TWO"], { header: 1, defval: "" });
|
||||
const xlCompanies = two
|
||||
.slice(6)
|
||||
.map((r) => r[0])
|
||||
.filter((c) => typeof c === "string" && c.length >= 2 && c.length < 12 && !/[0-9()]/.test(c));
|
||||
log(` ✔ 엑셀에서 업체 ${xlCompanies.length}개 추출 (예: ${xlCompanies.slice(0, 5).join(", ")})`);
|
||||
|
||||
// ===== 3. 가입할 업체 목록 =====
|
||||
const accounts = [
|
||||
{ email: "chpark@wace.me", company: "박창현유통", password: "wace1234" },
|
||||
...xlCompanies.slice(0, 5).map((c, i) => ({
|
||||
email: `test${i + 1}@momo.test`,
|
||||
company: c,
|
||||
password: "test1234",
|
||||
})),
|
||||
];
|
||||
|
||||
// ===== 4. 가입 + 발주 (각 업체) =====
|
||||
const apiClient = (cookieJar) => async (path, init = {}) => {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(cookieJar.value ? { Cookie: cookieJar.value } : {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
const sc = res.headers.getSetCookie?.() || [];
|
||||
for (const c of sc) {
|
||||
const kv = c.split(";")[0];
|
||||
const k = kv.split("=")[0];
|
||||
cookieJar.value = cookieJar.value
|
||||
? cookieJar.value.split("; ").filter((p) => !p.startsWith(k + "=")).concat(kv).join("; ")
|
||||
: kv;
|
||||
}
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch { json = { raw: text.slice(0, 200) }; }
|
||||
return { status: res.status, body: json };
|
||||
};
|
||||
|
||||
const orderIds = [];
|
||||
for (const acc of accounts) {
|
||||
log(`2.${accounts.indexOf(acc) + 1}. ${acc.company} (${acc.email})`);
|
||||
|
||||
// 기존 데이터 정리 — 재실행 가능하게
|
||||
await db.query(`DELETE FROM momo_order_items WHERE order_objid IN (SELECT objid FROM momo_orders WHERE customer_objid IN (SELECT objid FROM momo_users WHERE email = $1))`, [acc.email]);
|
||||
await db.query(`DELETE FROM momo_orders WHERE customer_objid IN (SELECT objid FROM momo_users WHERE email = $1)`, [acc.email]);
|
||||
await db.query(`DELETE FROM momo_users WHERE email = $1`, [acc.email]);
|
||||
|
||||
const jar = { value: "" };
|
||||
const api = apiClient(jar);
|
||||
const sup = await api("/api/auth/signup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: acc.email, password: acc.password, companyName: acc.company,
|
||||
ceoName: acc.company === "박창현유통" ? "박창현" : acc.company.slice(0, 2),
|
||||
bizNo: "123-45-67890", phone: "010-1234-5678",
|
||||
}),
|
||||
});
|
||||
if (!sup.body.success) fail(`가입 실패 (${acc.email}): ${JSON.stringify(sup.body)}`);
|
||||
log(` ✔ 가입`);
|
||||
|
||||
// 품목 조회
|
||||
const list = await api("/api/m/items/list", { method: "POST", body: JSON.stringify({ keyword: "PROD-" }) });
|
||||
const items = (list.body.RESULTLIST || []).filter((r) => r.ITEM_CODE.startsWith("PROD-"));
|
||||
if (items.length === 0) fail("품목 조회 결과 없음");
|
||||
|
||||
// 발주: 면세 1종 + 과세 1종 (랜덤 수량)
|
||||
const free = items.find((i) => i.IS_TAX_FREE === "Y");
|
||||
const taxable = items.find((i) => i.IS_TAX_FREE === "N");
|
||||
const qty1 = 5 + Math.floor(Math.random() * 10);
|
||||
const qty2 = 3 + Math.floor(Math.random() * 5);
|
||||
const order = await api("/api/m/orders/save", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
lines: [
|
||||
{ itemObjid: free.OBJID, qty: qty1 },
|
||||
{ itemObjid: taxable.OBJID, qty: qty2 },
|
||||
],
|
||||
memo: `프로덕션 E2E 테스트 - ${acc.company}`,
|
||||
}),
|
||||
});
|
||||
if (!order.body.success) fail(`발주 실패: ${JSON.stringify(order.body)}`);
|
||||
log(` ✔ 발주 ${order.body.orderNo} (면세 ${qty1} + 과세 ${qty2})`);
|
||||
orderIds.push({ orderObjid: order.body.objId, orderNo: order.body.orderNo, customer: acc });
|
||||
}
|
||||
|
||||
// ===== 5. 관리자 로그인 + 일괄 승인 =====
|
||||
log(`3. 관리자 로그인`);
|
||||
const adminJar = { value: "" };
|
||||
const adminApi = apiClient(adminJar);
|
||||
const al = await adminApi("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId: ADMIN_EMAIL, password: ADMIN_PASS }),
|
||||
});
|
||||
if (!al.body.success) fail(`관리자 로그인 실패: ${JSON.stringify(al.body)}`);
|
||||
log(` ✔ 관리자 세션`);
|
||||
|
||||
log(`4. 발주 승인 + 메일 발송`);
|
||||
let mailSentCount = 0;
|
||||
for (const o of orderIds) {
|
||||
const r = await adminApi("/api/m/orders/approve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ objid: o.orderObjid }),
|
||||
});
|
||||
if (!r.body.success) {
|
||||
console.error(` ✖ ${o.orderNo} 승인 실패: ${JSON.stringify(r.body)}`);
|
||||
continue;
|
||||
}
|
||||
if (r.body.mailSent) mailSentCount++;
|
||||
log(` ✔ ${o.orderNo} (${o.customer.company}) — 메일=${r.body.mailSent ? "✔" : "✖"} ${r.body.mailError ?? ""}`);
|
||||
}
|
||||
|
||||
// ===== 6. 후속 검증 =====
|
||||
log(`5. 검증`);
|
||||
const finals = await db.query(
|
||||
`SELECT O.order_no, O.status, O.total_amount, U.company_name, U.email,
|
||||
(SELECT status FROM momo_mail_logs WHERE ref_objid = O.objid ORDER BY regdate DESC LIMIT 1) AS mail_status
|
||||
FROM momo_orders O JOIN momo_users U ON O.customer_objid = U.objid
|
||||
WHERE O.regid IS NULL OR O.customer_objid IN (SELECT objid FROM momo_users WHERE email IN (${accounts.map((_, i) => `$${i + 1}`).join(",")}))
|
||||
ORDER BY O.regdate DESC LIMIT 20`,
|
||||
accounts.map((a) => a.email)
|
||||
);
|
||||
console.table(finals.rows.map((r) => ({
|
||||
주문번호: r.order_no, 업체: r.company_name, 이메일: r.email,
|
||||
상태: r.status, 합계: Number(r.total_amount).toLocaleString("ko-KR"),
|
||||
메일: r.mail_status,
|
||||
})));
|
||||
|
||||
log(`✔ E2E 완료 — ${orderIds.length}건 발주 / ${mailSentCount}건 메일 발송`);
|
||||
await db.end();
|
||||
@@ -0,0 +1,52 @@
|
||||
// 자동 배포 webhook — Gitea Actions 가 시크릿 없이 호출
|
||||
// 보안: X-Deploy-Token 헤더 일치 필수 (.env 또는 하드코딩 토큰)
|
||||
// 동작: 백그라운드로 git pull + docker compose up --build 실행
|
||||
// 전제: 컨테이너에 /var/run/docker.sock 마운트 + 소스 디렉토리 마운트 필요
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const DEPLOY_TOKEN = process.env.DEPLOY_WEBHOOK_TOKEN || "momo-deploy-2026-secure";
|
||||
const DEPLOY_SCRIPT = process.env.DEPLOY_SCRIPT || "/deploy/deploy.sh";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = req.headers.get("x-deploy-token") || req.headers.get("X-Deploy-Token");
|
||||
if (token !== DEPLOY_TOKEN) {
|
||||
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const logPath = "/tmp/momo-deploy.log";
|
||||
try {
|
||||
await mkdir(path.dirname(logPath), { recursive: true });
|
||||
await writeFile(logPath, `[${new Date().toISOString()}] deploy 시작\n`);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// 백그라운드 실행 — 응답은 즉시 반환
|
||||
const child = spawn("/bin/sh", ["-c",
|
||||
`(${DEPLOY_SCRIPT} 2>&1 || echo "[deploy.sh 실행 실패 — 호스트에 docker.sock + 스크립트 마운트 필요]") >> ${logPath}`
|
||||
], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "deploy 트리거됨 (백그라운드 실행)",
|
||||
logPath,
|
||||
});
|
||||
}
|
||||
|
||||
// GET — 마지막 배포 로그 조회용 (디버깅)
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.headers.get("x-deploy-token") || new URL(req.url).searchParams.get("token");
|
||||
if (token !== DEPLOY_TOKEN) {
|
||||
return NextResponse.json({ success: false }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
const log = await fs.readFile("/tmp/momo-deploy.log", "utf-8");
|
||||
return new NextResponse(log, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ log: "(로그 없음)", error: msg });
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export function middleware(request: NextRequest) {
|
||||
"/api/auth/login",
|
||||
"/api/auth/signup",
|
||||
"/api/auth/mobile-login",
|
||||
"/api/deploy/webhook",
|
||||
"/_next",
|
||||
"/favicon.ico",
|
||||
"/icon.svg",
|
||||
|
||||
Reference in New Issue
Block a user