diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 7b08758..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "root": true, - "env": { - "browser": true, - "node": true, - "es2022": true, - "serviceworker": true, - "worker": true - }, - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "script" - }, - "globals": { - "L": "readonly", - "indexedDB": "readonly", - "speechSynthesis": "readonly", - "SpeechSynthesisUtterance": "readonly", - "Notification": "readonly", - "AudioContext": "readonly", - "webkitAudioContext": "readonly", - "PushManager": "readonly", - "EventSource": "readonly", - "ServiceWorkerRegistration": "readonly" - }, - "rules": { - "no-unused-vars": ["warn", { "args": "none", "varsIgnorePattern": "^_" }], - "no-undef": "error", - "no-redeclare": "error", - "no-unreachable": "error", - "no-dupe-keys": "error", - "no-dupe-args": "error", - "no-dupe-else-if": "error", - "no-duplicate-case": "error", - "no-constant-condition": ["error", { "checkLoops": false }], - "no-debugger": "warn", - "no-empty": ["warn", { "allowEmptyCatch": true }], - "no-self-assign": "error", - "no-self-compare": "error", - "no-template-curly-in-string": "warn", - "no-unused-private-class-members": "warn", - "no-use-before-define": ["error", { "functions": false, "classes": false, "variables": true }], - "valid-typeof": "error", - "use-isnan": "error", - "eqeqeq": ["warn", "always", { "null": "ignore" }], - "no-var": "warn", - "prefer-const": ["warn", { "destructuring": "all" }], - "no-implicit-globals": "off", - "max-len": "off", - "no-mixed-operators": "off", - "semi": "off", - "quotes": "off", - "indent": "off" - }, - "overrides": [ - { - "files": ["test/unit.js", "test/integration.js"], - "rules": { - "no-unused-vars": "off" - } - } - ] -} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 52cc934..cb6a0fc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ on: - '**.md' - 'CLAUDE.md' - '.github/dependabot.yml' - - '.eslintrc.json' + - 'eslint.config.js' - 'docs/openapi.yaml' workflow_dispatch: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ec3eee..3bb2b2e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,11 +60,13 @@ jobs: lint: name: ESLint runs-on: ubuntu-latest - continue-on-error: true # optional dependency; don't block merges steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' - - run: npm install --no-save eslint@9 - - run: npx eslint server.js test/unit.js test/integration.js test/e2e.js telegram-bot.js + node-version: '22' # pnpm 11.25+ requires Node >=22.13; '20' crashes on node:sqlite + - uses: pnpm/action-setup@v4 + with: + version: 11 + - run: pnpm install --frozen-lockfile + - run: pnpm exec eslint server.js test/unit.js test/integration.js test/e2e.js telegram-bot.js diff --git a/README.md b/README.md index 579f328..e12963e 100644 --- a/README.md +++ b/README.md @@ -412,7 +412,7 @@ CI ב-[.github/workflows/test.yml](.github/workflows/test.yml) — מריץ unit `test/unit.js` משתמש ב-`node:test` המובנה (Node 18+) ובודק פונקציות פניניות (escapeHtml, formatShelter, shelterClass, distanceKm, isDND, fuzzyMatch, isRTL) וכן ששלל 14 השפות ב-`LN` חולקות בדיוק את אותו סט מפתחות. `test/integration.js` מקים שרת mock של OREF, מצביע אליו דרך `OREF_URL_OVERRIDE`, ובודק שאזעקה זורמת ל-`/api/alerts`, ל-SSE, ול-`/api/health` (12 assertions) — הכל ברמת API, בלי דפדפן. `test/e2e.js` מריץ Chrome אמיתי דרך Playwright (`channel:'chrome'`, לא מוריד דפדפן bundled) ובודק רגרסיות UI קונקרטיות מהיסטוריית הפרויקט (פוקוס בחיפוש, שימור טאב, רוחב ניווט מובייל, תוויות מקלטים, צבעי option). -קונפיג ESLint ב-[.eslintrc.json](.eslintrc.json) — מינימלי, מתמקד בחיפוש באגים אמיתיים (`no-unused-vars`, `no-undef`, `no-redeclare`, `eqeqeq`); לא אכפתי לסגנון בכוונה כי הקוד דחוס במכוון. +קונפיג ESLint ב-[eslint.config.js](eslint.config.js) (flat config) — מינימלי, מתמקד בחיפוש באגים אמיתיים (`no-unused-vars`, `no-undef`, `no-redeclare`, `eqeqeq`); לא אכפתי לסגנון בכוונה כי הקוד דחוס במכוון. --- diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..aa6b5a0 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,74 @@ +'use strict'; +// פלאט-קונפיג (ESLint 9+) — מיגרציה מ-.eslintrc.json. שומר בכוונה רק על כללי "באג אמיתי": +// לא נוספו כללי סגנון (max-len/quotes/semi/indent/no-mixed-operators) — הקוד one-liners דחוסים בכוונה. +const globals = require('globals'); + +const domGlobals = { + L: 'readonly', + indexedDB: 'readonly', + speechSynthesis: 'readonly', + SpeechSynthesisUtterance: 'readonly', + Notification: 'readonly', + AudioContext: 'readonly', + webkitAudioContext: 'readonly', + PushManager: 'readonly', + EventSource: 'readonly', + ServiceWorkerRegistration: 'readonly', +}; + +module.exports = [ + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'script', + globals: { + ...globals.browser, + ...globals.node, + ...globals.es2022, + ...globals.serviceworker, + ...globals.worker, + ...domGlobals, + // server.js עושה const crypto = require('crypto') — לא ה-Web Crypto API הגלובלי של Node + crypto: 'off', + }, + }, + rules: { + 'no-unused-vars': ['warn', { args: 'none', varsIgnorePattern: '^_' }], + 'no-undef': 'error', + 'no-redeclare': 'error', + 'no-unreachable': 'error', + 'no-dupe-keys': 'error', + 'no-dupe-args': 'error', + 'no-dupe-else-if': 'error', + 'no-duplicate-case': 'error', + 'no-constant-condition': ['error', { checkLoops: false }], + 'no-debugger': 'warn', + 'no-empty': ['warn', { allowEmptyCatch: true }], + 'no-self-assign': 'error', + 'no-self-compare': 'error', + 'no-template-curly-in-string': 'warn', + 'no-unused-private-class-members': 'warn', + 'no-use-before-define': ['error', { functions: false, classes: false, variables: true }], + 'valid-typeof': 'error', + 'use-isnan': 'error', + eqeqeq: ['warn', 'always', { null: 'ignore' }], + 'no-var': 'warn', + 'prefer-const': ['warn', { destructuring: 'all' }], + }, + }, + { + files: ['test/unit.js', 'test/integration.js'], + rules: { + 'no-unused-vars': 'off', + }, + }, + { + // page.evaluate(() => tglShl()) מריץ בקונטקסט הדפדפן — tglShl מוגדר ב-index.html, לא בקובץ הזה + files: ['test/e2e.js'], + languageOptions: { + globals: { + tglShl: 'readonly', + }, + }, + }, +]; diff --git a/package.json b/package.json index 81b4b50..4c894c9 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "eslint": "^9.0.0", + "globals": "^17.12.0", "playwright": "^1.62.1" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1672ac..e61e7f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: eslint: specifier: ^9.0.0 version: 9.39.5(supports-color@7.2.0) + globals: + specifier: ^17.12.0 + version: 17.12.0 playwright: specifier: ^1.62.1 version: 1.62.1 @@ -245,6 +248,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@17.12.0: + resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} + engines: {node: '>=18'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -668,6 +675,8 @@ snapshots: globals@14.0.0: {} + globals@17.12.0: {} + has-flag@4.0.0: {} http_ece@1.2.0: diff --git a/server.js b/server.js index b42eea3..675e8f6 100644 --- a/server.js +++ b/server.js @@ -376,7 +376,7 @@ function adminPage() { return `
// Optional TOTP 2FA (RFC 6238) — active only if ADMIN_TOTP_SECRET is set, so existing deployments // see no behavior change. When enabled, the Basic Auth password becomes ADMIN_PASS + the current // 6-digit code (e.g. "hunter2483726"), compatible with any standard authenticator app. -function base32Decode(str) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = '', bytes = []; for (const c of String(str).replace(/=+$/, '').toUpperCase()) { const val = alphabet.indexOf(c); if (val === -1) continue; bits += val.toString(2).padStart(5, '0'); } for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2)); return Buffer.from(bytes); } +function base32Decode(str) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = ''; const bytes = []; for (const c of String(str).replace(/=+$/, '').toUpperCase()) { const val = alphabet.indexOf(c); if (val === -1) continue; bits += val.toString(2).padStart(5, '0'); } for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2)); return Buffer.from(bytes); } function totpAt(secretB32, timeStep) { const key = base32Decode(secretB32); const buf = Buffer.alloc(8); buf.writeBigUInt64BE(BigInt(timeStep)); const hmac = crypto.createHmac('sha1', key).update(buf).digest(); const offset = hmac[hmac.length - 1] & 0xf; const code = ((hmac[offset] & 0x7f) << 24 | (hmac[offset + 1] & 0xff) << 16 | (hmac[offset + 2] & 0xff) << 8 | (hmac[offset + 3] & 0xff)) % 1000000; return String(code).padStart(6, '0'); } function verifyTOTP(secretB32, token) { if (!/^\d{6}$/.test(token || '')) return false; const step = Math.floor(Date.now() / 30000); for (const drift of [-1, 0, 1]) if (safeEqual(totpAt(secretB32, step + drift), token)) return true; return false; } // Plain === short-circuits on the first mismatched byte, which in principle leaks a timing @@ -532,7 +532,7 @@ const server = http.createServer(async (req, res) => { if (p === '/api/stream') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' }); const a = [...activeAlerts.values()]; res.write(`data: ${JSON.stringify({ type: 'init', alerts: a })}\n\n`); - let ls = JSON.stringify(a.map(a => a.id)); const cl = { res, ls }; sseClients.add(cl); track(p, 200); + const ls = JSON.stringify(a.map(a => a.id)); const cl = { res, ls }; sseClients.add(cl); track(p, 200); const iv = setInterval(() => { try { const c = [...activeAlerts.values()]; const ids = JSON.stringify(c.map(a => a.id)); if (ids !== cl.ls) { res.write(`data: ${JSON.stringify({ type: 'update', alerts: c })}\n\n`); cl.ls = ids; } else res.write(`: hb\n\n`); } catch { clearInterval(iv); sseClients.delete(cl); } }, 2000); req.on('close', () => { clearInterval(iv); sseClients.delete(cl); }); return; }