fix(together): restore reader and AI metadata after mobile share

Allow Together endpoints to resolve username from JWT-backed candidates and return legacy-compatible feed/article fields so reader icon, summary, and category tags render without reload.

Made-with: Cursor
This commit is contained in:
posimai 2026-04-20 20:49:09 +09:00
parent 0990385b89
commit af51a75244
1 changed files with 88 additions and 31 deletions

119
server.js
View File

@ -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=<ISO timestamp>
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();
});