mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
feat(time): server TZ display; optional SSH host sync via root pw
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
RUN apk add --no-cache docker-cli
|
RUN apk add --no-cache docker-cli openssh-client sshpass
|
||||||
|
|
||||||
RUN mkdir -p /data && chmod 700 /data
|
RUN mkdir -p /data && chmod 700 /data
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
|
|||||||
| `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG |
|
| `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG |
|
||||||
| `AWG_PROFILES` | _(нет)_ | JSON-массив профилей: несколько контейнеров/путей (см. ниже). Если задан — переключатель «Инстанс» в вебе |
|
| `AWG_PROFILES` | _(нет)_ | JSON-массив профилей: несколько контейнеров/путей (см. ниже). Если задан — переключатель «Инстанс» в вебе |
|
||||||
| `SCHEDULE_DISCONNECT_MS` | `60000` | Как часто планировщик проверяет отложенное отключение из туннеля (мс) |
|
| `SCHEDULE_DISCONNECT_MS` | `60000` | Как часто планировщик проверяет отложенное отключение из туннеля (мс) |
|
||||||
|
| `DISPLAY_TZ` | _(нет)_ | IANA пояс для строки «Сервер» в UI (например `Asia/Nicosia`), если не хотите полагаться только на `TZ` контейнера |
|
||||||
|
| `TIME_SYNC_SSH_HOST` | `172.17.0.1` | Хост для SSH root при синхронизации времени из панели (часто шлюз Docker к хосту) |
|
||||||
|
| `TIME_SYNC_DISABLED` | `0` | `1` — скрыть/отключить синхронизацию времени по SSH |
|
||||||
| `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла |
|
| `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла |
|
||||||
| `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` |
|
| `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` |
|
||||||
| `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже |
|
| `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже |
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ loginForm.addEventListener("submit", async (ev) => {
|
|||||||
loginPassword.value = "";
|
loginPassword.value = "";
|
||||||
showApp();
|
showApp();
|
||||||
await loadProtocols();
|
await loadProtocols();
|
||||||
|
await loadTimeSyncCaps();
|
||||||
await loadClients();
|
await loadClients();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loginError.textContent = String(e.message || e);
|
loginError.textContent = String(e.message || e);
|
||||||
@@ -269,6 +270,23 @@ const clockFmt = new Intl.DateTimeFormat("ru-RU", {
|
|||||||
timeStyle: "medium",
|
timeStyle: "medium",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Часовой пояс строки «Сервер» (IANA), как в /api/server-time */
|
||||||
|
let serverDisplayTz = "UTC";
|
||||||
|
/** @type {Intl.DateTimeFormat | null} */
|
||||||
|
let serverTzFmtCached = null;
|
||||||
|
|
||||||
|
function buildServerTzFmt(tz) {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "medium",
|
||||||
|
timeZone: tz,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {ReturnType<typeof setInterval> | null} */
|
/** @type {ReturnType<typeof setInterval> | null} */
|
||||||
let clockTickId = null;
|
let clockTickId = null;
|
||||||
/** @type {ReturnType<typeof setInterval> | null} */
|
/** @type {ReturnType<typeof setInterval> | null} */
|
||||||
@@ -276,7 +294,7 @@ let clockServerPollId = null;
|
|||||||
|
|
||||||
/** Метка UTC сервера (мс) по последнему ответу API */
|
/** Метка UTC сервера (мс) по последнему ответу API */
|
||||||
let serverAnchorUtcMs = /** @type {number | null} */ (null);
|
let serverAnchorUtcMs = /** @type {number | null} */ (null);
|
||||||
/** Date.now() в момент установки якоря (компенсация сети только между опросами) */
|
/** Date.now() в момент установки якоря */
|
||||||
let serverAnchorWallMs = 0;
|
let serverAnchorWallMs = 0;
|
||||||
|
|
||||||
function browserTimeZoneLabel() {
|
function browserTimeZoneLabel() {
|
||||||
@@ -296,8 +314,8 @@ function tickServerClockDisplay() {
|
|||||||
const estimatedUtcMs = serverAnchorUtcMs + (Date.now() - serverAnchorWallMs);
|
const estimatedUtcMs = serverAnchorUtcMs + (Date.now() - serverAnchorWallMs);
|
||||||
const d = new Date(estimatedUtcMs);
|
const d = new Date(estimatedUtcMs);
|
||||||
clockServerEl.dateTime = d.toISOString();
|
clockServerEl.dateTime = d.toISOString();
|
||||||
const tz = browserTimeZoneLabel();
|
const fmt = serverTzFmtCached || clockFmt;
|
||||||
clockServerEl.textContent = tz ? `${clockFmt.format(d)} · ${tz}` : clockFmt.format(d);
|
clockServerEl.textContent = `${fmt.format(d)} · ${serverDisplayTz}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshServerClock() {
|
async function refreshServerClock() {
|
||||||
@@ -310,9 +328,13 @@ async function refreshServerClock() {
|
|||||||
}
|
}
|
||||||
serverAnchorUtcMs = parsed;
|
serverAnchorUtcMs = parsed;
|
||||||
serverAnchorWallMs = Date.now();
|
serverAnchorWallMs = Date.now();
|
||||||
|
serverDisplayTz =
|
||||||
|
typeof t.timeZone === "string" && t.timeZone.trim() ? t.timeZone.trim() : "UTC";
|
||||||
|
serverTzFmtCached = buildServerTzFmt(serverDisplayTz);
|
||||||
tickServerClockDisplay();
|
tickServerClockDisplay();
|
||||||
} catch {
|
} catch {
|
||||||
serverAnchorUtcMs = null;
|
serverAnchorUtcMs = null;
|
||||||
|
serverTzFmtCached = null;
|
||||||
clockServerEl.dateTime = "";
|
clockServerEl.dateTime = "";
|
||||||
clockServerEl.textContent = "—";
|
clockServerEl.textContent = "—";
|
||||||
}
|
}
|
||||||
@@ -341,6 +363,8 @@ function stopClocks() {
|
|||||||
}
|
}
|
||||||
serverAnchorUtcMs = null;
|
serverAnchorUtcMs = null;
|
||||||
serverAnchorWallMs = 0;
|
serverAnchorWallMs = 0;
|
||||||
|
serverTzFmtCached = null;
|
||||||
|
serverDisplayTz = "UTC";
|
||||||
clockServerEl.dateTime = "";
|
clockServerEl.dateTime = "";
|
||||||
clockLocalEl.dateTime = "";
|
clockLocalEl.dateTime = "";
|
||||||
clockServerEl.textContent = "—";
|
clockServerEl.textContent = "—";
|
||||||
@@ -355,6 +379,44 @@ function startClocks() {
|
|||||||
clockServerPollId = setInterval(() => void refreshServerClock(), 30_000);
|
clockServerPollId = setInterval(() => void refreshServerClock(), 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTimeSyncCaps() {
|
||||||
|
const hint = document.querySelector("#sync-host-hint");
|
||||||
|
const btn = document.querySelector("#sync-host-time");
|
||||||
|
try {
|
||||||
|
const c = await api("/api/time-sync-capabilities");
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = c.hostTimeSync
|
||||||
|
? `Через SSH на root@${c.sshHost}. Часовой пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.`
|
||||||
|
: `Авто-синхронизация по SSH недоступна (или TIME_SYNC_DISABLED). Пояс «Сервер»: ${c.serverClockTimeZone}. Задайте DISPLAY_TZ или TZ для контейнера панели — см. README.`;
|
||||||
|
}
|
||||||
|
if (btn) btn.disabled = !c.hostTimeSync;
|
||||||
|
} catch {
|
||||||
|
if (hint) hint.textContent = "";
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("#sync-host-time")?.addEventListener("click", async () => {
|
||||||
|
const inp = document.querySelector("#sync-root-pw");
|
||||||
|
const pw = inp && typeof inp.value === "string" ? inp.value : "";
|
||||||
|
if (!pw.trim()) {
|
||||||
|
setStatus("Введите пароль root на хосте.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setStatus("Синхронизация времени хоста…", false);
|
||||||
|
await api("/api/sync-host-time", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ rootPassword: pw, unixMs: Date.now() }),
|
||||||
|
});
|
||||||
|
inp.value = "";
|
||||||
|
setStatus("Запрос выполнен. Проверьте строку «Сервер».", false);
|
||||||
|
void refreshServerClock();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e.message || e), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const dtRu = new Intl.DateTimeFormat("ru-RU", {
|
const dtRu = new Intl.DateTimeFormat("ru-RU", {
|
||||||
dateStyle: "short",
|
dateStyle: "short",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
@@ -545,6 +607,7 @@ async function boot() {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
showApp();
|
showApp();
|
||||||
await loadProtocols();
|
await loadProtocols();
|
||||||
|
await loadTimeSyncCaps();
|
||||||
await loadClients();
|
await loadClients();
|
||||||
} else {
|
} else {
|
||||||
showLogin();
|
showLogin();
|
||||||
|
|||||||
@@ -67,11 +67,20 @@
|
|||||||
<time id="clock-local" class="clock-time">—</time>
|
<time id="clock-local" class="clock-time">—</time>
|
||||||
</div>
|
</div>
|
||||||
<p class="clock-hint muted">
|
<p class="clock-hint muted">
|
||||||
Сервер показывается в вашем часовом поясе (как браузер). «Сверить» — заново запросить момент с VPS.
|
«Сервер» — время в часовом поясе VPS (подпись справа). «Браузер» — ваш компьютер. «Сверить» — заново запросить момент с сервера.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" id="clock-sync" class="btn small ghost clock-sync-btn">
|
<button type="button" id="clock-sync" class="btn small ghost clock-sync-btn">
|
||||||
Сверить время
|
Сверить время
|
||||||
</button>
|
</button>
|
||||||
|
<details class="clock-host-sync">
|
||||||
|
<summary>Синхронизировать часы VPS с этим компьютером</summary>
|
||||||
|
<p id="sync-host-hint" class="muted clock-sync-hint"></p>
|
||||||
|
<label for="sync-root-pw">Пароль root на хосте (не сохраняется)</label>
|
||||||
|
<input id="sync-root-pw" type="password" autocomplete="off" class="clock-sync-input">
|
||||||
|
<button type="button" id="sync-host-time" class="btn small primary clock-sync-submit">
|
||||||
|
Выставить время хоста как у браузера
|
||||||
|
</button>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" id="refresh" class="btn primary">Обновить</button>
|
<button type="button" id="refresh" class="btn primary">Обновить</button>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -268,6 +268,43 @@ h1 {
|
|||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clock-host-sync {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
padding-top: 0.55rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
max-width: 26rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-host-sync summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--accent, #38bdf8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-sync-hint {
|
||||||
|
margin: 0.45rem 0 0.5rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-sync-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 22rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #0a0f16;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-sync-submit {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -101,6 +101,18 @@ if [[ -n "${AWG_PROFILES:-}" ]]; then
|
|||||||
RUN_ENV+=( -e "AWG_PROFILES=${AWG_PROFILES}" )
|
RUN_ENV+=( -e "AWG_PROFILES=${AWG_PROFILES}" )
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${DISPLAY_TZ:-}" ]]; then
|
||||||
|
RUN_ENV+=( -e "DISPLAY_TZ=${DISPLAY_TZ}" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${TIME_SYNC_SSH_HOST:-}" ]]; then
|
||||||
|
RUN_ENV+=( -e "TIME_SYNC_SSH_HOST=${TIME_SYNC_SSH_HOST}" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${TIME_SYNC_DISABLED:-}" ]]; then
|
||||||
|
RUN_ENV+=( -e "TIME_SYNC_DISABLED=${TIME_SYNC_DISABLED}" )
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "${BOOT_PW}" ]]; then
|
if [[ -n "${BOOT_PW}" ]]; then
|
||||||
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
||||||
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then
|
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then
|
||||||
|
|||||||
121
server.js
121
server.js
@@ -461,6 +461,68 @@ function requireDisconnectedAt(raw) {
|
|||||||
return d.toISOString();
|
return d.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Часовой пояс для строки «Сервер» в UI (IANA). Приоритет: DISPLAY_TZ → TZ процесса Node */
|
||||||
|
function resolveServerClockTimeZone() {
|
||||||
|
const override = process.env.DISPLAY_TZ?.trim();
|
||||||
|
if (override) return override;
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||||
|
} catch {
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sshpassBinaryPath() {
|
||||||
|
for (const p of ["/usr/bin/sshpass", "/usr/local/bin/sshpass"]) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(p, fs.constants.X_OK);
|
||||||
|
return p;
|
||||||
|
} catch {
|
||||||
|
/* next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostTimeSyncConfigured() {
|
||||||
|
if (process.env.TIME_SYNC_DISABLED === "1" || process.env.TIME_SYNC_DISABLED === "true") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !!sshpassBinaryPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sshRootRun(password, host, remoteCmd) {
|
||||||
|
const bin = sshpassBinaryPath();
|
||||||
|
if (!bin) {
|
||||||
|
return Promise.reject(new Error("sshpass не установлен"));
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = [
|
||||||
|
"-p",
|
||||||
|
password,
|
||||||
|
"ssh",
|
||||||
|
"-oBatchMode=yes",
|
||||||
|
"-oStrictHostKeyChecking=no",
|
||||||
|
"-oUserKnownHostsFile=/dev/null",
|
||||||
|
"-oConnectTimeout=15",
|
||||||
|
"-oPreferredAuthentications=password",
|
||||||
|
"-oPubkeyAuthentication=no",
|
||||||
|
`root@${host}`,
|
||||||
|
remoteCmd,
|
||||||
|
];
|
||||||
|
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let out = "";
|
||||||
|
let err = "";
|
||||||
|
child.stdout.on("data", (c) => (out += c));
|
||||||
|
child.stderr.on("data", (c) => (err += c));
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code === 0) resolve(out.trim());
|
||||||
|
else reject(new Error(err.trim() || out.trim() || `ssh код ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ensureDataDir();
|
ensureDataDir();
|
||||||
loadOrCreateSessionSecret();
|
loadOrCreateSessionSecret();
|
||||||
bootstrapPassword();
|
bootstrapPassword();
|
||||||
@@ -482,22 +544,67 @@ app.get("/api/session", (req, res) => {
|
|||||||
|
|
||||||
app.get("/api/server-time", requireAuth, (_req, res) => {
|
app.get("/api/server-time", requireAuth, (_req, res) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let timeZone = "UTC";
|
const timeZone = resolveServerClockTimeZone();
|
||||||
|
let formatted;
|
||||||
try {
|
try {
|
||||||
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || timeZone;
|
formatted = now.toLocaleString("ru-RU", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "medium",
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
formatted = now.toLocaleString("ru-RU", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "medium",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
iso: now.toISOString(),
|
iso: now.toISOString(),
|
||||||
formatted: now.toLocaleString("ru-RU", {
|
formatted,
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "medium",
|
|
||||||
}),
|
|
||||||
timeZone,
|
timeZone,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
hostTimeSync: hostTimeSyncConfigured(),
|
||||||
|
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
||||||
|
serverClockTimeZone: resolveServerClockTimeZone(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/sync-host-time", requireAuth, async (req, res) => {
|
||||||
|
if (!hostTimeSyncConfigured()) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error:
|
||||||
|
"Синхронизация времени хоста недоступна (нет sshpass или TIME_SYNC_DISABLED=1).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pw = req.body?.rootPassword;
|
||||||
|
const unixMsRaw = req.body?.unixMs;
|
||||||
|
const unixMs =
|
||||||
|
typeof unixMsRaw === "number" && Number.isFinite(unixMsRaw) ? unixMsRaw : Date.now();
|
||||||
|
if (typeof pw !== "string" || !pw) {
|
||||||
|
return res.status(400).json({ error: "Укажите пароль root VPS" });
|
||||||
|
}
|
||||||
|
const unixSec = Math.floor(unixMs / 1000);
|
||||||
|
if (!Number.isFinite(unixSec)) {
|
||||||
|
return res.status(400).json({ error: "Некорректное время" });
|
||||||
|
}
|
||||||
|
const host = process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1";
|
||||||
|
const remoteCmd = `bash -lc 'date -u --set=@${unixSec} 2>/dev/null || date -s @${unixSec}; (command -v hwclock >/dev/null && hwclock -w --utc) || true; date -u +%Y-%m-%dT%H:%M:%SZ'`;
|
||||||
|
try {
|
||||||
|
const confirmed = await sshRootRun(pw, host, remoteCmd);
|
||||||
|
res.json({ ok: true, utc: confirmed });
|
||||||
|
} catch {
|
||||||
|
console.warn("sync-host-time: ssh не выполнен");
|
||||||
|
res.status(400).json({
|
||||||
|
error:
|
||||||
|
"Не удалось выставить время по SSH. Проверьте пароль root, вход root по паролю на хосте и переменную TIME_SYNC_SSH_HOST (часто 172.17.0.1 с контейнера).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/login", (req, res) => {
|
app.post("/api/login", (req, res) => {
|
||||||
const pw = req.body?.password;
|
const pw = req.body?.password;
|
||||||
if (typeof pw !== "string" || !pw) {
|
if (typeof pw !== "string" || !pw) {
|
||||||
|
|||||||
Reference in New Issue
Block a user