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>
128 lines
4.6 KiB
JavaScript
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;
|