dragonflight/services/mam-api/src/routes/comments.js
Zac 2615143c6d feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.

- recorders: param('id') resolves project + view baseline, requireRecorderEdit
  on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
  asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
  and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
  to write); also fixes the author bug (req.session.userId -> req.user.id, which
  was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
  registration) — TODO replaced with a rationale note

Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
  asset into a project the caller lacks edit on — now asserts edit on the
  override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
  could pull in (and via GET /:id leak signed proxy URLs for) assets from a
  project they can't access — now every clip asset must belong to the
  sequence's project; pre-transaction queries moved inside try/catch so a DB
  error returns 500 instead of hanging the request

- tests: recorders-access, sequences-access (incl. cross-project clip guard),
  comments-access (incl. author-id regression)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 03:48:02 +00:00

128 lines
4.6 KiB
JavaScript

// Asset-scoped comments for the Asset Detail page.
//
// Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router).
// Express's :assetId param flows through from the parent mount.
import express from 'express';
import pool from '../db/pool.js';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router({ mergeParams: true });
// Scope every comment route to the parent asset's project: resolve project_id
// via the asset, then require 'view' to read and 'edit' to write. A non-UUID or
// unknown asset is a clean 404 before any access decision leaks its existence.
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
router.use(async (req, res, next) => {
try {
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.assetId]);
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
await assertProjectAccess(req.user, rows[0].project_id, MUTATING.has(req.method) ? 'edit' : 'view');
next();
} catch (err) { next(err); }
});
function rowToJson(r) {
return {
id: r.id,
asset_id: r.asset_id,
user_id: r.user_id,
body: r.body,
frame_ms: r.frame_ms,
resolved: r.resolved,
created_at: r.created_at,
updated_at: r.updated_at,
author_name: r.author_name || null,
author_initials: r.author_initials || null,
};
}
// GET /api/v1/assets/:assetId/comments
router.get('/', async (req, res, next) => {
try {
const { assetId } = req.params;
const result = await pool.query(
`SELECT c.*,
u.display_name AS author_name,
UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials
FROM asset_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.asset_id = $1
ORDER BY c.created_at ASC`,
[assetId]
);
res.json({ comments: result.rows.map(rowToJson) });
} catch (err) { next(err); }
});
// POST /api/v1/assets/:assetId/comments
router.post('/', async (req, res, next) => {
try {
const { assetId } = req.params;
const { body, frame_ms } = req.body || {};
if (!body || !String(body).trim()) {
return res.status(400).json({ error: 'body is required' });
}
// Author is the authenticated user (requireAuth sets req.user for both
// session and bearer auth, and the dev user when AUTH_ENABLED=false).
const userId = req.user?.id || null;
const ins = await pool.query(
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[assetId, userId, String(body).trim(), frame_ms != null ? Math.max(0, Math.round(Number(frame_ms))) : null]
);
// Re-fetch with the author join so the response has the same shape as list.
const result = await pool.query(
`SELECT c.*,
u.display_name AS author_name,
UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials
FROM asset_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.id = $1`,
[ins.rows[0].id]
);
res.status(201).json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// PATCH /api/v1/assets/:assetId/comments/:id
router.patch('/:id', async (req, res, next) => {
try {
const { id, assetId } = req.params;
const { body, resolved } = req.body || {};
const fields = [];
const values = [];
let i = 1;
if (body !== undefined) { fields.push(`body = $${i++}`); values.push(String(body).trim()); }
if (resolved !== undefined) { fields.push(`resolved = $${i++}`); values.push(!!resolved); }
if (fields.length === 0) return res.status(400).json({ error: 'Nothing to update' });
fields.push('updated_at = NOW()');
values.push(id, assetId);
const result = await pool.query(
`UPDATE asset_comments SET ${fields.join(', ')}
WHERE id = $${i++} AND asset_id = $${i}
RETURNING *`,
values
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' });
res.json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// DELETE /api/v1/assets/:assetId/comments/:id
router.delete('/:id', async (req, res, next) => {
try {
const { id, assetId } = req.params;
const result = await pool.query(
`DELETE FROM asset_comments WHERE id = $1 AND asset_id = $2 RETURNING id`,
[id, assetId]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' });
res.json({ id });
} catch (err) { next(err); }
});
export default router;