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:
parent
0990385b89
commit
af51a75244
119
server.js
119
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=<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();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue