mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(cli): code-review-2 — I1/I2/M1/M2
I1 (logs): implementar filtragem runtime das 7 flags registradas mas ignoradas:
--request-id, --api-key, --combo, --status, --duration-min, --duration-max,
--export. Filtragem client-side via buildLogFilter(); --export grava jsonl.
I2 (keys): adicionar isServerUp() check + try/catch nos 8 comandos que chamavam
apiFetch diretamente sem proteção: regenerate/revoke/reveal/usage/policy-show/
policy-set/expiration-list/rotate. Garante exit code 1 com mensagem amigável
quando servidor offline.
M1 (tunnel): substituir string hardcoded inglesa em runTunnelStopCommand linha 151
por t("tunnel.typeRequired").
M2 (logs): substituir "Log stream stopped." e "Log stream error: ..." por t()
calls; adicionar logs.stopped / logs.streamError / logs.exported a en.json e
pt-BR.json.
This commit is contained in:
@@ -344,17 +344,26 @@ export async function runKeysRegenerateCommand(id, opts = {}) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/regenerate`, {
|
||||
method: "POST",
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
if (!(await isServerUp())) {
|
||||
console.error(t("common.serverOffline"));
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/regenerate`, {
|
||||
method: "POST",
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
console.log(t("keys.regenerated", { key: data.key || data.apiKey || "(see dashboard)" }));
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
console.log(t("keys.regenerated", { key: data.key || data.apiKey || "(see dashboard)" }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runKeysRevokeCommand(id, opts = {}) {
|
||||
@@ -370,78 +379,114 @@ export async function runKeysRevokeCommand(id, opts = {}) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/revoke`, {
|
||||
method: "POST",
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
if (!(await isServerUp())) {
|
||||
console.error(t("common.serverOffline"));
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/revoke`, {
|
||||
method: "POST",
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
console.log(t("keys.revoked", { id }));
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
console.log(t("keys.revoked", { id }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runKeysRevealCommand(id, opts = {}) {
|
||||
process.stderr.write(t("keys.revealWarning") + "\n");
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/reveal`, {
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
if (!(await isServerUp())) {
|
||||
console.error(t("common.serverOffline"));
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/reveal`, {
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
console.log(data.key || data.apiKey || "(not available)");
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
console.log(data.key || data.apiKey || "(not available)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runKeysUsageCommand(id, opts = {}) {
|
||||
const limit = opts.limit || "20";
|
||||
const res = await apiFetch(
|
||||
`/api/v1/registered-keys/${encodeURIComponent(id)}/usage?limit=${limit}`,
|
||||
{ retry: false }
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
if (!(await isServerUp())) {
|
||||
console.error(t("common.serverOffline"));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
const rows = data.usage || data.requests || data.items || [];
|
||||
if (rows.length === 0) {
|
||||
console.log(t("keys.noUsage"));
|
||||
const limit = opts.limit || "20";
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/api/v1/registered-keys/${encodeURIComponent(id)}/usage?limit=${limit}`,
|
||||
{ retry: false }
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
const rows = data.usage || data.requests || data.items || [];
|
||||
if (rows.length === 0) {
|
||||
console.log(t("keys.noUsage"));
|
||||
return 0;
|
||||
}
|
||||
for (const r of rows) {
|
||||
const ts = r.timestamp || r.createdAt || "";
|
||||
const path = r.path || r.endpoint || "";
|
||||
const status = r.status || r.statusCode || "";
|
||||
console.log(` ${ts} ${String(status).padEnd(4)} ${path}`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
for (const r of rows) {
|
||||
const ts = r.timestamp || r.createdAt || "";
|
||||
const path = r.path || r.endpoint || "";
|
||||
const status = r.status || r.statusCode || "";
|
||||
console.log(` ${ts} ${String(status).padEnd(4)} ${path}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runKeysPolicyShowCommand(id, opts = {}) {
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/policy`, {
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
if (!(await isServerUp())) {
|
||||
console.error(t("common.serverOffline"));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (opts.output === "json" || opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
try {
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/policy`, {
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (opts.output === "json" || opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
return 0;
|
||||
}
|
||||
console.log(t("keys.policy.title") + ` (${id}):`);
|
||||
console.log(` rate_limit: ${data.rateLimit ?? data.rate_limit ?? "(unset)"}`);
|
||||
console.log(` max_cost: ${data.maxCost ?? data.max_cost ?? "(unset)"}`);
|
||||
console.log(
|
||||
` allowed_models: ${(data.allowedModels ?? data.allowed_models ?? []).join(", ") || "(all)"}`
|
||||
);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
console.log(t("keys.policy.title") + ` (${id}):`);
|
||||
console.log(` rate_limit: ${data.rateLimit ?? data.rate_limit ?? "(unset)"}`);
|
||||
console.log(` max_cost: ${data.maxCost ?? data.max_cost ?? "(unset)"}`);
|
||||
console.log(
|
||||
` allowed_models: ${(data.allowedModels ?? data.allowed_models ?? []).join(", ") || "(all)"}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runKeysPolicySetCommand(id, opts = {}) {
|
||||
@@ -454,47 +499,64 @@ export async function runKeysPolicySetCommand(id, opts = {}) {
|
||||
console.error(t("keys.policy.nothingToSet"));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/policy`, {
|
||||
method: "PATCH",
|
||||
body,
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
if (!(await isServerUp())) {
|
||||
console.error(t("common.serverOffline"));
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/policy`, {
|
||||
method: "PATCH",
|
||||
body,
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
console.log(t("keys.policy.updated"));
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
console.log(t("keys.policy.updated"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runKeysExpirationListCommand(opts = {}) {
|
||||
const days = Number(opts.days || 30);
|
||||
const res = await apiFetch(`/api/v1/registered-keys?expiring=true&days=${days}`, {
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
if (!(await isServerUp())) {
|
||||
console.error(t("common.serverOffline"));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
const rows = data.keys || data.items || data;
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
console.log(t("keys.expiration.none", { days }));
|
||||
const days = Number(opts.days || 30);
|
||||
try {
|
||||
const res = await apiFetch(`/api/v1/registered-keys?expiring=true&days=${days}`, {
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
const rows = data.keys || data.items || data;
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
console.log(t("keys.expiration.none", { days }));
|
||||
return 0;
|
||||
}
|
||||
if (opts.json || opts.output === "json") {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
return 0;
|
||||
}
|
||||
console.log(t("keys.expiration.listTitle", { days }));
|
||||
for (const k of rows) {
|
||||
const exp = k.expiresAt || k.expires_at || "(unknown)";
|
||||
console.log(` ${(k.id || "").padEnd(24)} ${(k.name || "").padEnd(20)} expires: ${exp}`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
if (opts.json || opts.output === "json") {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
return 0;
|
||||
}
|
||||
console.log(t("keys.expiration.listTitle", { days }));
|
||||
for (const k of rows) {
|
||||
const exp = k.expiresAt || k.expires_at || "(unknown)";
|
||||
console.log(` ${(k.id || "").padEnd(24)} ${(k.name || "").padEnd(20)} expires: ${exp}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runKeysRotateCommand(id, opts = {}) {
|
||||
@@ -510,20 +572,28 @@ export async function runKeysRotateCommand(id, opts = {}) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await isServerUp())) {
|
||||
console.error(t("common.serverOffline"));
|
||||
return 1;
|
||||
}
|
||||
const gracePeriod = Number(opts.gracePeriod || 60000);
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/rotate`, {
|
||||
method: "POST",
|
||||
body: { gracePeriodMs: gracePeriod },
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
try {
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/rotate`, {
|
||||
method: "POST",
|
||||
body: { gracePeriodMs: gracePeriod },
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
const newId = data.newKeyId || data.id || "(see dashboard)";
|
||||
console.log(t("keys.rotated", { id, newId }));
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
const newId = data.newKeyId || data.id || "(see dashboard)";
|
||||
console.log(t("keys.rotated", { id, newId }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { printInfo, printError } from "../io.mjs";
|
||||
import { writeFileSync, appendFileSync, existsSync, unlinkSync } from "node:fs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
export function registerLogs(program) {
|
||||
@@ -24,18 +24,66 @@ export function registerLogs(program) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildLogFilter(opts) {
|
||||
const levelFilters = opts.filter ? opts.filter.split(",").map((f) => f.trim()) : [];
|
||||
const requestId = opts.requestId;
|
||||
const apiKey = opts.apiKey;
|
||||
const combo = opts.combo;
|
||||
const statusFilter = opts.status != null ? String(opts.status) : null;
|
||||
const durationMin = opts.durationMin != null ? Number(opts.durationMin) : null;
|
||||
const durationMax = opts.durationMax != null ? Number(opts.durationMax) : null;
|
||||
|
||||
return function matchesLog(parsed) {
|
||||
if (levelFilters.length > 0) {
|
||||
const level = String(parsed.level || "info").toLowerCase();
|
||||
if (!levelFilters.includes(level)) return false;
|
||||
}
|
||||
if (requestId) {
|
||||
const rid = String(parsed.requestId || parsed.request_id || "");
|
||||
if (!rid.includes(requestId)) return false;
|
||||
}
|
||||
if (apiKey) {
|
||||
const key = String(parsed.apiKey || parsed.api_key || parsed.key || "");
|
||||
if (!key.includes(apiKey)) return false;
|
||||
}
|
||||
if (combo) {
|
||||
const c = String(parsed.combo || parsed.comboName || parsed.combo_name || "");
|
||||
if (!c.includes(combo)) return false;
|
||||
}
|
||||
if (statusFilter) {
|
||||
const s = String(parsed.status || parsed.statusCode || parsed.status_code || "");
|
||||
if (!s.startsWith(statusFilter)) return false;
|
||||
}
|
||||
if (durationMin != null) {
|
||||
const d = Number(parsed.duration || parsed.durationMs || parsed.latency || 0);
|
||||
if (d < durationMin) return false;
|
||||
}
|
||||
if (durationMax != null) {
|
||||
const d = Number(parsed.duration || parsed.durationMs || parsed.latency || 0);
|
||||
if (d > durationMax) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export async function runLogsCommand(opts = {}) {
|
||||
const baseUrl = opts.baseUrl || opts["base-url"] || "http://localhost:20128";
|
||||
const follow = opts.follow ?? false;
|
||||
const filter = opts.filter ?? "";
|
||||
const lines = opts.lines || "100";
|
||||
const timeout = parseInt(String(opts.timeout || "30000"), 10);
|
||||
const isJson = opts.output === "json";
|
||||
const exportPath = opts.export;
|
||||
|
||||
const filters = filter ? filter.split(",").map((f) => f.trim()) : [];
|
||||
// Prepare export file
|
||||
if (exportPath && existsSync(exportPath)) {
|
||||
unlinkSync(exportPath);
|
||||
}
|
||||
|
||||
const matchesLog = buildLogFilter(opts);
|
||||
// Pass only level filters to the stream (server-side); other filters are client-side
|
||||
const levelFilters = opts.filter ? opts.filter.split(",").map((f) => f.trim()) : [];
|
||||
|
||||
const { createLogStream } = await import("../../../src/lib/cli-helper/log-streamer.js");
|
||||
const { stream, stop } = createLogStream({ baseUrl, filters, follow, timeout });
|
||||
const { stream, stop } = createLogStream({ baseUrl, filters: levelFilters, follow, timeout });
|
||||
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -43,21 +91,43 @@ export async function runLogsCommand(opts = {}) {
|
||||
|
||||
const processLine = (line) => {
|
||||
if (!line.trim()) return;
|
||||
if (isJson) {
|
||||
console.log(line);
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
// Non-JSON line: only include if no structured filters active
|
||||
if (
|
||||
opts.requestId ||
|
||||
opts.apiKey ||
|
||||
opts.combo ||
|
||||
opts.status ||
|
||||
opts.durationMin != null ||
|
||||
opts.durationMax != null
|
||||
)
|
||||
return;
|
||||
if (exportPath) appendFileSync(exportPath, line + "\n", "utf8");
|
||||
else console.log(line);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
const level = parsed.level || "info";
|
||||
const ts = parsed.timestamp || new Date().toISOString();
|
||||
const msg = parsed.message || JSON.stringify(parsed);
|
||||
const prefix =
|
||||
{ error: "\x1b[31m[ERR]", warn: "\x1b[33m[WRN]", info: "\x1b[36m[INF]" }[level] || "[INF]";
|
||||
console.log(`${prefix}\x1b[0m ${ts} ${msg}`);
|
||||
} catch {
|
||||
console.log(line);
|
||||
|
||||
if (!matchesLog(parsed)) return;
|
||||
|
||||
if (exportPath) {
|
||||
appendFileSync(exportPath, JSON.stringify(parsed) + "\n", "utf8");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(parsed));
|
||||
return;
|
||||
}
|
||||
|
||||
const level = parsed.level || "info";
|
||||
const ts = parsed.timestamp || new Date().toISOString();
|
||||
const msg = parsed.message || JSON.stringify(parsed);
|
||||
const prefix =
|
||||
{ error: "\x1b[31m[ERR]", warn: "\x1b[33m[WRN]", info: "\x1b[36m[INF]" }[level] || "[INF]";
|
||||
console.log(`${prefix}\x1b[0m ${ts} ${msg}`);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -70,11 +140,16 @@ export async function runLogsCommand(opts = {}) {
|
||||
for (const line of parts) processLine(line);
|
||||
}
|
||||
if (buffer) processLine(buffer);
|
||||
if (exportPath) console.log(t("logs.exported", { path: exportPath }));
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") {
|
||||
printInfo("Log stream stopped.");
|
||||
console.log(t("logs.stopped"));
|
||||
} else {
|
||||
printError(`Log stream error: ${err.message}`);
|
||||
console.error(
|
||||
t("logs.streamError", {
|
||||
message: (err instanceof Error ? err.message : String(err)).slice(0, 100),
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
stop();
|
||||
|
||||
@@ -148,7 +148,7 @@ export async function runTunnelCreateCommand(type = "cloudflare", opts = {}) {
|
||||
|
||||
export async function runTunnelStopCommand(type, opts = {}) {
|
||||
if (!type) {
|
||||
console.error("Tunnel type required. Valid: " + VALID_TUNNEL_TYPES.join(", "));
|
||||
console.error(t("tunnel.typeRequired"));
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1177,7 +1177,10 @@
|
||||
"status": "Filter by HTTP status code",
|
||||
"durationMin": "Min request duration in ms",
|
||||
"durationMax": "Max request duration in ms",
|
||||
"export": "Save logs to file (json/jsonl/csv)"
|
||||
"export": "Save logs to file (json/jsonl/csv)",
|
||||
"exported": "Logs saved to {path}",
|
||||
"stopped": "Log stream stopped.",
|
||||
"streamError": "Log stream error: {message}"
|
||||
},
|
||||
"tray": {
|
||||
"description": "Control the system tray icon",
|
||||
|
||||
@@ -1177,7 +1177,10 @@
|
||||
"status": "Filtrar por código HTTP",
|
||||
"durationMin": "Duração mínima do request em ms",
|
||||
"durationMax": "Duração máxima do request em ms",
|
||||
"export": "Salvar logs em arquivo (json/jsonl/csv)"
|
||||
"export": "Salvar logs em arquivo (json/jsonl/csv)",
|
||||
"exported": "Logs salvos em {path}",
|
||||
"stopped": "Stream de logs interrompido.",
|
||||
"streamError": "Erro no stream de logs: {message}"
|
||||
},
|
||||
"tray": {
|
||||
"description": "Controlar o ícone na bandeja do sistema",
|
||||
|
||||
Reference in New Issue
Block a user