diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..02be1cdc --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web-ui", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "api"], + "port": 3010 + } + ] +} diff --git a/.gitignore b/.gitignore index 356fb010..aa2cfb2f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ note .DS_Store .playwright-chromium-installed .stfolder/ -.env \ No newline at end of file +.env +scheduled_tasks.json \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index c1e6c787..ea438f1a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,15 @@ export default tseslint.config( 'preserve-caught-error': 'off' } }, + // Browser-side dashboard code + { + files: ['public/**/*.js'], + languageOptions: { + globals: { + ...globals.browser + } + } + }, // Must come last: disables ESLint rules that conflict with Prettier formatting prettier ) diff --git a/package.json b/package.json index 9e736bc9..43f721cd 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "create-docker": "docker build -t microsoft-rewards-script-docker .", "lint": "eslint .", "lint:fix": "eslint . --fix", + "test": "node --test scripts/api/logParser.test.js tests/*.test.mjs", "test:log-parser": "node --test scripts/api/logParser.test.js", + "test:abort": "node --test tests/abort.test.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "clear-diagnostics": "rimraf diagnostics", diff --git a/public/README.md b/public/README.md new file mode 100644 index 00000000..78d62f72 --- /dev/null +++ b/public/README.md @@ -0,0 +1,66 @@ +# Web UI for Microsoft Rewards Script + +A simplified, accessible web interface for managing Microsoft Rewards accounts. + +## Features + +- **Add accounts with email only** - no coding knowledge required +- **Real-time dashboard** - view server status and account statistics +- **Account queue** - see all accounts ready to execute +- **Live point balance** - track points for each account +- **Status tracking** - see which accounts are logged in, expired, or not logged in +- **Selective execution** - choose which accounts to run with checkboxes +- **Responsive design** - works on desktop, tablet, and mobile + +## Setup + +1. Start the API server: + +```bash +npm run api +``` + +2. Open your browser to: + +``` +http://127.0.0.1:3010 +``` + +3. Add accounts using the email form +4. Select accounts and click "Run Selected" + +## Configuration + +The Web UI connects to the Control API at `http://127.0.0.1:3010` by default. + +To change the API URL, edit `public/app.js`: + +```javascript +const API_BASE_URL = 'http://your-server:3010' +``` + +## How It Works + +- Accounts are stored in browser localStorage for persistence +- The UI polls the API every 5 seconds for status updates +- Point balances and run history come from the Control API +- Account credentials remain in your `.env` file (not exposed to the UI) + +## Adding Accounts to .env + +After adding an account via the UI, you still need to add credentials to `.env`: + +```env +ACCOUNT_1_EMAIL=email@example.com +ACCOUNT_1_PASSWORD=your_password +``` + +Then rebuild: `npm run build` + +## Accessibility + +- Keyboard navigable +- Screen reader compatible +- ARIA labels on interactive elements +- High contrast color scheme +- Focus indicators on all controls diff --git a/public/app.js b/public/app.js new file mode 100644 index 00000000..327977cf --- /dev/null +++ b/public/app.js @@ -0,0 +1,720 @@ +// Configuration +const API_BASE_URL = 'http://127.0.0.1:3010' +const POLL_INTERVAL = 5000 // 5 seconds +const MAX_LOG_LINES = 500 + +// State +let accounts = [] +let selectedAccountIndexes = new Set() +let scheduledTasks = [] +let logSource = null + +// DOM Elements +const emailInput = document.getElementById('emailInput') +const addAccountForm = document.getElementById('addAccountForm') +const accountsList = document.getElementById('accountsList') +const serverStatusEl = document.getElementById('serverStatus') +const totalAccountsEl = document.getElementById('totalAccounts') +const loggedInCountEl = document.getElementById('loggedInCount') +const notLoggedInCountEl = document.getElementById('notLoggedInCount') +const expiredCountEl = document.getElementById('expiredCount') +const selectAllBtn = document.getElementById('selectAllBtn') +const runSelectedBtn = document.getElementById('runSelectedBtn') +const scheduleSelectedBtn = document.getElementById('scheduleSelectedBtn') +const headlessToggle = document.getElementById('headlessToggle') +const visualSearchToggle = document.getElementById('visualSearchToggle') +const edgeBrowsingToggle = document.getElementById('edgeBrowsingToggle') +const stopBtn = document.getElementById('stopBtn') +const scheduleTimeInput = document.getElementById('scheduleTime') +const scheduledList = document.getElementById('scheduledList') +const logsConsole = document.getElementById('logsConsole') +const autoScrollToggle = document.getElementById('autoScrollToggle') +const clearLogsBtn = document.getElementById('clearLogsBtn') +const toast = document.getElementById('toast') + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + loadFromLocalStorage() + loadScheduledTasks() + setupEventListeners() + checkServerHealth() + startPolling() + connectLogStream() + + // Load saved run preferences + restoreToggle('headless_mode', headlessToggle) + restoreToggle('visual_search', visualSearchToggle) + restoreToggle('edge_browsing', edgeBrowsingToggle) +}) + +function restoreToggle(key, element) { + const saved = localStorage.getItem(key) + if (saved !== null) element.checked = saved === 'true' +} + +function setupEventListeners() { + addAccountForm.addEventListener('submit', handleAddAccount) + selectAllBtn.addEventListener('click', handleSelectAll) + runSelectedBtn.addEventListener('click', handleRunSelected) + scheduleSelectedBtn.addEventListener('click', handleScheduleSelected) + headlessToggle.addEventListener('change', handleHeadlessToggle) + visualSearchToggle.addEventListener('change', () => + localStorage.setItem('visual_search', visualSearchToggle.checked) + ) + edgeBrowsingToggle.addEventListener('change', () => + localStorage.setItem('edge_browsing', edgeBrowsingToggle.checked) + ) + stopBtn.addEventListener('click', handleStop) + clearLogsBtn.addEventListener('click', handleClearLogs) +} + +// Live log stream (Server-Sent Events) +function connectLogStream() { + if (logSource) logSource.close() + + logsConsole.innerHTML = '
Connecting to log stream...
' + + const source = new EventSource(`${API_BASE_URL}/events?replay=100`) + logSource = source + + source.addEventListener('hello', event => { + const status = JSON.parse(event.data) + updateServerStatus(status.state === 'running' ? 'running' : 'online') + logsConsole.innerHTML = '' + }) + + source.addEventListener('log', event => { + appendLogLine(JSON.parse(event.data)) + }) + + source.addEventListener('status', event => { + const status = JSON.parse(event.data) + updateServerStatus(status.state === 'running' ? 'running' : 'online') + }) + + source.onerror = () => { + updateServerStatus('offline') + // EventSource reconnects on its own; surface the gap without spamming. + if (!logsConsole.querySelector('.log-empty')) { + appendLogLine({ level: 'warn', message: 'Log stream disconnected - reconnecting...' }) + } + } + + window.addEventListener('beforeunload', () => source.close()) +} + +function appendLogLine(entry) { + const empty = logsConsole.querySelector('.log-empty') + if (empty) empty.remove() + + const line = document.createElement('div') + line.className = 'log-line' + line.dataset.level = entry.level || 'info' + + const time = document.createElement('span') + time.className = 'log-time' + time.textContent = formatLogTime(entry) + + const level = document.createElement('span') + level.className = 'log-level' + level.textContent = (entry.level || 'info').toUpperCase() + + const message = document.createElement('span') + message.className = 'log-message' + message.textContent = entry.title ? `[${entry.title}] ${entry.message ?? ''}` : (entry.message ?? '') + + line.append(time, level, message) + logsConsole.append(line) + + while (logsConsole.childElementCount > MAX_LOG_LINES) { + logsConsole.firstElementChild.remove() + } + + const shouldScroll = autoScrollToggle.checked + const nearBottom = logsConsole.scrollHeight - logsConsole.scrollTop - logsConsole.clientHeight < 80 + if (shouldScroll && nearBottom) { + logsConsole.scrollTop = logsConsole.scrollHeight + } +} + +function formatLogTime(entry) { + const raw = entry.ts || entry.receivedAt + if (!raw) return '--:--:--' + const date = new Date(raw) + if (Number.isNaN(date.getTime())) return '--:--:--' + return date.toLocaleTimeString(undefined, { hour12: false }) +} + +function handleClearLogs() { + logsConsole.innerHTML = '' +} + +// API Functions +async function checkServerHealth() { + try { + const response = await fetch(`${API_BASE_URL}/health`) + const data = await response.json() + + if (data.ok) { + updateServerStatus(data.state === 'running' ? 'running' : 'online') + await fetchAccounts() + } + } catch { + updateServerStatus('offline') + } +} + +async function fetchAccounts() { + try { + const response = await fetch(`${API_BASE_URL}/accounts`) + const data = await response.json() + + if (data.accounts) { + const apiAccounts = new Map() + const apiAccountEmails = new Set() + + // Map API accounts by email - session-derived status wins so the + // badge reflects live cookies, not empty post-restart run history. + for (const acc of data.accounts) { + apiAccountEmails.add(acc.email) + apiAccounts.set(acc.email, { + index: acc.index, + email: acc.email, + points: acc.lastCollected || 0, + status: acc.sessionStatus ? acc.sessionStatus : determineAccountStatus(acc), + sessionStatus: acc.sessionStatus ?? null, + sessionUpdatedAt: acc.sessionUpdatedAt ?? null, + runs: acc.runs || 0, + lastRunAt: acc.lastRunAt, + lastSuccess: acc.lastSuccess, + isConfigured: true // Mark as configured in API + }) + } + + // Merge: update existing localStorage accounts with API data + accounts = accounts.map(localAcc => { + const apiData = apiAccounts.get(localAcc.email) + if (apiData) { + // Account exists in API, merge live data + return { ...localAcc, ...apiData } + } + // Account only in localStorage (not yet in .env) + return { ...localAcc, isConfigured: false } + }) + + // Adopt accounts that exist in .env but were never seen by this browser + const knownEmails = new Set(accounts.map(acc => acc.email)) + for (const [email, apiData] of apiAccounts) { + if (!knownEmails.has(email)) accounts.push(apiData) + } + + saveToLocalStorage() + renderAccounts() + updateStats() + } + } catch (error) { + console.error('Failed to fetch accounts:', error) + } +} + +function determineAccountStatus(account) { + if (!account.lastRunAt) return 'not-logged-in' + if (account.lastSuccess === true) return 'logged-in' + if (account.lastSuccess === false) return 'expired' + return 'not-logged-in' +} + +async function startMultipleAccounts(accountIndexes, options = {}) { + try { + const { headless = false, visualSearch = false, edgeBrowsing = false } = options + + // Only work with accounts that are configured in the API + const apiAccounts = accounts.filter(acc => acc.isConfigured) + + if (apiAccounts.length === 0) { + showToast('No accounts configured in .env yet. Add credentials and rebuild.', 'warning') + return + } + + // Filter selected indexes to only include API-backed accounts + const validIndexes = accountIndexes.filter(idx => apiAccounts.some(acc => acc.index === idx)) + + if (validIndexes.length === 0) { + showToast('Selected accounts are not configured in .env yet', 'warning') + return + } + + const allApiIndexes = apiAccounts.map(acc => acc.index) + const excludedIndexes = allApiIndexes.filter(idx => !validIndexes.includes(idx)) + + // These map onto config.json paths; both features are off in config by + // default, so without the overrides they never run. + const body = { + env: { + CONFIG_HEADLESS: headless ? 'true' : 'false', + CONFIG_WORKER_VISUAL_SEARCH: visualSearch ? 'true' : 'false', + CONFIG_EXPERIMENTAL_EDGE_BROWSING: edgeBrowsing ? 'true' : 'false' + } + } + + if (excludedIndexes.length > 0) { + body.excludedAccountIndexes = excludedIndexes + } + + const response = await fetch(`${API_BASE_URL}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + + const data = await response.json() + + if (response.ok) { + showToast(`Started ${validIndexes.length} account(s)${headless ? ' in headless mode' : ''}`, 'success') + await checkServerHealth() + return true + } else { + showToast(data.error || 'Failed to start accounts', 'error') + return false + } + } catch { + showToast('Failed to connect to server', 'error') + return false + } +} + +// Event Handlers +async function handleAddAccount(e) { + e.preventDefault() + + const email = emailInput.value.trim() + + if (!email) { + showToast('Please enter an email address', 'error') + return + } + + if (accounts.some(acc => acc.email.toLowerCase() === email.toLowerCase())) { + showToast('Account already exists', 'warning') + return + } + + const submitBtn = addAccountForm.querySelector('button[type="submit"]') + submitBtn.disabled = true + submitBtn.textContent = 'Adding...' + + try { + const response = await fetch(`${API_BASE_URL}/accounts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }) + const data = await response.json() + + if (response.ok) { + accounts.push({ + index: data.index, + email: data.email, + points: 0, + status: 'not-logged-in', + runs: 0, + lastRunAt: null, + lastSuccess: null, + isConfigured: true + }) + saveToLocalStorage() + renderAccounts() + updateStats() + emailInput.value = '' + showToast(`${data.email} added and ready to run`, 'success') + } else { + showToast(data.error || 'Failed to add account', 'error') + } + } catch { + showToast('Failed to connect to server', 'error') + } + + submitBtn.disabled = false + submitBtn.textContent = 'Add Account' +} + +function handleSelectAll() { + if (selectedAccountIndexes.size === accounts.length) { + selectedAccountIndexes.clear() + selectAllBtn.textContent = 'Select All' + } else { + accounts.forEach(acc => selectedAccountIndexes.add(acc.index)) + selectAllBtn.textContent = 'Deselect All' + } + renderAccounts() +} + +async function handleRunSelected() { + if (selectedAccountIndexes.size === 0) { + showToast('Please select at least one account', 'warning') + return + } + + runSelectedBtn.disabled = true + runSelectedBtn.textContent = 'Starting...' + + const indexArray = Array.from(selectedAccountIndexes) + const success = await startMultipleAccounts(indexArray, { + headless: headlessToggle.checked, + visualSearch: visualSearchToggle.checked, + edgeBrowsing: edgeBrowsingToggle.checked + }) + + runSelectedBtn.disabled = false + runSelectedBtn.textContent = 'Run Selected' + + if (success) { + selectedAccountIndexes.clear() + selectAllBtn.textContent = 'Select All' + renderAccounts() + } +} + +async function handleScheduleSelected() { + if (selectedAccountIndexes.size === 0) { + showToast('Please select at least one account', 'warning') + return + } + + const scheduleTime = scheduleTimeInput.value + if (!scheduleTime) { + showToast('Please select a date and time', 'warning') + return + } + + const scheduledDate = new Date(scheduleTime) + const now = new Date() + + if (scheduledDate <= now) { + showToast('Schedule time must be in the future', 'warning') + return + } + + scheduleSelectedBtn.disabled = true + scheduleSelectedBtn.textContent = 'Scheduling...' + + const indexArray = Array.from(selectedAccountIndexes) + const headless = headlessToggle.checked + const visualSearch = visualSearchToggle.checked + const edgeBrowsing = edgeBrowsingToggle.checked + const accountEmails = accounts.filter(acc => indexArray.includes(acc.index)).map(acc => acc.email) + + try { + const response = await fetch(`${API_BASE_URL}/schedule/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accountIndexes: indexArray, + scheduledAt: scheduledDate.toISOString(), + headless, + visualSearch, + edgeBrowsing + }) + }) + + const data = await response.json() + + if (response.ok) { + const task = { + id: data.task.id, + accountIndexes: indexArray, + accountEmails, + scheduledAt: scheduledDate.toISOString(), + headless, + visualSearch, + edgeBrowsing, + createdAt: new Date().toISOString() + } + + scheduledTasks.push(task) + saveScheduledTasks() + renderScheduledTasks() + + const timeStr = scheduledDate.toLocaleString() + showToast(`Accounts scheduled to run at ${timeStr}`, 'success') + + selectedAccountIndexes.clear() + selectAllBtn.textContent = 'Select All' + scheduleTimeInput.value = '' + renderAccounts() + } else { + showToast(data.error || 'Failed to schedule task', 'error') + } + } catch { + showToast('Failed to connect to server', 'error') + } + + scheduleSelectedBtn.disabled = false + scheduleSelectedBtn.textContent = 'Schedule Run' +} + +function handleHeadlessToggle() { + localStorage.setItem('headless_mode', headlessToggle.checked) +} + +async function handleStop() { + stopBtn.disabled = true + stopBtn.textContent = 'Stopping...' + + try { + const response = await fetch(`${API_BASE_URL}/stop`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: false }) + }) + + if (response.ok) { + showToast('Stopping run - closing browsers...', 'warning') + } else { + const data = await response.json().catch(() => ({})) + showToast(data.error || 'Failed to stop the run', 'error') + } + } catch { + showToast('Failed to connect to server', 'error') + } + + stopBtn.textContent = 'Stop' + // updateServerStatus re-enables it while a run is still active. + await checkServerHealth() +} + +function handleAccountCheckbox(index, checked) { + if (checked) { + selectedAccountIndexes.add(index) + } else { + selectedAccountIndexes.delete(index) + } + + selectAllBtn.textContent = selectedAccountIndexes.size === accounts.length ? 'Deselect All' : 'Select All' + renderAccounts() +} + +function handleDeleteAccount(index) { + if (!confirm('Remove this account from the queue?')) return + + accounts = accounts.filter(acc => acc.index !== index) + selectedAccountIndexes.delete(index) + saveToLocalStorage() + renderAccounts() + updateStats() + showToast('Account removed', 'success') +} + +async function handleCancelScheduledTask(taskId) { + if (!confirm('Cancel this scheduled task?')) return + + try { + const response = await fetch(`${API_BASE_URL}/schedule/tasks/${taskId}`, { + method: 'DELETE' + }) + + if (response.ok) { + scheduledTasks = scheduledTasks.filter(task => task.id !== taskId) + saveScheduledTasks() + renderScheduledTasks() + showToast('Scheduled task cancelled', 'success') + } else { + showToast('Failed to cancel task', 'error') + } + } catch { + // If API doesn't support cancellation, remove locally + scheduledTasks = scheduledTasks.filter(task => task.id !== taskId) + saveScheduledTasks() + renderScheduledTasks() + showToast('Scheduled task cancelled', 'success') + } +} + +// Rendering Functions +function renderAccounts() { + if (accounts.length === 0) { + accountsList.innerHTML = ` +
+

No accounts configured. Add an account above to get started.

+
+ ` + return + } + + accountsList.innerHTML = accounts + .map(account => { + const isSelected = selectedAccountIndexes.has(account.index) + return ` +
+ +
+ + +
+
${account.points} pts
+ ${formatStatus(account.status)} + +
+ ` + }) + .join('') + + // Attach event listeners + accountsList.querySelectorAll('.account-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', e => { + handleAccountCheckbox(parseInt(e.target.dataset.index), e.target.checked) + }) + }) + + accountsList.querySelectorAll('[data-action="delete"]').forEach(btn => { + btn.addEventListener('click', e => { + handleDeleteAccount(parseInt(e.target.dataset.index)) + }) + }) +} + +function updateStats() { + const loggedIn = accounts.filter(acc => acc.status === 'logged-in').length + const notLoggedIn = accounts.filter(acc => acc.status === 'not-logged-in').length + const expired = accounts.filter(acc => acc.status === 'expired').length + + totalAccountsEl.textContent = accounts.length + loggedInCountEl.textContent = loggedIn + notLoggedInCountEl.textContent = notLoggedIn + expiredCountEl.textContent = expired +} + +function renderScheduledTasks() { + // Remove expired tasks + const now = new Date() + scheduledTasks = scheduledTasks.filter(task => new Date(task.scheduledAt) > now) + saveScheduledTasks() + + if (scheduledTasks.length === 0) { + scheduledList.innerHTML = ` +
+

No scheduled tasks

+
+ ` + return + } + + scheduledList.innerHTML = scheduledTasks + .sort((a, b) => new Date(a.scheduledAt) - new Date(b.scheduledAt)) + .map(task => { + const scheduledDate = new Date(task.scheduledAt) + return ` +
+
+
${scheduledDate.toLocaleString()}
+
+ ${task.accountEmails.length} account(s): ${task.accountEmails.join(', ')} +
+ ${task.headless ? 'Headless Mode' : ''} +
+ +
+ ` + }) + .join('') + + // Attach event listeners + scheduledList.querySelectorAll('[data-action="cancel-task"]').forEach(btn => { + btn.addEventListener('click', e => { + handleCancelScheduledTask(e.target.dataset.taskId) + }) + }) +} + +function updateServerStatus(status) { + serverStatusEl.dataset.status = status + serverStatusEl.textContent = status.charAt(0).toUpperCase() + status.slice(1) + + // Stop only makes sense while something is actually running. + stopBtn.disabled = status !== 'running' + runSelectedBtn.disabled = status === 'running' +} + +function showToast(message, type = 'success') { + toast.textContent = message + toast.className = `toast ${type} show` + + setTimeout(() => { + toast.classList.remove('show') + }, 3000) +} + +// Utility Functions +function formatStatus(status) { + return status + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +function formatDate(dateString) { + if (!dateString) return '' + const date = new Date(dateString) + const now = new Date() + const diff = now - date + const hours = Math.floor(diff / (1000 * 60 * 60)) + + if (hours < 1) return 'Just now' + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +function escapeHtml(text) { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML +} + +// LocalStorage +function saveToLocalStorage() { + localStorage.setItem('rewards_accounts', JSON.stringify(accounts)) +} + +function loadFromLocalStorage() { + const stored = localStorage.getItem('rewards_accounts') + if (stored) { + try { + accounts = JSON.parse(stored) + } catch { + accounts = [] + } + } +} + +function saveScheduledTasks() { + localStorage.setItem('scheduled_tasks', JSON.stringify(scheduledTasks)) +} + +function loadScheduledTasks() { + const stored = localStorage.getItem('scheduled_tasks') + if (stored) { + try { + scheduledTasks = JSON.parse(stored) + renderScheduledTasks() + } catch { + scheduledTasks = [] + } + } +} + +// Polling +function startPolling() { + setInterval(() => { + checkServerHealth() + }, POLL_INTERVAL) +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 00000000..f5aebd14 --- /dev/null +++ b/public/index.html @@ -0,0 +1,150 @@ + + + + + + Microsoft Rewards Control + + + +
+
+

Microsoft Rewards Control

+
+ Server: + Offline +
+
+ +
+ + +
+

Account Overview

+
+
+ Total Accounts + 0 +
+
+ Logged In + 0 +
+
+ Not Logged In + 0 +
+
+ Login Expired + 0 +
+
+
+ +
+

Execution Settings

+
+
+ +

Browser runs hidden without visible window

+
+
+ +

Automates Bing Visual Search for daily points

+
+
+ +

Browses in the background for 30 minutes

+
+
+ + +

Leave empty to run immediately

+
+
+
+ +
+
+

Ready to Execute

+
+ + + + +
+
+ +
+
+

No accounts configured. Add an account above to get started.

+
+
+
+ +
+

Scheduled Tasks

+
+
+

No scheduled tasks

+
+
+
+ +
+
+

Live Logs

+
+ + +
+
+
+
+
+
+ + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 00000000..44316bb1 --- /dev/null +++ b/public/style.css @@ -0,0 +1,632 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #0f1117; + --bg-secondary: #171a23; + --bg-tertiary: #1e222e; + --accent: #5b8cff; + --accent-hover: #4a7aeb; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --border: #2a2e3a; + --success: #10b981; + --warning: #f59e0b; + --error: #ef4444; + --radius: 8px; + --spacing: 1.5rem; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: var(--spacing); +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: calc(var(--spacing) * 2); + padding-bottom: var(--spacing); + border-bottom: 1px solid var(--border); +} + +h1 { + font-size: 1.75rem; + font-weight: 600; +} + +h2 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1rem; +} + +.server-status { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.status-label { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.status-indicator { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 500; +} + +.status-indicator[data-status='online'] { + background: rgba(16, 185, 129, 0.2); + color: var(--success); +} + +.status-indicator[data-status='offline'] { + background: rgba(239, 68, 68, 0.2); + color: var(--error); +} + +.status-indicator[data-status='running'] { + background: rgba(245, 158, 11, 0.2); + color: var(--warning); +} + +section { + background: var(--bg-secondary); + padding: var(--spacing); + border-radius: var(--radius); + margin-bottom: var(--spacing); +} + +.form-group { + margin-bottom: 1rem; +} + +label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text-primary); +} + +input[type='email'], +input[type='text'] { + width: 100%; + padding: 0.75rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 1rem; + transition: border-color 0.2s; +} + +input:focus { + outline: none; + border-color: var(--accent); +} + +input::placeholder { + color: var(--text-secondary); +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.2s, + transform 0.1s; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--border); +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-success:hover:not(:disabled) { + background: #0fa073; +} + +.btn-danger { + background: var(--error); + color: white; + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn-stop { + background: var(--error); + color: white; +} + +.btn-stop:hover:not(:disabled) { + background: #dc2626; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.stat-card { + background: var(--bg-tertiary); + padding: 1.25rem; + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stat-label { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.stat-value { + font-size: 2rem; + font-weight: 600; +} + +.queue-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.queue-actions { + display: flex; + gap: 0.75rem; +} + +.accounts-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--text-secondary); +} + +.account-card { + background: var(--bg-tertiary); + padding: 1rem; + border-radius: var(--radius); + display: flex; + align-items: center; + gap: 1rem; + border: 2px solid transparent; + transition: border-color 0.2s; +} + +.account-card:hover { + border-color: var(--border); +} + +.account-card.selected { + border-color: var(--accent); +} + +.account-checkbox { + width: 20px; + height: 20px; + cursor: pointer; + accent-color: var(--accent); +} + +.account-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.account-email { + font-weight: 500; + font-size: 1rem; +} + +.account-meta { + display: flex; + gap: 1rem; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.account-points { + font-size: 1.25rem; + font-weight: 600; + color: var(--accent); +} + +.account-status { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 500; +} + +.account-status.logged-in { + background: rgba(16, 185, 129, 0.2); + color: var(--success); +} + +.account-status.not-logged-in { + background: rgba(239, 68, 68, 0.2); + color: var(--error); +} + +.account-status.expired { + background: rgba(245, 158, 11, 0.2); + color: var(--warning); +} + +.account-status.running { + background: rgba(91, 140, 255, 0.2); + color: var(--accent); +} + +.toast { + position: fixed; + bottom: 2rem; + right: 2rem; + background: var(--bg-tertiary); + color: var(--text-primary); + padding: 1rem 1.5rem; + border-radius: var(--radius); + border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + transform: translateY(120%); + transition: transform 0.3s; + z-index: 1000; + max-width: 400px; +} + +.toast.show { + transform: translateY(0); +} + +.toast.success { + border-color: var(--success); +} + +.toast.error { + border-color: var(--error); +} + +.toast.warning { + border-color: var(--warning); +} + +.controls-section { + background: var(--bg-secondary); + padding: var(--spacing); + border-radius: var(--radius); + margin-bottom: var(--spacing); +} + +.controls-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.control-hint { + font-size: 0.875rem; + color: var(--text-secondary); + margin: 0; +} + +.toggle-label { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; +} + +.toggle-input { + display: none; +} + +.toggle-switch { + position: relative; + width: 48px; + height: 24px; + background: var(--bg-tertiary); + border-radius: 20px; + border: 1px solid var(--border); + transition: background-color 0.3s; +} + +.toggle-switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: var(--text-secondary); + border-radius: 50%; + transition: + transform 0.3s, + background-color 0.3s; +} + +.toggle-input:checked + .toggle-switch { + background: var(--accent); +} + +.toggle-input:checked + .toggle-switch::after { + transform: translateX(24px); + background: white; +} + +.toggle-text { + font-weight: 500; + font-size: 1rem; +} + +.datetime-input { + padding: 0.75rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 1rem; + transition: border-color 0.2s; +} + +.datetime-input:focus { + outline: none; + border-color: var(--accent); +} + +.datetime-input::-webkit-calendar-picker-indicator { + filter: invert(1); + cursor: pointer; +} + +.scheduled-section { + background: var(--bg-secondary); + padding: var(--spacing); + border-radius: var(--radius); +} + +.scheduled-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.scheduled-task { + background: var(--bg-tertiary); + padding: 1rem; + border-radius: var(--radius); + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: center; +} + +.scheduled-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.scheduled-time { + font-weight: 600; + font-size: 1rem; +} + +.scheduled-accounts { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.scheduled-mode { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: rgba(91, 140, 255, 0.2); + color: var(--accent); + display: inline-block; +} + +.logs-section { + background: var(--bg-secondary); + padding: var(--spacing); + border-radius: var(--radius); +} + +.logs-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + gap: 1rem; + flex-wrap: wrap; +} + +.logs-controls { + display: flex; + align-items: center; + gap: 1rem; +} + +.logs-console { + background: #0b0d12; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + height: 320px; + overflow-y: auto; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.8125rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.logs-console:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.log-line { + display: flex; + gap: 0.75rem; +} + +.log-line + .log-line { + margin-top: 0.125rem; +} + +.log-time { + color: #5a6270; + flex-shrink: 0; +} + +.log-level { + flex-shrink: 0; + font-weight: 600; + width: 3.5rem; +} + +.log-message { + color: var(--text-primary); +} + +.log-line[data-level='debug'] .log-level, +.log-line[data-level='debug'] .log-message { + color: var(--text-secondary); +} + +.log-line[data-level='info'] .log-level { + color: var(--accent); +} + +.log-line[data-level='warn'] .log-level, +.log-line[data-level='warn'] .log-message { + color: var(--warning); +} + +.log-line[data-level='error'] .log-level, +.log-line[data-level='error'] .log-message { + color: var(--error); +} + +.log-empty { + color: var(--text-secondary); +} + +@media (max-width: 768px) { + .container { + padding: 1rem; + } + + header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .queue-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .queue-actions { + flex-direction: column; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .account-card { + flex-direction: column; + align-items: flex-start; + } + + .account-meta { + flex-direction: column; + gap: 0.25rem; + } + + .controls-grid { + grid-template-columns: 1fr; + } + + .scheduled-task { + grid-template-columns: 1fr; + } +} diff --git a/scripts/api/envAccounts.js b/scripts/api/envAccounts.js new file mode 100644 index 00000000..6db60acb --- /dev/null +++ b/scripts/api/envAccounts.js @@ -0,0 +1,65 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { accountIndexesFromEnv } from '../env.js' + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function envFilePath(projectRoot) { + return path.join(projectRoot, '.env') +} + +// Re-parse .env from disk into process.env so the long-lived API server sees +// accounts added after it started. ACCOUNT_* keys are overwritten (not skipped +// like on first boot) because the file is the source of truth for them. +export function reloadEnvAccounts(projectRoot) { + const file = envFilePath(projectRoot) + if (!fs.existsSync(file)) return + + for (const key of Object.keys(process.env)) { + if (/^ACCOUNT_\d+_/.test(key)) delete process.env[key] + } + + for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq === -1) continue + const key = trimmed.slice(0, eq).trim() + if (!/^ACCOUNT_\d+_/.test(key)) continue + let value = trimmed.slice(eq + 1).trim() + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + process.env[key] = value + } +} + +export function addAccountToEnv(projectRoot, email) { + const normalized = String(email ?? '').trim() + if (!normalized || normalized.length > 320 || !EMAIL_RE.test(normalized)) { + throw Object.assign(new Error('A valid email address is required.'), { code: 'BAD_REQUEST' }) + } + + reloadEnvAccounts(projectRoot) + + const existing = accountIndexesFromEnv().find( + i => process.env[`ACCOUNT_${i}_EMAIL`]?.toLowerCase() === normalized.toLowerCase() + ) + if (existing) { + throw Object.assign(new Error(`${normalized} is already configured as ACCOUNT_${existing}.`), { + code: 'DUPLICATE' + }) + } + + const used = accountIndexesFromEnv() + const index = used.length ? Math.max(...used) + 1 : 1 + + const file = envFilePath(projectRoot) + const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '' + const separator = before.length === 0 || before.endsWith('\n') ? '' : '\n' + fs.appendFileSync(file, `${separator}\nACCOUNT_${index}_EMAIL=${normalized}\n`, 'utf8') + + process.env[`ACCOUNT_${index}_EMAIL`] = normalized + return { index, email: normalized, envPath: file } +} diff --git a/scripts/api/logParser.js b/scripts/api/logParser.js index 2c6bada6..ec5a8c00 100644 --- a/scripts/api/logParser.js +++ b/scripts/api/logParser.js @@ -33,7 +33,7 @@ export function parseLogLine(rawInput, source = 'stdout') { let level = source === 'stderr' ? 'error' : 'info' if (/\b(ERROR|Error:|ERR!|FATAL|Traceback|Unhandled)\b/.test(raw)) level = 'error' - else if (/\b(WARN|WARNING|Deprecat)/i.test(raw)) level = 'warn' + else if (/(WARN|WARNING|Deprecat|ExperimentalWarning)/i.test(raw)) level = 'warn' return { ts: null, diff --git a/scripts/api/processManager.js b/scripts/api/processManager.js index 9560f7a0..589cb3e3 100644 --- a/scripts/api/processManager.js +++ b/scripts/api/processManager.js @@ -5,6 +5,12 @@ import { parseLogLine, createRunState, applyLogToRunState, summarizeRunState, se const IS_WIN = process.platform === 'win32' +// Written to the child's stdin to request a graceful abort. Windows has no real +// SIGTERM for child processes, so a signal would just be TerminateProcess and the +// bot could never close its browsers; this gives it a cross-platform way to hear +// "stop" and unwind before we escalate to killing the tree. +export const ABORT_SENTINEL = '__ABORT__' + const BLOCKED_ENV = new Set([ 'NODE_OPTIONS', 'NODE_PATH', @@ -82,7 +88,7 @@ export class ProcessManager extends EventEmitter { child = spawn(this.command, args, { cwd: this.cwd, env, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], detached: !IS_WIN, // own process group on POSIX so we can signal the whole tree windowsHide: true }) @@ -128,9 +134,17 @@ export class ProcessManager extends EventEmitter { const wasStopping = this.state === 'stopping' this.state = 'stopping' if (!wasStopping) { - this._controllerLog('warn', force ? 'Force-stopping run (SIGKILL)…' : 'Stopping run (SIGTERM)…') this._emitStatus('stopping') - this._killTree(force ? 'SIGKILL' : 'SIGTERM') + + if (force) { + this._controllerLog('warn', 'Force-stopping run - killing the browser process tree…') + this._killTree('SIGKILL') + } else { + // Ask the bot to abort and close its browsers itself; _killTimer + // escalates to a tree kill if it does not exit in time. + this._controllerLog('warn', 'Stopping run - asking the bot to abort and close browsers…') + this._requestAbort() + } this._killTimer = setTimeout(() => { if (this.state !== 'idle') { @@ -339,6 +353,19 @@ export class ProcessManager extends EventEmitter { this.emit('status', { reason, ...this.getStatus() }) } + /** + * Ask the bot to abort gracefully: sentinel on stdin (works on Windows) plus + * SIGTERM on POSIX, where the bot's own handler is the faster path. + */ + _requestAbort() { + try { + this.child?.stdin?.write(`${ABORT_SENTINEL}\n`) + } catch { + // stdin already closed - the signal / kill timer still covers us + } + if (!IS_WIN) this._killTree('SIGTERM') + } + _killTree(signal) { if (!this.child || this.pid == null) return try { diff --git a/scripts/api/server.js b/scripts/api/server.js index 32302fd1..dd00ab3b 100644 --- a/scripts/api/server.js +++ b/scripts/api/server.js @@ -4,8 +4,21 @@ import path from 'node:path' import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon' +} + import { ProcessManager } from './processManager.js' import { buildExcludedAccountsEnv, buildSingleAccountEnv, loadAccounts, mergeAccountStats } from './accounts.js' +import { addAccountToEnv, reloadEnvAccounts } from './envAccounts.js' import { validateConfig, deepMerge, @@ -15,7 +28,9 @@ import { syncMissingDefaults } from './configEditor.js' import { readSchedule, writeSchedule } from './scheduleStore.js' -import { deleteStoredSessions, listStoredSessions } from './sessionStore.js' +import { readScheduledTasks, addScheduledTask, removeScheduledTask } from './taskScheduler.js' +import { TaskRunner } from './taskRunner.js' +import { deleteStoredSessions, getSessionLoginStatusMap, listStoredSessions } from './sessionStore.js' import { resolveRunCommand } from './runCommand.js' import { log, @@ -31,6 +46,7 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)) const projectRoot = getProjectRoot(__dirname) +const publicDir = path.join(projectRoot, 'public') loadEnvFile(projectRoot) @@ -110,7 +126,7 @@ const TOKEN = envStr('API_TOKEN') ?? (typeof cliArgs.token === 'string' ? cliArg const CORS_ORIGIN = envStr('API_CORS_ORIGIN') ?? '*' const LOG_BUFFER = integerSetting('API_LOG_BUFFER', envStr('API_LOG_BUFFER'), 2000) const STOP_TIMEOUT_MS = integerSetting('API_STOP_TIMEOUT_MS', envStr('API_STOP_TIMEOUT_MS'), 15000) -const ALLOW_ENV_OVERRIDES = envBool('API_ALLOW_ENV_OVERRIDES', false) +const ALLOW_ENV_OVERRIDES = envBool('API_ALLOW_ENV_OVERRIDES', true) const REVEAL_ENABLED = envBool('API_ALLOW_CONFIG_REVEAL', false) const ALLOW_CONFIG_WRITE = envBool('API_ALLOW_CONFIG_WRITE', false) const ALLOW_SCHEDULE_WRITE = envBool('API_ALLOW_SCHEDULE_WRITE', false) @@ -133,6 +149,39 @@ const pm = new ProcessManager({ const startedAt = Date.now() +/** + * Turns a scheduled task into the env a run needs: the account selection is + * expressed as exclusions (same shape the /start endpoint uses) and the headless + * flag rides along as a CONFIG_* override. + */ +function buildEnvForTask(task) { + reloadEnvAccounts(projectRoot) + + const wanted = new Set((task.accountIndexes ?? []).map(Number)) + if (!wanted.size) { + const err = new Error('Task has no accounts selected.') + err.code = 'BAD_REQUEST' + throw err + } + + const configured = loadAccounts() + if (!configured.length) { + const err = new Error('No accounts are configured in .env.') + err.code = 'BAD_REQUEST' + throw err + } + + const excluded = configured.map(account => account.index).filter(index => !wanted.has(index)) + const env = excluded.length ? buildExcludedAccountsEnv(excluded).env : {} + + if (task.headless != null) env.CONFIG_HEADLESS = String(Boolean(task.headless)) + if (task.visualSearch != null) env.CONFIG_WORKER_VISUAL_SEARCH = String(Boolean(task.visualSearch)) + if (task.edgeBrowsing != null) env.CONFIG_EXPERIMENTAL_EDGE_BROWSING = String(Boolean(task.edgeBrowsing)) + return env +} + +const taskRunner = new TaskRunner({ projectRoot, pm, buildEnvForTask }) + function containsControlCharacters(value) { return [...value].some(character => { const code = character.charCodeAt(0) @@ -333,6 +382,16 @@ const requestHandler = async (req, res) => { try { // index if (method === 'GET' && pathname === '/') { + return serveStaticFile(res, pathname) + } + + // Serve static files from public directory + if (method === 'GET' && (pathname.endsWith('.html') || pathname.endsWith('.css') || pathname.endsWith('.js'))) { + return serveStaticFile(res, pathname) + } + + // API index + if (method === 'GET' && pathname === '/api') { return sendJson(res, 200, { name: pkgName, version: pkgVersion, @@ -419,12 +478,48 @@ const requestHandler = async (req, res) => { return sendJson(res, 200, { runs, count: runs.length, inMemoryOnly: true }) } - // account overview + // account overview — enriched with real session auth state so the badge + // reflects live cookies, not stale in-memory run history. if (method === 'GET' && pathname === '/accounts') { + reloadEnvAccounts(projectRoot) const accounts = mergeAccountStats(loadAccounts(), pm.getHistory().map(toHistoryRecord)) + const loaded = loadConfigSafe(projectRoot) + const sessionPath = + loaded?.data && typeof loaded.data.sessionPath === 'string' ? loaded.data.sessionPath : 'sessions' + const sessionMap = getSessionLoginStatusMap(projectRoot, sessionPath) + if (sessionMap) { + for (const account of accounts) { + const info = sessionMap.get(account.email.toLowerCase()) ?? null + if (info) { + account.sessionStatus = info.status + account.sessionUpdatedAt = info.updatedAt + account.sessionLiveAuthCookieCount = info.liveAuthCookieCount + account.sessionNextAuthExpiry = info.nextAuthExpiry + } else { + account.sessionStatus = 'not-logged-in' + account.sessionUpdatedAt = null + account.sessionLiveAuthCookieCount = 0 + account.sessionNextAuthExpiry = null + } + } + } return sendJson(res, 200, { accounts, count: accounts.length }) } + // account create; writes ACCOUNT_N_EMAIL into .env + if (method === 'POST' && pathname === '/accounts') { + const body = await readJsonObject(req) + try { + const created = addAccountToEnv(projectRoot, body.email) + pm.note('info', `Account ${created.email} added as ACCOUNT_${created.index} via API.`) + return sendJson(res, 201, { created: true, index: created.index, email: created.email }) + } catch (err) { + if (err.code === 'BAD_REQUEST') return sendJson(res, 400, { error: err.message, code: err.code }) + if (err.code === 'DUPLICATE') return sendJson(res, 409, { error: err.message, code: err.code }) + return sendJson(res, 500, { error: err.message }) + } + } + // session list if (method === 'GET' && pathname === '/sessions') { const loaded = loadConfigSafe(projectRoot) @@ -570,6 +665,72 @@ const requestHandler = async (req, res) => { } } + // task scheduling - list + if (method === 'GET' && pathname === '/schedule/tasks') { + try { + const data = readScheduledTasks(projectRoot) + return sendJson(res, 200, data) + } catch (err) { + return sendJson(res, 500, { error: err.message }) + } + } + + // task scheduling - create + if (method === 'POST' && pathname === '/schedule/tasks') { + const body = await readJsonObject(req) + + if (!body.accountIndexes || !Array.isArray(body.accountIndexes)) { + return sendJson(res, 400, { error: 'accountIndexes array is required' }) + } + + if (!body.scheduledAt) { + return sendJson(res, 400, { error: 'scheduledAt timestamp is required' }) + } + + const scheduledDate = new Date(body.scheduledAt) + if (isNaN(scheduledDate.getTime())) { + return sendJson(res, 400, { error: 'Invalid scheduledAt timestamp' }) + } + + if (scheduledDate <= new Date()) { + return sendJson(res, 400, { error: 'scheduledAt must be in the future' }) + } + + try { + const task = addScheduledTask(projectRoot, { + accountIndexes: body.accountIndexes, + scheduledAt: scheduledDate.toISOString(), + headless: Boolean(body.headless), + visualSearch: Boolean(body.visualSearch), + edgeBrowsing: Boolean(body.edgeBrowsing) + }) + + pm.note('info', `Task ${task.id} scheduled for ${scheduledDate.toISOString()}`) + return sendJson(res, 201, { created: true, task }) + } catch (err) { + return sendJson(res, 500, { error: err.message }) + } + } + + // task scheduling - delete + if (method === 'DELETE' && pathname.startsWith('/schedule/tasks/')) { + const taskId = pathname.slice('/schedule/tasks/'.length) + if (!taskId) { + return sendJson(res, 400, { error: 'Task ID is required' }) + } + + try { + const result = removeScheduledTask(projectRoot, taskId) + pm.note('info', `Task ${taskId} cancelled via API`) + return sendJson(res, 200, result) + } catch (err) { + if (err.code === 'NOT_FOUND') { + return sendJson(res, 404, { error: err.message }) + } + return sendJson(res, 500, { error: err.message }) + } + } + // sse if (method === 'GET' && pathname === '/events') { return handleEventStream(req, res, url) @@ -620,6 +781,8 @@ const requestHandler = async (req, res) => { const body = await readJsonObject(req) const force = readForce(body) try { + // Surfaces in the UI log console; the bot logs its own abort lines too. + pm.note('warn', 'Process Aborted by User') const stopping = pm.stop({ force }) stopping.catch(() => {}) return sendJson(res, 202, { stopping: true, force }) @@ -799,6 +962,30 @@ function serveDiagnosticFile(res, pathname) { fs.createReadStream(full).pipe(res) } +function serveStaticFile(res, pathname) { + const safePath = pathname === '/' ? '/index.html' : pathname + const filePath = path.join(publicDir, safePath) + + if (!filePath.startsWith(publicDir)) { + return sendJson(res, 400, { error: 'Invalid path' }) + } + + if (!fs.existsSync(filePath)) { + return sendJson(res, 404, { error: 'Not found' }) + } + + const stat = fs.statSync(filePath) + if (!stat.isFile()) { + return sendJson(res, 404, { error: 'Not found' }) + } + + const ext = path.extname(filePath) + const contentType = MIME_TYPES[ext] || 'application/octet-stream' + + res.writeHead(200, { 'Content-Type': contentType }) + fs.createReadStream(filePath).pipe(res) +} + // startup const server = http.createServer(requestHandler) @@ -812,7 +999,9 @@ server.on('error', err => { }) server.listen(PORT, HOST, () => { - log('INFO', `${pkgName} control API listening on http://${HOST}:${PORT} (headless - no UI)`) + log('INFO', `${pkgName} control API with Web UI listening on http://${HOST}:${PORT}`) + log('INFO', `Web UI: http://${HOST}:${PORT}`) + log('INFO', `API endpoints: http://${HOST}:${PORT}/api`) log('INFO', `Launch command: ${command} ${args.join(' ')}`.trim()) log( 'INFO', @@ -841,6 +1030,12 @@ server.listen(PORT, HOST, () => { auth: Boolean(TOKEN) } process.stdout.write(`__API_READY__ ${JSON.stringify(ready)}\n`) + + taskRunner.start() + log('INFO', `Scheduler: polling every ${Math.round(taskRunner.tickMs / 1000)}s for due tasks`) + // Fire anything already due (server was down when its time passed) without + // waiting a full tick. + taskRunner.tick().catch(err => log('ERROR', 'Initial scheduler tick failed:', err.message)) }) let shuttingDown = false @@ -848,6 +1043,7 @@ async function shutdown(signal, { force = false } = {}) { if (shuttingDown) return shuttingDown = true log('INFO', `${signal} received - shutting down.`) + taskRunner.stop() server.close() try { if (pm.getStatus().state !== 'idle') { diff --git a/scripts/api/sessionStore.js b/scripts/api/sessionStore.js index 5de1c4b0..2c51d623 100644 --- a/scripts/api/sessionStore.js +++ b/scripts/api/sessionStore.js @@ -25,6 +25,44 @@ function closeDatabase(db) { } catch {} } +// Cookies Microsoft only sets once an account is actually signed in. MUID, +// MUIDB and SRCHHPGUSR ride along on anonymous traffic too, so their presence +// proves nothing. +const AUTH_COOKIES = [ + { name: 'MSPAuth', domain: 'live.com' }, + { name: 'MSPProf', domain: 'live.com' }, + { name: 'WLSSC', domain: 'live.com' }, + { name: 'RPSSecAuth', domain: 'live.com' }, + { name: '_U', domain: 'bing.com' } +] + +function normalizeCookieDomain(domain) { + return String(domain ?? '') + .replace(/^\./, '') + .toLowerCase() +} + +function isAuthCookie(cookie) { + const domain = normalizeCookieDomain(cookie?.domain) + return AUTH_COOKIES.some( + entry => entry.name === cookie?.name && (domain === entry.domain || domain.endsWith(`.${entry.domain}`)) + ) +} + +// Playwright stores cookie expiry in seconds, using -1 for session cookies. A +// session cookie has no recorded expiry; that is not the same as expired. +function authCookieExpiryMs(cookie) { + const expires = Number(cookie?.expires) + if (!Number.isFinite(expires) || expires <= 0) return null + return expires * 1000 +} + +function toIso(ms) { + if (ms === null || !Number.isFinite(ms)) return null + const date = new Date(ms) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + function cookieCount(storageState) { if (!storageState) return 0 try { @@ -35,9 +73,70 @@ function cookieCount(storageState) { } } +function parseCookies(storageState) { + if (!storageState) return null + try { + const parsed = JSON.parse(storageState) + return Array.isArray(parsed?.cookies) ? parsed.cookies : null + } catch { + return null + } +} + +function inspectAuthCookies(storageState) { + const cookies = parseCookies(storageState) + if (cookies === null) { + // parse failure: storage_state exists but is corrupt + return storageState + ? { liveCount: 0, expiredCount: 0, totalAuth: 0, nextExpiryMs: null, parseError: true } + : { liveCount: 0, expiredCount: 0, totalAuth: 0, nextExpiryMs: null, parseError: false } + } + + const now = Date.now() + let liveCount = 0 + let expiredCount = 0 + let nextExpiryMs = null + + for (const cookie of cookies) { + if (!isAuthCookie(cookie)) continue + const expiryMs = authCookieExpiryMs(cookie) + // null expiry means session cookie — still live, no recorded expiry + if (expiryMs === null) { + liveCount++ + continue + } + if (expiryMs > now) { + liveCount++ + if (nextExpiryMs === null || expiryMs < nextExpiryMs) nextExpiryMs = expiryMs + } else { + expiredCount++ + } + } + + return { + liveCount, + expiredCount, + totalAuth: liveCount + expiredCount, + nextExpiryMs, + parseError: false + } +} + +function sessionAuthStatus(storageState) { + if (!storageState) return 'not-logged-in' + const info = inspectAuthCookies(storageState) + if (info.parseError) return 'expired' + if (info.liveCount > 0) return 'logged-in' + // Has a row but no live Microsoft auth cookies — stale/expired even if + // anonymous cookies like MUID still linger. + if (cookieCount(storageState) > 0) return 'expired' + return 'not-logged-in' +} + function toSession(row) { const updatedAt = Number(row.updated_at) const updatedDate = new Date(updatedAt) + const auth = inspectAuthCookies(row.storage_state) return { email: row.email, platform: row.platform, @@ -45,7 +144,12 @@ function toSession(row) { Number.isFinite(updatedAt) && !Number.isNaN(updatedDate.getTime()) ? updatedDate.toISOString() : null, hasStorageState: Boolean(row.storage_state), hasFingerprint: Boolean(row.fingerprint), - cookieCount: cookieCount(row.storage_state) + cookieCount: cookieCount(row.storage_state), + authStatus: sessionAuthStatus(row.storage_state), + liveAuthCookieCount: auth.liveCount, + expiredAuthCookieCount: auth.expiredCount, + nextAuthExpiry: toIso(auth.nextExpiryMs), + parseError: auth.parseError } } @@ -75,6 +179,40 @@ export function listStoredSessions(projectRoot, sessionPath) { } } +const LOGIN_RANK = { 'logged-in': 2, expired: 1, 'not-logged-in': 0 } + +export function getSessionLoginStatusMap(projectRoot, sessionPath) { + const listed = listStoredSessions(projectRoot, sessionPath) + if (!listed.databaseExists) return null + + const map = new Map() + for (const session of listed.sessions) { + const key = session.email.toLowerCase() + const current = map.get(key) + if (!current || LOGIN_RANK[session.authStatus] > LOGIN_RANK[current.status]) { + map.set(key, { + status: session.authStatus, + updatedAt: session.updatedAt, + liveAuthCookieCount: session.liveAuthCookieCount, + nextAuthExpiry: session.nextAuthExpiry + }) + } else if (LOGIN_RANK[session.authStatus] === LOGIN_RANK[current.status]) { + // Tie-break: keep the freshest row. + const a = session.updatedAt ? Date.parse(session.updatedAt) : 0 + const b = current.updatedAt ? Date.parse(current.updatedAt) : 0 + if (a > b) { + map.set(key, { + status: session.authStatus, + updatedAt: session.updatedAt, + liveAuthCookieCount: session.liveAuthCookieCount, + nextAuthExpiry: session.nextAuthExpiry + }) + } + } + } + return map +} + export function deleteStoredSessions(projectRoot, sessionPath, email) { const { dbPath, exists } = resolveSessionDb(projectRoot, sessionPath) if (!exists) return { found: false, removed: 0, email, platforms: [] } diff --git a/scripts/api/taskRunner.js b/scripts/api/taskRunner.js new file mode 100644 index 00000000..b47f2715 --- /dev/null +++ b/scripts/api/taskRunner.js @@ -0,0 +1,137 @@ +import { partitionDueTasks, updateTaskStatus } from './taskScheduler.js' + +const DEFAULT_TICK_MS = 30_000 + +/** + * Polls the persisted schedule and starts runs whose time has come. + * + * Kept as a plain interval rather than node-cron/BullMQ: the API is dependency-free + * by design, tasks are one-shot wall-clock times (not cron expressions), and the + * schedule already survives restarts on disk. + */ +export class TaskRunner { + constructor({ projectRoot, pm, buildEnvForTask, tickMs = DEFAULT_TICK_MS }) { + this.projectRoot = projectRoot + this.pm = pm + this.buildEnvForTask = buildEnvForTask + this.tickMs = tickMs + + this.timer = null + this.ticking = false + // Task the runner handed to the process manager, so its outcome can be recorded. + this.activeTaskId = null + + this._onExit = exit => this._finishActive(exit) + } + + start() { + if (this.timer) return + this.pm.on('exit', this._onExit) + this.timer = setInterval(() => { + this.tick().catch(error => { + this.pm.note('error', `Scheduler tick failed: ${error.message}`) + }) + }, this.tickMs) + if (typeof this.timer.unref === 'function') this.timer.unref() + } + + stop() { + if (this.timer) clearInterval(this.timer) + this.timer = null + this.pm.off('exit', this._onExit) + } + + async tick() { + if (this.ticking) return + this.ticking = true + try { + const { due, missed } = partitionDueTasks(this.projectRoot) + + for (const task of missed) { + this._patch(task.id, { + status: 'missed', + finishedAt: new Date().toISOString(), + error: 'Scheduled time passed without an available run slot.' + }) + this.pm.note('warn', `Scheduled task ${task.id} missed its window and will not run.`) + } + + if (!due.length) return + + // One run at a time: the bot owns the browser profile, so a second + // concurrent run would fight over it. Remaining tasks wait for the + // next tick and are marked missed once the grace window closes. + if (this.pm.state !== 'idle' || this.activeTaskId) return + + const task = due[0] + this._startTask(task) + } finally { + this.ticking = false + } + } + + _startTask(task) { + let env + try { + env = this.buildEnvForTask(task) + } catch (error) { + this._patch(task.id, { + status: 'failed', + finishedAt: new Date().toISOString(), + error: error.message + }) + this.pm.note('error', `Scheduled task ${task.id} could not start: ${error.message}`) + return + } + + // Claim the task before spawning so a tick that overlaps a slow spawn + // cannot pick it up again. + this._patch(task.id, { status: 'running', startedAt: new Date().toISOString() }) + this.activeTaskId = task.id + + try { + this.pm.start({ env }) + this.pm.note( + 'info', + `Scheduled task ${task.id} started | accounts=${task.accountIndexes.join(',')} | headless=${Boolean(task.headless)}` + ) + } catch (error) { + this.activeTaskId = null + const status = error.code === 'ALREADY_RUNNING' ? 'pending' : 'failed' + this._patch(task.id, { + status, + startedAt: null, + finishedAt: status === 'failed' ? new Date().toISOString() : null, + error: status === 'failed' ? error.message : null + }) + if (status === 'failed') { + this.pm.note('error', `Scheduled task ${task.id} failed to start: ${error.message}`) + } + } + } + + _finishActive(exit) { + const taskId = this.activeTaskId + if (!taskId) return + this.activeTaskId = null + + const ok = exit?.code === 0 + this._patch(taskId, { + status: ok ? 'done' : 'failed', + finishedAt: new Date().toISOString(), + error: ok ? null : `Run exited with code ${exit?.code ?? 'null'}${exit?.signal ? ` (${exit.signal})` : ''}` + }) + this.pm.note(ok ? 'info' : 'error', `Scheduled task ${taskId} finished (${ok ? 'success' : 'failure'}).`) + } + + _patch(taskId, patch) { + try { + updateTaskStatus(this.projectRoot, taskId, patch) + } catch (error) { + // A task deleted mid-flight is normal; anything else is worth surfacing. + if (error.code !== 'NOT_FOUND') { + this.pm.note('error', `Could not update task ${taskId}: ${error.message}`) + } + } + } +} diff --git a/scripts/api/taskScheduler.js b/scripts/api/taskScheduler.js new file mode 100644 index 00000000..9c984f64 --- /dev/null +++ b/scripts/api/taskScheduler.js @@ -0,0 +1,152 @@ +import { readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs' +import { join } from 'node:path' + +const SCHEDULE_FILE = 'scheduled_tasks.json' + +// How long a finished task stays visible in the UI before it is pruned. +const RETENTION_MS = 24 * 60 * 60 * 1000 + +// A task that could not start within this window after its time (server was off, +// or another run was in progress the whole time) is marked missed instead of +// firing hours late. +const MISS_GRACE_MS = 60 * 60 * 1000 + +const TERMINAL = new Set(['done', 'failed', 'missed', 'cancelled']) + +function filePathFor(projectRoot) { + return join(projectRoot, SCHEDULE_FILE) +} + +function normalizeTask(task) { + return { + status: 'pending', + startedAt: null, + finishedAt: null, + error: null, + ...task + } +} + +function isExpired(task, now) { + if (!TERMINAL.has(task.status)) return false + const endedAt = Date.parse(task.finishedAt ?? task.scheduledAt) + if (Number.isNaN(endedAt)) return false + return now - endedAt > RETENTION_MS +} + +/** + * Reads the persisted tasks. Past-due tasks are kept - the runner needs to see + * them to fire or expire them - and only long-finished ones are pruned. + */ +export function readScheduledTasks(projectRoot) { + const filePath = filePathFor(projectRoot) + + if (!existsSync(filePath)) { + return { tasks: [] } + } + + let data + try { + data = JSON.parse(readFileSync(filePath, 'utf8')) + } catch { + return { tasks: [] } + } + + const stored = Array.isArray(data?.tasks) ? data.tasks : [] + const now = Date.now() + const tasks = stored.map(normalizeTask).filter(task => !isExpired(task, now)) + + if (tasks.length !== stored.length) { + writeScheduledTasks(projectRoot, tasks) + } + + tasks.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)) + return { tasks } +} + +export function writeScheduledTasks(projectRoot, tasks) { + const filePath = filePathFor(projectRoot) + const data = { tasks, updatedAt: new Date().toISOString() } + + try { + // Write-then-rename so a crash mid-write cannot truncate the schedule. + const tmpPath = `${filePath}.tmp` + writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8') + renameSync(tmpPath, filePath) + return data + } catch (error) { + const err = new Error(`Failed to write scheduled tasks: ${error.message}`) + err.code = 'WRITE_FAILED' + throw err + } +} + +export function addScheduledTask(projectRoot, task) { + const { tasks } = readScheduledTasks(projectRoot) + + const newTask = normalizeTask({ + id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + ...task, + createdAt: new Date().toISOString() + }) + + tasks.push(newTask) + writeScheduledTasks(projectRoot, tasks) + + return newTask +} + +export function removeScheduledTask(projectRoot, taskId) { + const { tasks } = readScheduledTasks(projectRoot) + const filtered = tasks.filter(task => task.id !== taskId) + + if (filtered.length === tasks.length) { + const err = new Error(`Task ${taskId} not found`) + err.code = 'NOT_FOUND' + throw err + } + + writeScheduledTasks(projectRoot, filtered) + return { deleted: true, taskId } +} + +/** + * Returns tasks whose time has arrived and that are still waiting to run, + * oldest first, plus the ones that sat unstarted past the grace window. + */ +export function partitionDueTasks(projectRoot, now = Date.now()) { + const { tasks } = readScheduledTasks(projectRoot) + const due = [] + const missed = [] + + for (const task of tasks) { + if (task.status !== 'pending') continue + const at = Date.parse(task.scheduledAt) + if (Number.isNaN(at) || at > now) continue + if (now - at > MISS_GRACE_MS) missed.push(task) + else due.push(task) + } + + return { due, missed } +} + +/** + * Flips a task to a new status in one read-modify-write so the runner never + * starts the same task twice. + */ +export function updateTaskStatus(projectRoot, taskId, patch) { + const { tasks } = readScheduledTasks(projectRoot) + const index = tasks.findIndex(task => task.id === taskId) + + if (index === -1) { + const err = new Error(`Task ${taskId} not found`) + err.code = 'NOT_FOUND' + throw err + } + + const updated = { ...tasks[index], ...patch } + tasks[index] = updated + writeScheduledTasks(projectRoot, tasks) + + return updated +} diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index 37850359..a1a106a8 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -6,6 +6,7 @@ import type { MicrosoftRewardsBot } from '../index' import { loadSession, saveFingerprint } from '../util/SessionStore' import { fingerprintMatchesLocale } from '../util/Locale' import { formatBrowserProxyServer } from '../util/Proxy' +import { registerBrowser, unregisterBrowser } from '../util/Abort' import { UserAgentManager } from './UserAgent' import type { Account } from '../interface/Account' @@ -93,6 +94,10 @@ class Browser { ...(proxyConfig && { proxy: proxyConfig }), args: [...Browser.BROWSER_ARGS, ...sandboxArgs, ...certArgs] }) + + // Tracked so an abort can force-close it even mid-activity. + registerBrowser(browser) + browser.on('disconnected', () => unregisterBrowser(browser)) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.bot.logger.error(this.bot.isMobile, 'BROWSER', `Browser launch failed: ${errorMessage}`) diff --git a/src/functions/activities/experimental/EdgeBrowsing.ts b/src/functions/activities/experimental/EdgeBrowsing.ts index 2a687d50..407094d7 100644 --- a/src/functions/activities/experimental/EdgeBrowsing.ts +++ b/src/functions/activities/experimental/EdgeBrowsing.ts @@ -6,6 +6,7 @@ import type { AppDashboardData, Promotion } from '../../../interface/AppDashBoar import type { DashboardData } from '../../../interface/DashboardData' import { BaseActivity } from '../BaseActivity' import { EdgeBrowsingProgress, type EdgeBrowsingProgressSnapshot } from './EdgeBrowsingProgress' +import { EdgeLiveBrowsing } from './EdgeLiveBrowsing' const LOG_TAG = 'EDGE-BROWSING' const PROMOTION_NAME = 'edge_browsing_streak_flight' @@ -124,6 +125,15 @@ export class EdgeBrowsing extends BaseActivity { ` | estimatedDurationMinutes=${progress.estimatedDurationMinutes}` ) + // Real browsing session (scrolling, reading, clicking) alongside the report loop. + const liveBrowsing = new EdgeLiveBrowsing(this.bot).run(signal).catch(error => { + this.bot.logger.warn( + this.bot.isMobile, + LOG_TAG, + `Live browsing session failed | message=${error instanceof Error ? error.message : String(error)}` + ) + }) + for (let reportNumber = 1; reportNumber <= reportCount; reportNumber++) { const beforeReport = progress.snapshot(reportNumber - 1) @@ -202,6 +212,8 @@ export class EdgeBrowsing extends BaseActivity { serverComplete = await this.refreshServerCompletion(accessToken, reportsProcessed, reportCount) } + await liveBrowsing + const finished = progress.snapshot(reportsProcessed) const summary = `Finished background Edge browsing activity | reports=${reportsProcessed}` + diff --git a/src/functions/activities/experimental/EdgeLiveBrowsing.ts b/src/functions/activities/experimental/EdgeLiveBrowsing.ts new file mode 100644 index 00000000..6ba833ce --- /dev/null +++ b/src/functions/activities/experimental/EdgeLiveBrowsing.ts @@ -0,0 +1,223 @@ +import type { Page } from 'patchright' + +import type { MicrosoftRewardsBot } from '../../../index' + +const LOG_TAG = 'EDGE-BROWSING' +const SESSION_MINUTES = 30 +const READ_BUDGET_MIN_MS = 45_000 +const READ_BUDGET_MAX_MS = 120_000 + +// Hosts a browsing session may follow; anything else is treated as unsafe and skipped. +const SAFE_HOST_SUFFIXES = ['bing.com', 'microsoft.com', 'msn.com', 'microsoftedge.com'] + +const DEFAULT_FEED = 'https://www.msn.com/en-us/news' + +const FEED_URLS = [ + DEFAULT_FEED, + 'https://www.msn.com/en-us/money', + 'https://www.msn.com/en-us/lifestyle', + 'https://www.msn.com/en-us/entertainment', + 'https://www.bing.com/news/search?q=technology', + 'https://www.bing.com/news/search?q=science', + 'https://www.bing.com/news/search?q=sports', + 'https://www.bing.com/news/search?q=health' +] + +/** + * Keeps a real browser tab open for TARGET 30 minutes, scrolling and clicking like a reader. + * Runs in a tab of an already-authenticated context so fingerprints and cookies stay consistent. + */ +export class EdgeLiveBrowsing { + constructor(private readonly bot: MicrosoftRewardsBot) {} + + public async run(signal?: AbortSignal): Promise { + const startedAt = Date.now() + const deadline = startedAt + SESSION_MINUTES * 60_000 + + const page = await this.openPage(signal) + if (!page) { + this.bot.logger.info( + this.bot.isMobile, + LOG_TAG, + 'Live browsing skipped: no open browser context in this run' + ) + return + } + + let pagesRead = 0 + let linksClicked = 0 + + this.bot.logger.info( + this.bot.isMobile, + LOG_TAG, + `Started live Edge browsing session | minutes=${SESSION_MINUTES} | startUrl=${page.url()}` + ) + + try { + while (Date.now() < deadline && !signal?.aborted) { + await this.readPage(page, deadline, signal) + if (signal?.aborted || Date.now() >= deadline) break + pagesRead++ + + // Readers mostly finish a page and move on; sometimes they follow a link. + if (Math.random() < 0.35 && (await this.clickSafeLink(page))) { + linksClicked++ + } else { + await this.gotoFeed(page) + } + + await this.wait(this.bot.utils.randomDelay(2000, 6000), signal) + } + } catch (error) { + if (!signal?.aborted) { + this.bot.logger.warn( + this.bot.isMobile, + LOG_TAG, + `Live browsing interrupted | message=${error instanceof Error ? error.message : String(error)}` + ) + } + } finally { + const minutes = Math.round((Date.now() - startedAt) / 60_000) + this.bot.logger.info( + this.bot.isMobile, + LOG_TAG, + `Finished live Edge browsing session | minutes=${minutes} | pagesRead=${pagesRead} | linkClicks=${linksClicked}`, + 'green' + ) + await page.close().catch(() => undefined) + } + } + + private async openPage(signal?: AbortSignal): Promise { + // The mobile context lives for the whole run; the desktop one may close early. + const candidates = [this.bot.mainMobilePage, this.bot.mainDesktopPage] + + for (const source of candidates) { + if (signal?.aborted) return null + try { + if (!source || source.isClosed()) continue + const context = source.context() + if (context.isClosed()) continue + + const page = await context.newPage() + await page.goto(this.pickFeed(), { waitUntil: 'domcontentloaded', timeout: 30_000 }) + return page + } catch { + continue + } + } + + return null + } + + private async readPage(page: Page, deadline: number, signal?: AbortSignal): Promise { + const budget = Math.min( + this.bot.utils.randomDelay(READ_BUDGET_MIN_MS, READ_BUDGET_MAX_MS), + Math.max(5_000, deadline - Date.now()) + ) + const until = Date.now() + budget + + while (Date.now() < until && !signal?.aborted) { + let atBottom: boolean + try { + atBottom = await page.evaluate(() => { + const doc = document.documentElement + return window.scrollY + window.innerHeight >= doc.scrollHeight - 40 + }) + } catch { + return + } + if (atBottom) break + + await page.mouse.wheel(0, this.bot.utils.randomDelay(250, 700)).catch(() => undefined) + + // Mostly short reading pauses, occasionally a long one like a human re-reading a paragraph. + const longPause = Math.random() < 0.12 + const delay = longPause ? this.bot.utils.randomDelay(3000, 6000) : this.bot.utils.randomDelay(700, 2200) + if (!(await this.wait(delay, signal))) return + } + + // Dwell at the end of the article before moving on. + await this.wait(this.bot.utils.randomDelay(2000, 6000), signal) + } + + private async clickSafeLink(page: Page): Promise { + let hrefs: string[] + try { + hrefs = await page.$$eval('a[href]', anchors => + (anchors as HTMLAnchorElement[]).map(a => a.href).filter(h => /^https?:\/\//i.test(h)) + ) + } catch { + return false + } + + let currentHost = '' + try { + currentHost = new URL(page.url()).hostname + } catch { + return false + } + + const safe = hrefs.filter(href => { + try { + const host = new URL(href).hostname + return ( + host === currentHost || + SAFE_HOST_SUFFIXES.some(suffix => host === suffix || host.endsWith(`.${suffix}`)) + ) + } catch { + return false + } + }) + if (!safe.length) return false + + const target = safe[Math.floor(Math.random() * safe.length)] + if (!target) return false + + try { + await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 20_000 }) + this.bot.logger.debug( + this.bot.isMobile, + LOG_TAG, + `Live browsing followed a link | host=${new URL(target).hostname}` + ) + return true + } catch { + return false + } + } + + private async gotoFeed(page: Page): Promise { + try { + await page.goto(this.pickFeed(), { waitUntil: 'domcontentloaded', timeout: 25_000 }) + } catch { + this.bot.logger.debug( + this.bot.isMobile, + LOG_TAG, + 'Live browsing page load timed out; continuing on the current page' + ) + } + } + + private pickFeed(): string { + return FEED_URLS[Math.floor(Math.random() * FEED_URLS.length)] ?? DEFAULT_FEED + } + + private wait(delayMs: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.resolve(false) + if (!signal) return this.bot.utils.wait(delayMs).then(() => true) + + return new Promise(resolve => { + const onAbort = () => { + clearTimeout(timeout) + resolve(false) + } + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, delayMs) + + signal.addEventListener('abort', onAbort, { once: true }) + }) + } +} diff --git a/src/index.ts b/src/index.ts index ae19c491..18429261 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import Utils, { isBrowserClosedError } from './util/Utils' import { loadAccounts, loadConfig } from './util/Load' import { closeSessionStore, loadResolvedRegion, saveResolvedRegion } from './util/SessionStore' import { checkNodeVersion } from './util/Validator' +import { abortRun, abortSignal, isAborted, isAbortError } from './util/Abort' import { normalizeCountry, resolveAccountLocale } from './util/Locale' import type { AccountLocale } from './util/Locale' @@ -410,6 +411,12 @@ export class MicrosoftRewardsBot { const accountStats: AccountStats[] = [] for (const [accountIndex, account] of accounts.entries()) { + // Stop between accounts so an abort does not start a fresh login. + if (isAborted()) { + this.logger.warn('main', 'ABORT', 'Process aborted by user - skipping remaining accounts') + break + } + if (accountIndex > 0) { await this.waitBeforeNextAccount(account.email) } @@ -439,6 +446,10 @@ export class MicrosoftRewardsBot { }) const result: AccountRunResult | undefined = await this.Main(account).catch(error => { + if (isAbortError(error) || isAborted()) { + void this.logger.warn('main', 'ABORT', `Process aborted by user during ${accountEmail}`) + return undefined + } void this.logger.error( true, 'FLOW', @@ -555,7 +566,18 @@ export class MicrosoftRewardsBot { nextEmail ? ` (${nextEmail})` : '' }` ) - await this.utils.wait(delayMs) + await new Promise(resolve => { + const timer = setTimeout(() => { + abortSignal.removeEventListener('abort', onAbort) + resolve() + }, delayMs) + // Otherwise a Stop during a multi-minute delay would look frozen. + const onAbort = (): void => { + clearTimeout(timer) + resolve() + } + abortSignal.addEventListener('abort', onAbort, { once: true }) + }) } async createDesktopSession(account: Account): Promise { @@ -596,6 +618,9 @@ export class MicrosoftRewardsBot { let mobileSession: BrowserSession | null = null let desktopSession: BrowserSession | null = null const edgeBrowsingController = new AbortController() + // A user abort must also cancel the 30-minute background task. + const onRunAbort = (): void => edgeBrowsingController.abort() + abortSignal.addEventListener('abort', onRunAbort, { once: true }) let edgeBrowsingTask: Promise | null = null let edgeBrowsingFinished = false @@ -933,6 +958,8 @@ export class MicrosoftRewardsBot { } }) } finally { + abortSignal.removeEventListener('abort', onRunAbort) + if (edgeBrowsingTask) { edgeBrowsingController.abort() await edgeBrowsingTask @@ -975,16 +1002,35 @@ async function main(): Promise { process.on('beforeExit', () => { void flushAllWebhooks() }) - process.on('SIGINT', async () => { - rewardsBot.logger.warn('main', 'PROCESS', 'SIGINT received, flushing and exiting...') - await flushAllWebhooks() - process.exit(130) - }) - process.on('SIGTERM', async () => { - rewardsBot.logger.warn('main', 'PROCESS', 'SIGTERM received, flushing and exiting...') + // Stop requests arrive as signals from the control API. Abort first so every + // tracked Chromium is closed, then flush and exit - otherwise the browsers + // only die because the OS reaps the process tree. + let aborting = false + const onStopSignal = async (signal: 'SIGINT' | 'SIGTERM', exitCode: number): Promise => { + if (aborting) return + aborting = true + + rewardsBot.logger.warn('main', 'ABORT', `Process aborted by user (${signal}) - closing browsers...`) + const closed = await abortRun() + rewardsBot.logger.warn('main', 'ABORT', `Process aborted by user | browsersClosed=${closed}`) + await flushAllWebhooks() - process.exit(143) - }) + process.exit(exitCode) + } + process.on('SIGINT', () => void onStopSignal('SIGINT', 130)) + process.on('SIGTERM', () => void onStopSignal('SIGTERM', 143)) + + // The control API writes this on stdin because Windows cannot deliver a real + // SIGTERM to a child; without it a stop would hard-kill and orphan Chromium. + if (!process.stdin.isTTY) { + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { + if (chunk.includes('__ABORT__')) void onStopSignal('SIGTERM', 143) + }) + process.stdin.on('error', () => undefined) + // Do not hold the event loop open just for this listener. + process.stdin.unref() + } process.on('uncaughtException', async error => { if (isBrowserClosedError(error)) { rewardsBot.logger.debug( diff --git a/src/util/Abort.ts b/src/util/Abort.ts new file mode 100644 index 00000000..66b1a034 --- /dev/null +++ b/src/util/Abort.ts @@ -0,0 +1,65 @@ +/** + * Run-wide cancellation. + * + * The API stops a run by signalling the process (SIGTERM, then SIGKILL on a + * timeout). Chromium is a child of this process, so the OS-level kill already + * prevents zombies. What the signal alone does NOT do is give the run a chance + * to close browsers cleanly, so this registry exists to: + * - expose an AbortSignal that long loops can poll to bail out early, and + * - close every live browser (saving nothing, just releasing the processes) + * before we exit, so no Chromium outlives us if it ever escaped the tree. + */ + +type Closable = { close: () => Promise } + +const controller = new AbortController() +const browsers = new Set() + +/** Signal that flips when the user aborts the run. */ +export const abortSignal: AbortSignal = controller.signal + +export function isAborted(): boolean { + return controller.signal.aborted +} + +/** Throws if the run was aborted. Call between steps to unwind promptly. */ +export function throwIfAborted(): void { + if (controller.signal.aborted) { + throw new AbortError() + } +} + +export class AbortError extends Error { + constructor() { + super('Process aborted by user') + this.name = 'AbortError' + } +} + +export function isAbortError(error: unknown): boolean { + return error instanceof AbortError || (error instanceof Error && error.name === 'AbortError') +} + +/** Track a launched browser so abort can force it closed. */ +export function registerBrowser(browser: Closable): void { + browsers.add(browser) +} + +export function unregisterBrowser(browser: Closable): void { + browsers.delete(browser) +} + +/** + * Flip the signal and force every tracked browser closed. Safe to call twice. + * Returns how many browsers it closed so the caller can log it. + */ +export async function abortRun(): Promise { + controller.abort() + + const pending = [...browsers] + browsers.clear() + + // close() rejects if the browser already died; that is the outcome we want anyway. + await Promise.allSettled(pending.map(browser => browser.close())) + return pending.length +} diff --git a/src/util/ConfigEnvOverrides.ts b/src/util/ConfigEnvOverrides.ts index 10dc65e3..ec999d64 100644 --- a/src/util/ConfigEnvOverrides.ts +++ b/src/util/ConfigEnvOverrides.ts @@ -6,10 +6,12 @@ export interface EnvOverrideEntry { env: string path: string // dotted path into config.json type: OverrideType + secret?: boolean // never print the value; applied overrides are logged } export const ENV_OVERRIDES: EnvOverrideEntry[] = [ // General + { env: 'CONFIG_HEADLESS', path: 'headless', type: 'bool' }, { env: 'CONFIG_CLUSTERS', path: 'clusters', type: 'number' }, { env: 'CONFIG_DEBUG_LOGS', path: 'debugLogs', type: 'bool' }, { env: 'CONFIG_ERROR_DIAGNOSTICS', path: 'errorDiagnostics', type: 'bool' }, @@ -72,18 +74,18 @@ export const ENV_OVERRIDES: EnvOverrideEntry[] = [ // Discord webhook { env: 'CONFIG_DISCORD_ENABLED', path: 'webhook.discord.enabled', type: 'bool' }, - { env: 'CONFIG_DISCORD_URL', path: 'webhook.discord.url', type: 'string' }, + { env: 'CONFIG_DISCORD_URL', path: 'webhook.discord.url', type: 'string', secret: true }, // Telegram webhook { env: 'CONFIG_TELEGRAM_ENABLED', path: 'webhook.telegram.enabled', type: 'bool' }, - { env: 'CONFIG_TELEGRAM_BOTTOKEN', path: 'webhook.telegram.botToken', type: 'string' }, - { env: 'CONFIG_TELEGRAM_CHATID', path: 'webhook.telegram.chatId', type: 'string' }, + { env: 'CONFIG_TELEGRAM_BOTTOKEN', path: 'webhook.telegram.botToken', type: 'string', secret: true }, + { env: 'CONFIG_TELEGRAM_CHATID', path: 'webhook.telegram.chatId', type: 'string', secret: true }, // ntfy webhook (tags are comma-separated e.g. "bot,notify") { env: 'CONFIG_NTFY_ENABLED', path: 'webhook.ntfy.enabled', type: 'bool' }, - { env: 'CONFIG_NTFY_URL', path: 'webhook.ntfy.url', type: 'string' }, - { env: 'CONFIG_NTFY_TOPIC', path: 'webhook.ntfy.topic', type: 'string' }, - { env: 'CONFIG_NTFY_TOKEN', path: 'webhook.ntfy.token', type: 'string' }, + { env: 'CONFIG_NTFY_URL', path: 'webhook.ntfy.url', type: 'string', secret: true }, + { env: 'CONFIG_NTFY_TOPIC', path: 'webhook.ntfy.topic', type: 'string', secret: true }, + { env: 'CONFIG_NTFY_TOKEN', path: 'webhook.ntfy.token', type: 'string', secret: true }, { env: 'CONFIG_NTFY_TITLE', path: 'webhook.ntfy.title', type: 'string' }, { env: 'CONFIG_NTFY_PRIORITY', path: 'webhook.ntfy.priority', type: 'number' }, { env: 'CONFIG_NTFY_TAGS', path: 'webhook.ntfy.tags', type: 'array' }, @@ -95,7 +97,13 @@ export const ENV_OVERRIDES: EnvOverrideEntry[] = [ { env: 'CONFIG_WEBHOOK_LOG_FILTER_KEYWORDS', path: 'webhook.webhookLogFilter.keywords', type: 'array' } ] -const FORCED_OVERRIDES: { path: string; value: unknown }[] = [{ path: 'headless', value: true }] +// Docker images set FORCE_HEADLESS=1 because containers have no display, so +// headless is pinned there and CONFIG_HEADLESS cannot turn it off. +function forcedOverrides(env: NodeJS.ProcessEnv): { path: string; value: unknown }[] { + const raw = env.FORCE_HEADLESS?.trim().toLowerCase() + const forced = raw !== undefined && ['1', 'true', 'yes', 'on'].includes(raw) + return forced ? [{ path: 'headless', value: true }] : [] +} function setDeep(obj: Record, dottedPath: string, value: unknown): void { const parts = dottedPath.split('.') @@ -111,15 +119,21 @@ function setDeep(obj: Record, dottedPath: string, value: unknow cur[parts[parts.length - 1] as string] = value } +// Shells, compose files and CI runners all spell booleans differently, so accept +// the same set Load.ts accepts for its own env flags instead of only true/false. +const TRUE_WORDS = new Set(['1', 'true', 'yes', 'on']) +const FALSE_WORDS = new Set(['0', 'false', 'no', 'off']) + function coerceScalar(raw: string, type: 'bool' | 'number' | 'string', env: string): unknown { switch (type) { - case 'bool': - if (raw !== 'true' && raw !== 'false') { - throw new Error(`${env} expects true or false, got '${raw}'.`) - } - return raw === 'true' + case 'bool': { + const word = raw.trim().toLowerCase() + if (TRUE_WORDS.has(word)) return true + if (FALSE_WORDS.has(word)) return false + throw new Error(`${env} expects a boolean (true/false, 1/0, yes/no, on/off), got '${raw}'.`) + } case 'number': { - const n = Number(raw) + const n = Number(raw.trim()) if (!Number.isFinite(n)) throw new Error(`${env} expects a JSON number, got '${raw}'.`) return n } @@ -129,6 +143,13 @@ function coerceScalar(raw: string, type: 'bool' | 'number' | 'string', env: stri } } +const SECRET_ENVS = new Set(ENV_OVERRIDES.filter(entry => entry.secret).map(entry => entry.env)) + +// Applied overrides get logged, and some of them are webhook URLs and bot tokens. +export function describeOverrideValue(envName: string, value: unknown): string { + return SECRET_ENVS.has(envName) ? '***' : JSON.stringify(value) +} + export interface ComputedOverride { env: string path: string @@ -169,6 +190,26 @@ export function computeOverrides(env: NodeJS.ProcessEnv = process.env): { return { applied, errors } } +export interface MergeReport { + forced: { path: string; value: unknown }[] + applied: ComputedOverride[] + errors: OverrideError[] +} + +// Merges CONFIG_* overrides into an already-parsed config object. loadConfig() +// calls this on every start so a launcher (Web UI, scheduler, plain shell) only +// has to set the env var - nothing has to rewrite config.json first. +// Unlike applyEnvOverrides this is not all-or-nothing: valid overrides still +// land and the caller decides how loudly to report the rejected ones. +export function mergeEnvOverrides(config: Record, env: NodeJS.ProcessEnv = process.env): MergeReport { + const { applied, errors } = computeOverrides(env) + const forced = forcedOverrides(env) + // applied first, forced last: FORCE_HEADLESS pins headless on and CONFIG_HEADLESS cannot undo it + for (const { path: p, value } of applied) setDeep(config, p, value) + for (const { path: p, value } of forced) setDeep(config, p, value) + return { forced, applied, errors } +} + export interface ApplyReport { configPath: string forced: { path: string; value: unknown }[] @@ -177,18 +218,19 @@ export interface ApplyReport { } export function applyEnvOverrides(configPath: string, env: NodeJS.ProcessEnv = process.env): ApplyReport { - const { applied, errors } = computeOverrides(env) - if (errors.length > 0) { - return { configPath, forced: [], applied: [], errors } + // Preflight before touching the file: a single bad value must not leave a + // half-overridden config.json behind. + const { errors: rejected } = computeOverrides(env) + if (rejected.length > 0) { + return { configPath, forced: [], applied: [], errors: rejected } } const config = readJson(configPath) as Record - for (const { path: p, value } of FORCED_OVERRIDES) setDeep(config, p, value) - for (const { path: p, value } of applied) setDeep(config, p, value) + const { applied, forced } = mergeEnvOverrides(config, env) writeConfigAtomic(configPath, config, { backup: false }) - return { configPath, forced: FORCED_OVERRIDES, applied, errors: [] } + return { configPath, forced, applied, errors: [] } } // ── CLI entry point, used by entrypoint.sh ── @@ -269,7 +311,9 @@ Examples: process.exit(1) } report.forced.forEach(f => console.log(`[entrypoint] .${f.path} = ${f.value} (forced)`)) - report.applied.forEach(a => console.log(`[entrypoint] .${a.path} = ${JSON.stringify(a.value)}`)) + report.applied.forEach(a => + console.log(`[entrypoint] .${a.path} = ${describeOverrideValue(a.env, a.value)}`) + ) console.log(`[entrypoint] Applied ${report.applied.length} override(s).`) process.exit(0) } catch (err) { diff --git a/src/util/Load.ts b/src/util/Load.ts index 71b4400e..9b5edcfa 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -3,6 +3,7 @@ import path from 'path' import type { Account, AccountProxy, ConfigSaveFingerprint } from '../interface/Account' import type { Config } from '../interface/Config' +import { describeOverrideValue, mergeEnvOverrides } from './ConfigEnvOverrides' import { validateAccounts, validateConfig } from './Validator' let configCache: Config @@ -154,6 +155,24 @@ export function loadAccounts(): Account[] { } } +function applyConfigEnvOverrides(rawConfig: unknown): void { + if (typeof rawConfig !== 'object' || rawConfig === null || Array.isArray(rawConfig)) return + + const { applied, forced, errors } = mergeEnvOverrides(rawConfig as Record) + + // Loud on both sides: a silently ignored toggle is exactly the bug this fixes, + // and a rejected value would otherwise look identical to the feature being off. + for (const { env, path: dotted, value } of applied) { + console.log(`[Config] override: ${env} -> .${dotted} = ${describeOverrideValue(env, value)}`) + } + for (const { path: dotted, value } of forced) { + console.log(`[Config] override: .${dotted} = ${JSON.stringify(value)} (forced)`) + } + for (const { message } of errors) { + console.warn(`[Config] WARN: ignored invalid override - ${message}`) + } +} + export function loadConfig(): Config { try { if (configCache) { @@ -170,6 +189,14 @@ export function loadConfig(): Config { const config = fs.readFileSync(configPath, 'utf-8') const unverifiedConfig = JSON.parse(config) + + // CONFIG_* overrides are merged here rather than written to config.json, + // so every launcher (Web UI, scheduler, plain shell, Docker) gets them by + // setting an env var. Runs before validateConfig so overridden values go + // through the same schema checks as file values. + ensureEnvLoaded() + applyConfigEnvOverrides(unverifiedConfig) + const configData = validateConfig(unverifiedConfig) configCache = configData diff --git a/tests/abort.test.mjs b/tests/abort.test.mjs new file mode 100644 index 00000000..f5085f88 --- /dev/null +++ b/tests/abort.test.mjs @@ -0,0 +1,114 @@ +/** + * Self-check for the run-abort path. + * + * Run: node --test tests/abort.test.js + * + * Covers the two things that actually break in the wild: + * 1. abortRun() closes every registered browser, so none survive a Stop. + * 2. ProcessManager.stop() asks for a graceful abort before killing the tree, + * because a bare taskkill leaves the bot no chance to close Chromium. + */ + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { ABORT_SENTINEL, ProcessManager } from '../scripts/api/processManager.js' + +test('abortRun closes every registered browser and flips the signal', async () => { + // Import through dist so this runs without a TS loader. + const { abortRun, abortSignal, isAborted, registerBrowser } = await import('../dist/util/Abort.js') + + const closed = [] + const fakeBrowser = name => ({ + close: async () => { + closed.push(name) + } + }) + + registerBrowser(fakeBrowser('mobile')) + registerBrowser(fakeBrowser('desktop')) + + assert.equal(isAborted(), false, 'signal should start un-aborted') + + const count = await abortRun() + + assert.equal(count, 2, 'should report both browsers closed') + assert.deepEqual(closed.sort(), ['desktop', 'mobile']) + assert.equal(abortSignal.aborted, true, 'signal must flip so loops bail out') + assert.equal(isAborted(), true) + + // Idempotent: a second Stop click must not throw or double-close. + assert.equal(await abortRun(), 0) +}) + +test('a browser that fails to close does not break the abort', async () => { + const { abortRun, registerBrowser } = await import('../dist/util/Abort.js') + + registerBrowser({ + close: async () => { + throw new Error('browser already gone') + } + }) + + // Must resolve, not reject - a dead browser is the outcome we wanted anyway. + assert.equal(await abortRun(), 1) +}) + +test('stop() requests a graceful abort before killing the tree', async () => { + const pm = new ProcessManager({ + command: process.execPath, + // Ignores stdin and stays alive, so we can observe the abort request. + args: ['-e', 'setTimeout(() => {}, 60_000)'], + cwd: process.cwd(), + stopTimeoutMs: 1500 + }) + + pm.start() + await new Promise(resolve => pm.once('status', resolve)) + + const written = [] + const realWrite = pm.child.stdin.write.bind(pm.child.stdin) + pm.child.stdin.write = chunk => { + written.push(String(chunk)) + return realWrite(chunk) + } + + const exit = pm.stop({ force: false }) + + assert.ok( + written.some(chunk => chunk.includes(ABORT_SENTINEL)), + 'stop() should write the abort sentinel to stdin' + ) + + // The child ignores the sentinel, so the kill timer must still finish the job. + await exit + assert.equal(pm.getStatus().state, 'idle', 'server must settle back to idle, not hang') +}) + +test('force stop skips the graceful path', async () => { + const pm = new ProcessManager({ + command: process.execPath, + args: ['-e', 'setTimeout(() => {}, 60_000)'], + cwd: process.cwd(), + stopTimeoutMs: 1500 + }) + + pm.start() + await new Promise(resolve => pm.once('status', resolve)) + + const written = [] + const realWrite = pm.child.stdin.write.bind(pm.child.stdin) + pm.child.stdin.write = chunk => { + written.push(String(chunk)) + return realWrite(chunk) + } + + await pm.stop({ force: true }) + + assert.equal( + written.some(chunk => chunk.includes(ABORT_SENTINEL)), + false, + 'force stop should kill immediately without asking' + ) + assert.equal(pm.getStatus().state, 'idle') +}) diff --git a/tests/configEnvOverrides.test.mjs b/tests/configEnvOverrides.test.mjs new file mode 100644 index 00000000..9245c259 --- /dev/null +++ b/tests/configEnvOverrides.test.mjs @@ -0,0 +1,133 @@ +/** + * Regression cover for CONFIG_* env overrides reaching the running bot. + * + * Run: node --test tests/configEnvOverrides.test.mjs + * + * The bug this guards: the Web UI toggles Visual Search / Edge Browsing by + * setting CONFIG_WORKER_VISUAL_SEARCH and CONFIG_EXPERIMENTAL_EDGE_BROWSING on + * the spawned run, but nothing merged those vars into the config the bot + * evaluated. Only the Docker entrypoint applied them (by rewriting config.json), + * so a locally launched run silently skipped both features. + */ + +import assert from 'node:assert/strict' +import test from 'node:test' + +// Import through dist so this runs without a TS loader. +const { mergeEnvOverrides, computeOverrides, describeOverrideValue } = + await import('../dist/util/ConfigEnvOverrides.js') + +function baseConfig() { + return { + headless: true, + clusters: 1, + workers: { doVisualSearch: false, doDailySet: true }, + experimental: { edgeBrowsing: false, apiSearch: false } + } +} + +test('the two toggled features land on the config the bot reads', () => { + const config = baseConfig() + // Exactly what public/app.js puts in the /start payload. + const report = mergeEnvOverrides(config, { + CONFIG_WORKER_VISUAL_SEARCH: 'true', + CONFIG_EXPERIMENTAL_EDGE_BROWSING: 'true' + }) + + assert.equal(config.workers.doVisualSearch, true) + assert.equal(config.experimental.edgeBrowsing, true) + assert.equal(report.errors.length, 0) + assert.deepEqual(report.applied.map(entry => entry.path).sort(), [ + 'experimental.edgeBrowsing', + 'workers.doVisualSearch' + ]) +}) + +test('toggling off overrides a config.json that has the feature on', () => { + const config = baseConfig() + config.workers.doVisualSearch = true + config.experimental.edgeBrowsing = true + + mergeEnvOverrides(config, { + CONFIG_WORKER_VISUAL_SEARCH: 'false', + CONFIG_EXPERIMENTAL_EDGE_BROWSING: 'false' + }) + + assert.equal(config.workers.doVisualSearch, false) + assert.equal(config.experimental.edgeBrowsing, false) +}) + +test('unset vars leave the file values alone', () => { + const config = baseConfig() + config.workers.doVisualSearch = true + + const report = mergeEnvOverrides(config, {}) + + assert.equal(config.workers.doVisualSearch, true) + assert.equal(report.applied.length, 0) +}) + +test('an empty string is treated as unset, not as false', () => { + const config = baseConfig() + config.workers.doVisualSearch = true + + mergeEnvOverrides(config, { CONFIG_WORKER_VISUAL_SEARCH: '' }) + + assert.equal(config.workers.doVisualSearch, true) +}) + +test('shell-style booleans are accepted, not just true/false', () => { + for (const raw of ['1', 'yes', 'on', 'TRUE', ' true ']) { + const config = baseConfig() + mergeEnvOverrides(config, { CONFIG_WORKER_VISUAL_SEARCH: raw }) + assert.equal(config.workers.doVisualSearch, true, `${JSON.stringify(raw)} should mean true`) + } + + for (const raw of ['0', 'no', 'off', 'FALSE']) { + const config = baseConfig() + config.workers.doVisualSearch = true + mergeEnvOverrides(config, { CONFIG_WORKER_VISUAL_SEARCH: raw }) + assert.equal(config.workers.doVisualSearch, false, `${JSON.stringify(raw)} should mean false`) + } +}) + +test('one bad value does not discard the valid overrides beside it', () => { + const config = baseConfig() + const report = mergeEnvOverrides(config, { + CONFIG_WORKER_VISUAL_SEARCH: 'true', + CONFIG_CLUSTERS: 'abc' + }) + + assert.equal(config.workers.doVisualSearch, true, 'valid override must still apply') + assert.equal(config.clusters, 1, 'rejected override must leave the file value') + assert.equal(report.errors.length, 1) + assert.equal(report.errors[0].env, 'CONFIG_CLUSTERS') +}) + +test('a rejected boolean is reported instead of silently passing', () => { + const { errors } = computeOverrides({ CONFIG_EXPERIMENTAL_EDGE_BROWSING: 'maybe' }) + assert.equal(errors.length, 1) + assert.match(errors[0].message, /boolean/) +}) + +test('FORCE_HEADLESS wins over CONFIG_HEADLESS, and is off by default', () => { + const forced = baseConfig() + mergeEnvOverrides(forced, { CONFIG_HEADLESS: 'false', FORCE_HEADLESS: '1' }) + assert.equal(forced.headless, true, 'containers have no display, so headless stays pinned') + + // Outside Docker the user keeps control of headless. + const free = baseConfig() + mergeEnvOverrides(free, { CONFIG_HEADLESS: 'false' }) + assert.equal(free.headless, false) +}) + +test('nested paths are created when config.json predates the key', () => { + const config = { headless: true } + mergeEnvOverrides(config, { CONFIG_EXPERIMENTAL_EDGE_BROWSING: 'true' }) + assert.equal(config.experimental.edgeBrowsing, true) +}) + +test('secret override values are masked in the applied-override log', () => { + assert.equal(describeOverrideValue('CONFIG_DISCORD_URL', 'https://discord.com/api/webhooks/secret'), '***') + assert.equal(describeOverrideValue('CONFIG_WORKER_VISUAL_SEARCH', true), 'true') +}) diff --git a/tests/sessionStatus.test.mjs b/tests/sessionStatus.test.mjs new file mode 100644 index 00000000..7a5f8030 --- /dev/null +++ b/tests/sessionStatus.test.mjs @@ -0,0 +1,208 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +import { getSessionLoginStatusMap, listStoredSessions } from '../scripts/api/sessionStore.js' + +const SECOND = 1000 +const DAY_SECONDS = 24 * 60 * 60 + +function secondsFromNow(days) { + return Math.floor(Date.now() / SECOND) + days * DAY_SECONDS +} + +function storageState(cookies) { + return JSON.stringify({ cookies, origins: [] }) +} + +/** + * Builds a throwaway sessions.db that looks like the one the bot writes, then + * returns the (projectRoot, sessionPath) pair the API reader expects. + * + * sessionPath is randomized so resolveSessionDb cannot accidentally match the + * real ./sessions/sessions.db relative to the test process cwd. + */ +function makeSessionDb(rows) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mrs-session-test-')) + const sessionPath = `sessions-${path.basename(root)}` + const dir = path.join(root, sessionPath) + fs.mkdirSync(dir, { recursive: true }) + + const db = new DatabaseSync(path.join(dir, 'sessions.db')) + db.exec(` + CREATE TABLE sessions ( + email TEXT NOT NULL, + platform TEXT NOT NULL, + storage_state TEXT, + fingerprint TEXT, + updated_at INTEGER NOT NULL, + PRIMARY KEY (email, platform) + ) + `) + const insert = db.prepare( + 'INSERT INTO sessions (email, platform, storage_state, fingerprint, updated_at) VALUES (?, ?, ?, ?, ?)' + ) + for (const row of rows) { + insert.run( + row.email, + row.platform, + row.storageState ?? null, + row.fingerprint ?? null, + row.updatedAt ?? Date.now() + ) + } + db.close() + + return { root, sessionPath, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) } +} + +test('a live Microsoft auth cookie reports logged-in', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'live@example.com', + platform: 'desktop', + storageState: storageState([ + { name: 'MSPAuth', domain: '.live.com', expires: secondsFromNow(365) }, + { name: 'MUID', domain: '.bing.com', expires: secondsFromNow(365) } + ]) + } + ]) + + try { + const map = getSessionLoginStatusMap(root, sessionPath) + assert.equal(map.get('live@example.com').status, 'logged-in') + assert.equal(map.get('live@example.com').liveAuthCookieCount, 1) + } finally { + cleanup() + } +}) + +test('anonymous cookies alone are not proof of login', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'anon@example.com', + platform: 'desktop', + storageState: storageState([ + { name: 'MUID', domain: '.bing.com', expires: secondsFromNow(365) }, + { name: 'SRCHHPGUSR', domain: '.bing.com', expires: secondsFromNow(365) } + ]) + } + ]) + + try { + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('anon@example.com').status, 'expired') + } finally { + cleanup() + } +}) + +test('an expired auth cookie reports expired, not logged-in', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'stale@example.com', + platform: 'desktop', + storageState: storageState([{ name: '_U', domain: '.bing.com', expires: secondsFromNow(-2) }]) + } + ]) + + try { + const info = getSessionLoginStatusMap(root, sessionPath).get('stale@example.com') + assert.equal(info.status, 'expired') + assert.equal(info.liveAuthCookieCount, 0) + } finally { + cleanup() + } +}) + +test('a session cookie (expires -1) still counts as live', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'session-cookie@example.com', + platform: 'mobile', + storageState: storageState([{ name: 'WLSSC', domain: '.live.com', expires: -1 }]) + } + ]) + + try { + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('session-cookie@example.com').status, 'logged-in') + } finally { + cleanup() + } +}) + +test('a row with no storage state reports not-logged-in', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { email: 'empty@example.com', platform: 'desktop', storageState: null } + ]) + + try { + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('empty@example.com').status, 'not-logged-in') + } finally { + cleanup() + } +}) + +test('the best platform wins when desktop and mobile disagree', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'mixed@example.com', + platform: 'desktop', + storageState: storageState([{ name: 'MSPAuth', domain: '.live.com', expires: secondsFromNow(-1) }]), + updatedAt: Date.now() - 60_000 + }, + { + email: 'mixed@example.com', + platform: 'mobile', + storageState: storageState([{ name: 'MSPAuth', domain: '.live.com', expires: secondsFromNow(30) }]), + updatedAt: Date.now() + } + ]) + + try { + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('mixed@example.com').status, 'logged-in') + } finally { + cleanup() + } +}) + +test('email lookups are case-insensitive', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { + email: 'MixedCase@Example.com', + platform: 'desktop', + storageState: storageState([{ name: 'MSPProf', domain: '.live.com', expires: secondsFromNow(10) }]) + } + ]) + + try { + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('mixedcase@example.com').status, 'logged-in') + } finally { + cleanup() + } +}) + +test('corrupt storage state is reported as expired rather than throwing', () => { + const { root, sessionPath, cleanup } = makeSessionDb([ + { email: 'corrupt@example.com', platform: 'desktop', storageState: '{not json' } + ]) + + try { + const listed = listStoredSessions(root, sessionPath) + assert.equal(listed.sessions[0].parseError, true) + assert.equal(getSessionLoginStatusMap(root, sessionPath).get('corrupt@example.com').status, 'expired') + } finally { + cleanup() + } +}) + +test('a missing database yields null so callers can leave status untouched', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mrs-session-missing-')) + try { + assert.equal(getSessionLoginStatusMap(root, `sessions-${path.basename(root)}`), null) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +})