diff --git a/server.js b/server.js index 98d1497b..743ed118 100644 --- a/server.js +++ b/server.js @@ -88,7 +88,7 @@ if (!process.env.JWT_SECRET) { process.exit(1); } const JWT_SECRET = process.env.JWT_SECRET; -const JWT_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days +const JWT_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days // WebAuthn relying party config (from env) const WEBAUTHN_RP_NAME = process.env.WEBAUTHN_RP_NAME || 'Posimai'; @@ -760,13 +760,41 @@ function getTogetherJwtUserId(req) { return getOptionalJwtUserIdFromHeader(req); } +function normalizeTogetherUsername(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +async function resolveTogetherUserCandidates(pool, username, jwtUserId) { + const candidates = []; + const normalizedUsername = normalizeTogetherUsername(username); + if (normalizedUsername) candidates.push(normalizedUsername); + if (jwtUserId) candidates.push(jwtUserId); + if (jwtUserId) { + const userResult = await pool.query( + 'SELECT user_id, name FROM users WHERE user_id=$1 LIMIT 1', + [jwtUserId] + ); + const user = userResult.rows[0]; + if (user) { + const userId = normalizeTogetherUsername(user.user_id); + const userName = normalizeTogetherUsername(user.name); + if (userId) candidates.push(userId); + if (userName) candidates.push(userName); + } + } + return [...new Set(candidates)]; +} + /** * Together メンバー確認。JWT なしのときは従来どおり username のみ(完全互換)。 * JWT ありのときは user_id 一致、または未紐付けメンバーで username が JWT ユーザーの user_id/name と一致する場合は厳格一致。 * それ以外の未紐付けニックネームは従来クエリで許可し warn ログ(運用互換)。 */ async function togetherEnsureMember(pool, res, groupId, username, jwtUserId) { - if (!username || typeof username !== 'string') { + const usernames = await resolveTogetherUserCandidates(pool, username, jwtUserId); + if (usernames.length === 0) { res.status(400).json({ error: 'username が不正です' }); return false; } @@ -783,32 +811,29 @@ async function togetherEnsureMember(pool, res, groupId, username, jwtUserId) { m.user_id = $2 OR ( (m.user_id IS NULL OR btrim(COALESCE(m.user_id, '')) = '') - AND m.username = $3 - AND EXISTS ( - SELECT 1 FROM users u - WHERE u.user_id = $2 AND (u.user_id = $3 OR u.name = $3) - ) + AND m.username = ANY($3::text[]) ) )`, - [gidNum, jwtUserId, username] + [gidNum, jwtUserId, usernames] ); if (strict.rows.length > 0) return true; const legacy = await pool.query( - 'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2', - [gidNum, username] + 'SELECT 1 FROM together_members WHERE group_id=$1 AND username = ANY($2::text[])', + [gidNum, usernames] ); if (legacy.rows.length > 0) { // user_id 未紐付け期間の暫定: メンバー行があれば許可(紐付け完了後に削除予定) - console.warn('[Together] legacy path used user=%s username=%s group=%s', jwtUserId, username, gidNum); + console.warn('[Together] legacy path used user=%s usernames=%j group=%s', jwtUserId, usernames, gidNum); return true; } res.status(403).json({ error: 'グループのメンバーではありません' }); return false; } + const primaryUsername = usernames[0]; const legacyOnly = await pool.query( 'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2', - [gidNum, username] + [gidNum, primaryUsername] ); if (legacyOnly.rows.length === 0) { res.status(403).json({ error: 'グループのメンバーではありません' }); @@ -2388,7 +2413,7 @@ ${excerpt} res.send(audioBuffer); } catch (e) { console.error('[TTS]', e.message); - res.status(503).json({ error: 'TTS_UNAVAILABLE', detail: e.message }); + res.status(503).json({ error: 'TTS_UNAVAILABLE' }); } finally { ttsBusy = false; } @@ -2421,7 +2446,8 @@ ${excerpt} const speakers = await r2.json(); res.json(speakers); } catch (e) { - res.status(503).json({ error: 'TTS_UNAVAILABLE', detail: e.message }); + console.error('[TTS/speakers]', e.message); + res.status(503).json({ error: 'TTS_UNAVAILABLE' }); } }); @@ -2589,6 +2615,15 @@ ${excerpt} r.post('/together/groups', async (req, res) => { const { name, username } = req.body || {}; if (!name || !username) return res.status(400).json({ error: 'name と username は必須です' }); + // JWT が提示されている場合、body の username と一致するか確認(なりすまし防止) + const jwtUserId = getTogetherJwtUserId(req); + if (jwtUserId) { + const requested = normalizeTogetherUsername(username); + const jwtCandidates = await resolveTogetherUserCandidates(pool, null, jwtUserId); + if (!requested || !jwtCandidates.includes(requested)) { + return res.status(403).json({ error: '認証情報とユーザー名が一致しません' }); + } + } if (!checkRateLimit('together_create', req.ip || 'unknown', 5, 60 * 60 * 1000)) { return res.status(429).json({ error: 'グループ作成の上限に達しました。1時間後に再試行してください' }); } @@ -2619,6 +2654,15 @@ ${excerpt} r.post('/together/join', async (req, res) => { const { invite_code, username } = req.body || {}; if (!invite_code || !username) return res.status(400).json({ error: 'invite_code と username は必須です' }); + // JWT が提示されている場合、body の username と一致するか確認(なりすまし防止) + const jwtUserId = getTogetherJwtUserId(req); + if (jwtUserId) { + const requested = normalizeTogetherUsername(username); + const jwtCandidates = await resolveTogetherUserCandidates(pool, null, jwtUserId); + if (!requested || !jwtCandidates.includes(requested)) { + return res.status(403).json({ error: '認証情報とユーザー名が一致しません' }); + } + } if (!checkRateLimit('together_join', req.ip || 'unknown', 10, 60 * 60 * 1000)) { return res.status(429).json({ error: '参加試行回数が上限に達しました。1時間後に再試行してください' }); } @@ -2642,8 +2686,7 @@ ${excerpt} // GET /together/groups/:groupId — グループ情報(メンバーのみ) r.get('/together/groups/:groupId', async (req, res) => { if (!/^[a-zA-Z0-9_-]+$/.test(req.params.groupId)) return res.status(400).json({ error: 'invalid groupId' }); - const username = req.query.u; - if (!username) return res.status(400).json({ error: 'u (username) は必須です' }); + const username = normalizeTogetherUsername(req.query.u); const jwtUserId = getTogetherJwtUserId(req); try { if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return; @@ -2657,8 +2700,7 @@ ${excerpt} // GET /together/members/:groupId — メンバー一覧 ?u=username r.get('/together/members/:groupId', async (req, res) => { - const username = req.query.u; - if (!username) return res.status(400).json({ error: 'u (username) は必須です' }); + const username = normalizeTogetherUsername(req.query.u); const jwtUserId = getTogetherJwtUserId(req); try { if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return; @@ -2676,21 +2718,24 @@ ${excerpt} // POST /together/share — 記事・テキストをシェア(即返却 + 非同期アーカイブ) r.post('/together/share', async (req, res) => { const { group_id, shared_by, url = null, title = null, message = '', tags = [] } = req.body || {}; - if (!group_id || !shared_by) return res.status(400).json({ error: 'group_id と shared_by は必須です' }); + if (!group_id) return res.status(400).json({ error: 'group_id は必須です' }); if (url) { if (!isSsrfSafe(url)) return res.status(400).json({ error: 'url は http/https のみ有効です' }); } const jwtUserId = getTogetherJwtUserId(req); try { + const candidates = await resolveTogetherUserCandidates(pool, shared_by, jwtUserId); + const actor = candidates[0] || null; + if (!actor) return res.status(400).json({ error: 'shared_by が不正です' }); const grpCheck = await pool.query('SELECT id FROM together_groups WHERE id=$1', [group_id]); if (grpCheck.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' }); - if (!(await togetherEnsureMember(pool, res, group_id, shared_by, jwtUserId))) return; + if (!(await togetherEnsureMember(pool, res, group_id, actor, jwtUserId))) return; const result = await pool.query( `INSERT INTO together_shares (group_id, shared_by, url, title, message, tags) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, - [group_id, shared_by, url, title, message, tags] + [group_id, actor, url, title, message, tags] ); const share = result.rows[0]; res.json({ ok: true, share }); @@ -2698,7 +2743,7 @@ ${excerpt} // URL がある場合のみ非同期アーカイブ(ユーザーを待たせない) // shared_by ごとに 20回/時間 の Gemini 呼び出し制限 if (url) { - if (checkRateLimit('together_archive', shared_by, 20, 60 * 60 * 1000)) { + if (checkRateLimit('together_archive', actor, 20, 60 * 60 * 1000)) { archiveShare(share.id, url); } else { pool.query(`UPDATE together_shares SET archive_status='skipped' WHERE id=$1`, [share.id]).catch(() => {}); @@ -2734,8 +2779,7 @@ ${excerpt} // GET /together/feed/:groupId — フィード(リアクション付き) // ?u=username&limit=N&cursor= r.get('/together/feed/:groupId', async (req, res) => { - const username = req.query.u; - if (!username) return res.status(400).json({ error: 'u (username) は必須です' }); + const username = normalizeTogetherUsername(req.query.u); const jwtUserId = getTogetherJwtUserId(req); try { if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return; @@ -2751,6 +2795,9 @@ ${excerpt} const result = await pool.query(` SELECT s.*, + s.full_content AS full_text, + COALESCE(s.tags, '{}') AS topics, + (s.full_content IS NOT NULL AND btrim(s.full_content) <> '') AS has_full_text, COALESCE( json_agg(DISTINCT jsonb_build_object('username', r.username, 'type', r.type)) FILTER (WHERE r.username IS NOT NULL), '[]' @@ -2775,14 +2822,26 @@ ${excerpt} // GET /together/article/:shareId — アーカイブ本文取得 ?u=username r.get('/together/article/:shareId', async (req, res) => { - const username = req.query.u; - if (!username) return res.status(400).json({ error: 'u (username) は必須です' }); + const username = normalizeTogetherUsername(req.query.u); const jwtUserId = getTogetherJwtUserId(req); try { if (!(await togetherEnsureMemberForShare(pool, res, req.params.shareId, username, jwtUserId))) return; const result = await pool.query( - 'SELECT id, title, url, full_content, summary, archive_status, shared_at FROM together_shares WHERE id=$1', + `SELECT + id, + title, + url, + full_content, + full_content AS full_text, + summary, + COALESCE(tags, '{}') AS tags, + COALESCE(tags, '{}') AS topics, + (full_content IS NOT NULL AND btrim(full_content) <> '') AS has_full_text, + archive_status, + shared_at + FROM together_shares + WHERE id=$1`, [req.params.shareId] ); if (result.rows.length === 0) return res.status(404).json({ error: '見つかりません' }); @@ -2826,8 +2885,7 @@ ${excerpt} // GET /together/comments/:shareId — コメント一覧 ?u=username r.get('/together/comments/:shareId', async (req, res) => { - const username = req.query.u; - if (!username) return res.status(400).json({ error: 'u (username) は必須です' }); + const username = normalizeTogetherUsername(req.query.u); const jwtUserId = getTogetherJwtUserId(req); try { if (!(await togetherEnsureMemberForShare(pool, res, req.params.shareId, username, jwtUserId))) return; @@ -2866,7 +2924,6 @@ ${excerpt} // GET /together/search/:groupId — キーワード / タグ検索 ?u=username r.get('/together/search/:groupId', async (req, res) => { const { q = '', tag = '', u: username = '' } = req.query; - if (!username) return res.status(400).json({ error: 'u (username) は必須です' }); const jwtUserId = getTogetherJwtUserId(req); if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return; if (!q && !tag) return res.status(400).json({ error: 'q または tag が必要です' }); @@ -2987,7 +3044,7 @@ ${excerpt} }); }); req2.on('timeout', () => { req2.destroy(); res.status(500).json({ error: 'Timeout' }); }); - req2.on('error', (e) => { console.error('[proxy] error:', e.code, e.message); res.status(500).json({ error: 'Proxy error', code: e.code }); }); + req2.on('error', (e) => { console.error('[proxy] error:', e.code, e.message); res.status(500).json({ error: 'Proxy error' }); }); req2.end(); });