fix: security hardening and stem multi-select

- Remove public /migrate endpoint (startup migration handles it)
- Add membership + canUpload check to POST /versions/track/:trackId
- Add membership check to stream-url, download-url, waveform endpoints
- Scope member PATCH/DELETE to projectId to prevent cross-project mutation
- Add auth + membership check to POST /comments/:id/resolve
- Add secure: true to session cookie in production
- Hash magic link tokens before storing (was plaintext)
- Return generic error message instead of err.message
- Fix stem multi-file-select: replace hidden attr with CSS offscreen
  (Safari/WebKit drops multiple selection on display:none file inputs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-16 21:04:22 +02:00
parent afcb818dd4
commit c949d6b829
6 changed files with 72 additions and 56 deletions

View File

@@ -81,6 +81,23 @@ export const versionRoutes = new Hono<AppEnv>()
const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1);
if (!track) return c.json({ error: 'Not found' }, 404);
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(eq(projectMembers.projectId, track.projectId), eq(projectMembers.userId, userId)),
)
.limit(1);
if (!membership || !membership.canUpload) {
return c.json({ error: 'Forbidden' }, 403);
}
const expectedPrefix = `projects/${track.projectId}/tracks/${trackId}/`;
if (!input.fileKey.startsWith(expectedPrefix)) {
return c.json({ error: 'Forbidden' }, 403);
}
// Get next version number
const [latest] = await db
.select({ maxVersion: sql<number>`coalesce(max(${versions.versionNumber}), 0)` })
@@ -269,16 +286,20 @@ export const versionRoutes = new Hono<AppEnv>()
// Get stream URL
.get('/:id/stream-url', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const [track] = await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership) return c.json({ error: 'Not found' }, 404);
const key = version.streamFileKey || version.originalFileKey;
const url = await createDownloadUrl(key);
@@ -288,16 +309,20 @@ export const versionRoutes = new Hono<AppEnv>()
// Get download URL
.get('/:id/download-url', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const [track] = await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership) return c.json({ error: 'Not found' }, 404);
const url = await createDownloadUrl(version.originalFileKey);
return c.json({ url });
})
@@ -305,17 +330,19 @@ export const versionRoutes = new Hono<AppEnv>()
// Get waveform data
.get('/:id/waveform', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1);
if (!version || !version.waveformDataKey) return c.json({ error: 'Not found' }, 404);
if (!version || !version.waveformDataKey) {
return c.json({ error: 'Not found' }, 404);
}
const [track] = await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership) return c.json({ error: 'Not found' }, 404);
const url = await createDownloadUrl(version.waveformDataKey);
return c.json({ url });