diff --git a/server.js b/server.js index 7a868c98..0dd86baa 100644 --- a/server.js +++ b/server.js @@ -20,6 +20,8 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const jwt = require('jsonwebtoken'); +const os = require('os'); +const { execSync } = require('child_process'); // ── Auth: WebAuthn (ESM dynamic import) ───────────────────────────── let webauthn = null; @@ -181,6 +183,15 @@ function extractSource(url) { } catch { return 'unknown'; } } +// ── 文字コード正規化 ───────────────────────── +function normalizeCharset(raw) { + const e = (raw || '').toLowerCase().replace(/['"]/g, '').trim(); + if (['shift_jis', 'shift-jis', 'sjis', 'x-sjis', 'ms_kanji', 'ms932', 'windows-31j', 'csshiftjis'].includes(e)) return 'shift_jis'; + if (['euc-jp', 'euc_jp', 'x-euc-jp', 'cseucpkdfmtjapanese'].includes(e)) return 'euc-jp'; + if (e.startsWith('utf')) return 'utf-8'; + return 'utf-8'; +} + // ── OGP フェッチ ─────────────────────────── async function fetchMeta(url) { try { @@ -191,15 +202,27 @@ async function fetchMeta(url) { if (!res.ok) throw new Error(`HTTP ${res.status}`); const buffer = await res.arrayBuffer(); - // 文字コード判定(先頭2000バイトからcharsetを探す) - const headSnippet = new TextDecoder('utf-8', { fatal: false }).decode(buffer.slice(0, 2000)); + // 文字コード判定: 1) Content-Typeヘッダー優先 2) HTMLメタタグ確認 + // iso-8859-1はバイト値0-255をロスレスでデコードするためcharset検出に最適 let encoding = 'utf-8'; - const charsetMatch = headSnippet.match(/charset=["']?(shift_jis|euc-jp|utf-8)["']?/i); - if (charsetMatch && charsetMatch[1]) { - encoding = charsetMatch[1].toLowerCase(); + const contentType = res.headers.get('content-type') || ''; + const ctMatch = contentType.match(/charset=([^\s;]+)/i); + if (ctMatch) { + encoding = normalizeCharset(ctMatch[1]); + } + if (encoding === 'utf-8') { + const headSnippet = new TextDecoder('iso-8859-1').decode(new Uint8Array(buffer).slice(0, 2000)); + const metaMatch = headSnippet.match(/charset=["']?([^"'\s;>]+)/i); + if (metaMatch) encoding = normalizeCharset(metaMatch[1]); } - const html = new TextDecoder(encoding).decode(buffer); + let html; + try { + html = new TextDecoder(encoding).decode(buffer); + } catch { + // TextDecoder が対象エンコーディング非対応の場合は UTF-8 フォールバック + html = new TextDecoder('utf-8', { fatal: false }).decode(buffer); + } const doc = parse(html); const og = (p) => doc.querySelector(`meta[property="${p}"]`)?.getAttribute('content') || ''; @@ -571,9 +594,30 @@ async function initDB() { function buildRouter() { const r = express.Router(); - // ヘルスチェック + // ヘルスチェック(Station コックピット向けに拡張) r.get('/health', (req, res) => { - res.json({ status: 'ok', gemini: !!genAI, users: Object.values(KEY_MAP).length }); + res.setHeader('Access-Control-Allow-Origin', '*'); + const mem = os.freemem(), total = os.totalmem(); + let disk = null; + try { + const df = execSync('df -B1 / 2>/dev/null', { timeout: 2000 }).toString(); + const p = df.trim().split('\n')[1].split(/\s+/); + disk = { total_gb: Math.round(parseInt(p[1])/1e9*10)/10, used_gb: Math.round(parseInt(p[2])/1e9*10)/10, use_pct: Math.round(parseInt(p[2])/parseInt(p[1])*100) }; + } catch(_) {} + res.json({ + status: 'ok', + gemini: !!genAI, + users: Object.values(KEY_MAP).length, + hostname: os.hostname(), + uptime_s: Math.floor(os.uptime()), + load_avg: os.loadavg().map(l => Math.round(l * 100) / 100), + mem_used_mb: Math.round((total - mem) / 1024 / 1024), + mem_total_mb: Math.round(total / 1024 / 1024), + disk, + platform: os.platform(), + node_version: process.version, + timestamp: new Date().toISOString(), + }); }); // 認証テスト (UI用)