Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 62 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,8 @@ function createDefaultUser(user) {
mainMenuChatId: null,
adminMenuMsgId: null,
adminMenuChatId: null,
ephemeralBonusMsgId: null,
ephemeralBonusChatId: null,
referralCount: 0,
lastReferralAt: 0,
boostExpiresAt: 0, // ms timestamp; if > now, winner gets 2× SC
Expand Down Expand Up @@ -1886,6 +1888,7 @@ function userMainMenuText(user) {
async function sendPersistentUserMenu(ctx, user) {
const chatId = ctx.chat ? ctx.chat.id : (ctx.callbackQuery && ctx.callbackQuery.message ? ctx.callbackQuery.message.chat.id : null);
if (!chatId) return;
await deleteEphemeralBonusPrompt(ctx, user);
// Show admin button only when admin view is ON
const isAdminUser = ADMIN_IDS.includes(Number(user.id)) && user.adminModeOn;

Expand Down Expand Up @@ -2099,6 +2102,62 @@ async function linkedUsernameError(ctx) {
);
}


async function deleteEphemeralBonusPrompt(ctx, user) {
const activePrompt = await runUserMutation(user.id, async () => {
if (!user.ephemeralBonusMsgId || !user.ephemeralBonusChatId) return null;
return { msgId: user.ephemeralBonusMsgId, chatId: user.ephemeralBonusChatId };
});
if (!activePrompt) return;

try {
await ctx.telegram.deleteMessage(activePrompt.chatId, activePrompt.msgId);
} catch (_) { /* message may already be gone */ }

await runUserMutation(user.id, async () => {
if (user.ephemeralBonusMsgId === activePrompt.msgId && user.ephemeralBonusChatId === activePrompt.chatId) {
user.ephemeralBonusMsgId = null;
user.ephemeralBonusChatId = null;
}
});
}

async function sendEphemeralBonusPrompt(ctx, user) {
await deleteEphemeralBonusPrompt(ctx, user);

const sent = await ctx.reply(
`🎁 *Claim Your New User Bonus*

Enter promo code *${promoStore.code}* on the Runewager affiliate page to claim your ${promoStore.amountSC} SC new user bonus!`,
{
parse_mode: 'Markdown',
...Markup.inlineKeyboard([
[Markup.button.url('🎁 Enter Promo Code (Mini App)', LINKS.miniAppClaim)],
[Markup.button.callback('⬅️ Main Menu', 'to_main_menu')],
]),
},
);

await runUserMutation(user.id, async () => {
user.ephemeralBonusMsgId = sent.message_id;
user.ephemeralBonusChatId = sent.chat.id;
});

setTimeout(async () => {
try {
await ctx.telegram.deleteMessage(sent.chat.id, sent.message_id);
} catch (_) { /* best effort */ }

await runUserMutation(user.id, async () => {
if (user.ephemeralBonusMsgId === sent.message_id && user.ephemeralBonusChatId === sent.chat.id) {
user.ephemeralBonusMsgId = null;
user.ephemeralBonusChatId = null;
}
});
}, 15 * 1000);
}


const adminEventsLogFile = path.join(dataDir, 'admin-events.log');
const ADMIN_LOG_CAP = 200;

Expand Down Expand Up @@ -2136,17 +2195,8 @@ async function showOnboardingPrompt(ctx, user, step) {
await sendLinkAccountPrompt(ctx, user);
break;
case 3:
// Promo claim
await ctx.reply(
`🎁 *Claim Your New User Bonus*\n\nEnter promo code *${promoStore.code}* on the Runewager affiliate page to claim your ${promoStore.amountSC} SC new user bonus!`,
{
parse_mode: 'Markdown',
...Markup.inlineKeyboard([
[Markup.button.url('🎁 Enter Promo Code (Mini App)', LINKS.miniAppClaim)],
[Markup.button.callback('⬅️ Main Menu', 'to_main_menu')],
]),
},
);
// Promo claim (auto-removes after 15s to keep chat organized)
await sendEphemeralBonusPrompt(ctx, user);
break;
case 4:
// Join community channel + group
Expand Down Expand Up @@ -4028,6 +4078,7 @@ bot.command('join', async (ctx) => {
bot.action('to_main_menu', async (ctx) => {
const user = getUser(ctx);
await ctx.answerCbQuery();
await deleteEphemeralBonusPrompt(ctx, user);
await sendPersistentUserMenu(ctx, user);
});

Expand Down
114 changes: 110 additions & 4 deletions prod-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,70 @@ say() { printf '[%s] %s\n' "$APP_NAME" "$*"; }
warn() { printf '[%s][warn] %s\n' "$APP_NAME" "$*" >&2; }
err() { printf '[%s][error] %s\n' "$APP_NAME" "$*" >&2; }

read_env_value() {
local key="$1"
[[ -f "$PROJECT_DIR/.env" ]] || return 1
grep -E "^${key}=" "$PROJECT_DIR/.env" | head -1 | cut -d= -f2- | sed "s/^[\'\"]//;s/[\'\"]$//" | tr -d $'
'
'
}

resolve_health_url() {
local port tls_key tls_cert
port="$(read_env_value PORT)"
[[ -z "$port" ]] && port="3000"
tls_key="$(read_env_value HTTPS_KEY_PATH)"
tls_cert="$(read_env_value HTTPS_CERT_PATH)"

if [[ -n "$tls_key" && -n "$tls_cert" && -f "$tls_key" && -f "$tls_cert" ]]; then
printf 'https://127.0.0.1:%s/health' "$port"
else
printf 'http://127.0.0.1:%s/health' "$port"
fi
}

check_health_endpoint() {
local url="$1"
local code
if [[ "$url" == https://* ]]; then
code="$(curl -k -s -o /dev/null -w "%{http_code}" --max-time 5 "$url" 2>/dev/null || echo 000)"
else
code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$url" 2>/dev/null || echo 000)"
fi
[[ "$code" == "200" ]]
}

wait_for_health() {
local url="$1"
local attempts="${2:-15}"
local sleep_sec="${3:-2}"
local i
for ((i=1; i<=attempts; i++)); do
if check_health_endpoint "$url"; then
return 0
fi
sleep "$sleep_sec"
done
return 1
}

is_port_listening() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn "sport = :${port}" 2>/dev/null | awk 'NR>1 {found=1} END {exit(found ? 0 : 1)}'
else
return 1
fi
}

if id -u "$APP_NAME" >/dev/null 2>&1; then
chown -R "$APP_NAME:$APP_NAME" "$LOG_DIR" "$PROJECT_DIR/data"
chmod 0750 "$PROJECT_DIR/data" "$PROJECT_DIR/data/backups" "$LOG_DIR"
chmod 0640 "$MAIN_LOG" "$ERROR_LOG" "$ADMIN_EVENTS_LOG" "$SSHV_SESSIONS_FILE"
else
warn "System user '$APP_NAME' not found — using $(id -un):$(id -gn)"
fi

say "Starting production runner"
say "Project directory: $PROJECT_DIR"
say "Running as: $(id -un) (uid=$(id -u) gid=$(id -g))"
Expand All @@ -47,9 +111,15 @@ else
fi

# ---------------------------------------------------------
# 2) Runtime directories & log files
mkdir -p "$LOG_DIR" "$PROJECT_DIR/data" "$PROJECT_DIR/data/backups"
touch "$MAIN_LOG" "$ERROR_LOG" "$ADMIN_EVENTS_LOG" "$SSHV_SESSIONS_FILE"
# 2) Ensure required runtime directories & log files exist
# (logs/ and data/ are in .gitignore so they won't exist
# on a fresh clone — we always create them here)
# ---------------------------------------------------------
mkdir -p "$LOG_DIR"
mkdir -p "$PROJECT_DIR/data"
mkdir -p "$PROJECT_DIR/data/backups"
touch "$MAIN_LOG" "$ERROR_LOG" "$ADMIN_EVENTS_LOG" "$SSHV_SESSIONS_FILE" # create if missing; safe on existing files

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The data directory and JSON/log files are created as root-owned by this script, but the systemd unit is configured to run the Node process as an unprivileged user; this will cause permission denied errors when the app tries to write admin events and SSHV session state. Ensuring the data directory is owned by the service user when it exists fixes this mismatch. [logic error]

Severity Level: Major ⚠️
- ❌ SSHV sessions cannot persist to data/sshv-sessions.json.
- ❌ Admin events log file not writable; audit trail lost.
- ⚠️ Data directory permissions may block other JSON persist helpers.
Suggested change
# Ensure data directory and its files are writable by the service user when it exists
if id -u "$APP_NAME" >/dev/null 2>&1; then
chown -R "$APP_NAME":"$APP_NAME" "$PROJECT_DIR/data"
fi
Steps of Reproduction ✅
1. Create a dedicated system user for the service so `id -u runewager` succeeds; this
causes `ensure_systemd_service()` in `prod-run.sh` (lines 187–201) to generate a unit with
`User=runewager`/`Group=runewager`.

2. As root, run `/workspace/Runewager/prod-run.sh`. It creates `$PROJECT_DIR/data` and
`$PROJECT_DIR/data/backups`, then touches `$PROJECT_DIR/data/admin-events.log` and
`$PROJECT_DIR/data/sshv-sessions.json` as root at `prod-run.sh:113–116`, leaving the
directory and files owned by root with default 0755/0644-style permissions.

3. The same script restarts the service via systemd at `prod-run.sh:385–412`, so
`index.js` now runs as the unprivileged `runewager` user while the `data/` directory and
its files remain root-owned and not writable by `runewager`.

4. Trigger an admin feature that persists state: (a) use the `/sshv` admin console so its
session code calls `saveJson(sshvSessionsFile, entries)` at `index.js:969` with
`sshvSessionsFile` defined at `index.js:206`, which goes through `writeFileAtomic()` at
`index.js:2264–10`, or (b) execute any admin action that calls `adminLog()` at
`index.js:2141–24` to append to `admin-events.log`. In both cases the Node process,
running as `runewager`, attempts to write under `$PROJECT_DIR/data` but lacks write
permission due to root ownership, causing `fs.writeFileSync`/`fs.appendFileSync` to
receive EACCES and preventing SSHV session and admin event data from being persisted.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** prod-run.sh
**Line:** 117:117
**Comment:**
	*Logic Error: The data directory and JSON/log files are created as root-owned by this script, but the systemd unit is configured to run the Node process as an unprivileged user; this will cause permission denied errors when the app tries to write admin events and SSHV session state. Ensuring the data directory is owned by the service user when it exists fixes this mismatch.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

say "Ensured runtime directories and log files exist"

# ---------------------------------------------------------
Expand Down Expand Up @@ -264,4 +334,40 @@ if [[ -n "$TELEGRAM_BOT_TOKEN" && -n "$ADMIN_IDS" ]]; then
done
fi

say "✅ Production runner + God-Mode Heal complete"
SYSTEMD_ACTIVE="$(systemctl is-active "${APP_NAME}.service" 2>/dev/null || echo inactive)"
HEALTH_URL="$(resolve_health_url)"
HEALTH_PORT="${HEALTH_URL#*://127.0.0.1:}"
HEALTH_PORT="${HEALTH_PORT%%/*}"

if wait_for_health "$HEALTH_URL" 20 2; then
HEALTH_STATUS="healthy"
else
HEALTH_STATUS="unhealthy"
fi

if is_port_listening "$HEALTH_PORT"; then
PORT_STATUS="listening"
else
PORT_STATUS="not-listening"
fi

say "✔ Systemd service: ${SYSTEMD_ACTIVE}"
say "✔ Health URL: ${HEALTH_URL}"
say "✔ Health endpoint: ${HEALTH_STATUS}"
say "✔ Port ${HEALTH_PORT}: ${PORT_STATUS}"
say "✔ Logrotate installed: $(command -v logrotate >/dev/null 2>&1 && echo yes || echo no)"
say "✔ Logs directory: $LOG_DIR"
say "✔ Main log: $MAIN_LOG"
say "✔ Error log: $ERROR_LOG"

if [[ "$HEALTH_STATUS" != "healthy" ]]; then
warn "Health endpoint check failed after retries; dumping last 50 log lines"
echo "---- Last 50 lines of $MAIN_LOG ----"
tail -n 50 "$MAIN_LOG" 2>/dev/null || true
echo "---- Last 50 lines of $ERROR_LOG ----"
tail -n 50 "$ERROR_LOG" 2>/dev/null || true
fi

echo "--------------------------------------------------"
say "Production runner complete"
echo "--------------------------------------------------"
Loading