This commit is contained in:
44
scripts/ops-safe-restart.sh
Executable file
44
scripts/ops-safe-restart.sh
Executable file
@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR=${APP_DIR:-$(pwd)}
|
||||
PM2_NAME=${PM2_NAME:-whatsapp-inbox-platform}
|
||||
PORT=${PORT:-3002}
|
||||
SKIP_DB=${SKIP_DB:-0}
|
||||
SKIP_HEALTHCHECK=${SKIP_HEALTHCHECK:-0}
|
||||
|
||||
if [ ! -f "$APP_DIR/.env" ]; then
|
||||
echo "[ops-safe-restart] .env file not found in $APP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
echo "[ops-safe-restart] install dependencies"
|
||||
npm ci --no-audit --no-fund
|
||||
|
||||
echo "[ops-safe-restart] run build"
|
||||
npm run build
|
||||
|
||||
if [ "$SKIP_DB" != "1" ]; then
|
||||
echo "[ops-safe-restart] apply prisma migration"
|
||||
npm run db:deploy
|
||||
fi
|
||||
|
||||
if pm2 describe "$PM2_NAME" >/dev/null 2>&1; then
|
||||
echo "[ops-safe-restart] restart existing pm2 process: $PM2_NAME"
|
||||
pm2 restart "$PM2_NAME" --update-env
|
||||
else
|
||||
echo "[ops-safe-restart] start new pm2 process: $PM2_NAME"
|
||||
pm2 start npm --name "$PM2_NAME" -- start -- --port "$PORT"
|
||||
fi
|
||||
|
||||
if [ "$SKIP_HEALTHCHECK" != "1" ]; then
|
||||
echo "[ops-safe-restart] run ops healthcheck"
|
||||
npm run ops:healthcheck
|
||||
fi
|
||||
|
||||
echo "[ops-safe-restart] saving pm2 process list"
|
||||
pm2 save
|
||||
|
||||
echo "[ops-safe-restart] done"
|
||||
189
scripts/ops-session-check.mjs
Normal file
189
scripts/ops-session-check.mjs
Normal file
@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const BASE_URL = [
|
||||
process.env.OPS_BASE_URL,
|
||||
process.env.APP_URL,
|
||||
process.env.NEXT_PUBLIC_APP_URL
|
||||
].map((value) => value?.trim()).find(Boolean);
|
||||
|
||||
const TEST_EMAIL = process.env.OPS_SESSION_CHECK_EMAIL?.trim();
|
||||
const TEST_PASSWORD = process.env.OPS_SESSION_CHECK_PASSWORD?.trim();
|
||||
const EXPECTED_TTL_SECONDS = resolveSessionTtl(process.env.SESSION_TTL_SECONDS);
|
||||
const SESSION_COOKIE_NAME = "wa_inbox_session";
|
||||
|
||||
if (!BASE_URL) {
|
||||
console.error("[ops-session-check] Missing OPS_BASE_URL / APP_URL / NEXT_PUBLIC_APP_URL");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!TEST_EMAIL || !TEST_PASSWORD) {
|
||||
console.error("[ops-session-check] Missing OPS_SESSION_CHECK_EMAIL / OPS_SESSION_CHECK_PASSWORD. Test skipped.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolveSessionTtl(value) {
|
||||
if (!value) {
|
||||
return 60 * 60 * 24 * 7;
|
||||
}
|
||||
const parsed = Number(value.trim());
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 60 * 60 * 24 * 7;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function readCookieLines(headers) {
|
||||
const direct = [];
|
||||
if (typeof headers.getSetCookie === "function") {
|
||||
const lines = headers.getSetCookie();
|
||||
if (Array.isArray(lines)) {
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() === "set-cookie") {
|
||||
direct.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
return direct;
|
||||
}
|
||||
|
||||
function getCookieValue(cookieHeader, name) {
|
||||
const prefix = `${name}=`;
|
||||
const found = cookieHeader.find((entry) => entry.startsWith(prefix));
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [value] = found.split(";")[0].split("=", 2).slice(1);
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
return `${found.split(";")[0].slice(prefix.length)}`;
|
||||
}
|
||||
|
||||
function getCookieTtlSeconds(cookieHeader, name) {
|
||||
const prefix = `${name}=`;
|
||||
const found = cookieHeader.find((entry) => entry.startsWith(prefix));
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = found.split(";").map((part) => part.trim());
|
||||
const attrs = new Map();
|
||||
for (const part of parts.slice(1)) {
|
||||
const [rawKey, rawValue] = part.split("=");
|
||||
attrs.set(rawKey.toLowerCase(), rawValue ?? "");
|
||||
}
|
||||
|
||||
const maxAgeRaw = attrs.get("max-age");
|
||||
if (maxAgeRaw) {
|
||||
const parsed = Number(maxAgeRaw);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
const expiresRaw = attrs.get("expires");
|
||||
if (expiresRaw) {
|
||||
const parsedDate = new Date(expiresRaw);
|
||||
if (!Number.isNaN(parsedDate.getTime())) {
|
||||
return Math.floor((parsedDate.getTime() - Date.now()) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12_000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const loginHeaders = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "ops-session-check"
|
||||
};
|
||||
const loginBody = new URLSearchParams({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
next: "/super-admin"
|
||||
}).toString();
|
||||
|
||||
const loginResponse = await requestJson(`${BASE_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: loginHeaders,
|
||||
body: loginBody
|
||||
});
|
||||
|
||||
if (loginResponse.status !== 307 && loginResponse.status !== 302) {
|
||||
console.error(`[ops-session-check] login status unexpected: ${loginResponse.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const setCookieLines = readCookieLines(loginResponse.headers);
|
||||
const sessionCookieValue = getCookieValue(setCookieLines, SESSION_COOKIE_NAME);
|
||||
if (!sessionCookieValue) {
|
||||
console.error("[ops-session-check] session cookie not issued by /auth/login");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ttl = getCookieTtlSeconds(setCookieLines, SESSION_COOKIE_NAME);
|
||||
if (ttl === null) {
|
||||
console.error("[ops-session-check] session cookie ttl missing");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Math.abs(ttl - EXPECTED_TTL_SECONDS) > 600) {
|
||||
console.warn(
|
||||
`[ops-session-check] session ttl unexpected: ${ttl}s (expected ${EXPECTED_TTL_SECONDS}s ±600s)`
|
||||
);
|
||||
} else {
|
||||
console.log(`[ops-session-check] session ttl check OK: ${ttl}s`);
|
||||
}
|
||||
|
||||
const protected = await requestJson(`${BASE_URL}/super-admin`, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
Cookie: `${SESSION_COOKIE_NAME}=${sessionCookieValue}`
|
||||
}
|
||||
});
|
||||
|
||||
if (protected.status === 200) {
|
||||
console.log("[ops-session-check] protected path access OK (HTTP 200).");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (protected.status >= 300 && protected.status < 400) {
|
||||
const location = protected.headers.get("location") || "";
|
||||
if (location.includes("/login")) {
|
||||
console.error("[ops-session-check] protected path redirected to login; session not accepted.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[ops-session-check] protected path check failed with status ${protected.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[ops-session-check] failed:", error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user