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