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:
@@ -65,37 +65,9 @@ const app = new Hono<AppEnv>()
|
||||
})
|
||||
.onError((err, c) => {
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ error: err.message }, 500);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
})
|
||||
.get('/health', (c) => c.json({ status: 'ok' }))
|
||||
.get('/migrate', async (c) => {
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
const pathMod = await import('path');
|
||||
const { sql: dsql } = await import('drizzle-orm');
|
||||
const folder = pathMod.resolve(process.cwd(), 'packages/db/src/migrations');
|
||||
const journal = JSON.parse(fs.readFileSync(pathMod.join(folder, 'meta', '_journal.json'), 'utf8'));
|
||||
const results: string[] = [];
|
||||
for (const entry of journal.entries) {
|
||||
const sqlFile = pathMod.join(folder, `${entry.tag}.sql`);
|
||||
if (!fs.existsSync(sqlFile)) { results.push(`skip: ${entry.tag} (not found)`); continue; }
|
||||
const rawSql = fs.readFileSync(sqlFile, 'utf8');
|
||||
const statements = rawSql.split('--> statement-breakpoint').map((s: string) => s.trim()).filter(Boolean);
|
||||
for (const stmt of statements) {
|
||||
try {
|
||||
await db.execute(dsql.raw(stmt));
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('already exists') || err.message?.includes('duplicate')) continue;
|
||||
results.push(`error in ${entry.tag}: ${err.message?.slice(0, 150)}`);
|
||||
}
|
||||
}
|
||||
results.push(`ok: ${entry.tag}`);
|
||||
}
|
||||
return c.json({ status: 'ok', results });
|
||||
} catch (err: any) {
|
||||
return c.json({ status: 'error', message: err.message }, 500);
|
||||
}
|
||||
})
|
||||
.basePath('/api/v1')
|
||||
.route('/auth', authRoutes)
|
||||
.route('/projects', projectRoutes)
|
||||
|
||||
@@ -16,6 +16,7 @@ async function createSession(c: any, db: any, userId: string) {
|
||||
setCookie(c, 'session', sessionToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
@@ -64,11 +65,12 @@ export const authRoutes = new Hono<AppEnv>()
|
||||
const db = c.get('db');
|
||||
|
||||
const token = generateToken();
|
||||
const tokenHash = await hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min
|
||||
|
||||
await db.insert(magicLinks).values({
|
||||
email,
|
||||
token,
|
||||
token: tokenHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
@@ -81,10 +83,11 @@ export const authRoutes = new Hono<AppEnv>()
|
||||
const { token } = c.req.valid('json');
|
||||
const db = c.get('db');
|
||||
|
||||
const tokenHash = await hashToken(token);
|
||||
const [link] = await db
|
||||
.select()
|
||||
.from(magicLinks)
|
||||
.where(eq(magicLinks.token, token))
|
||||
.where(eq(magicLinks.token, tokenHash))
|
||||
.limit(1);
|
||||
|
||||
if (!link || link.expiresAt < new Date() || link.usedAt) {
|
||||
|
||||
@@ -158,15 +158,29 @@ export const commentRoutes = new Hono<AppEnv>()
|
||||
// Resolve comment
|
||||
.post('/:id/resolve', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const commentId = c.req.param('id');
|
||||
|
||||
const [comment] = await db.select().from(comments).where(eq(comments.id, commentId)).limit(1);
|
||||
if (!comment) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [version] = await db.select().from(versions).where(eq(versions.id, comment.versionId)).limit(1);
|
||||
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 || (!membership.canComment && !membership.canApprove)) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(comments)
|
||||
.set({ resolvedAt: new Date() })
|
||||
.where(eq(comments.id, commentId))
|
||||
.returning();
|
||||
|
||||
if (!updated) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
return c.json({ comment: updated });
|
||||
});
|
||||
|
||||
@@ -266,7 +266,7 @@ export const projectRoutes = new Hono<AppEnv>()
|
||||
const [updated] = await db
|
||||
.update(projectMembers)
|
||||
.set({ role: newRole, ...defaults })
|
||||
.where(eq(projectMembers.id, memberId))
|
||||
.where(and(eq(projectMembers.id, memberId), eq(projectMembers.projectId, projectId)))
|
||||
.returning();
|
||||
|
||||
return c.json({ member: updated });
|
||||
@@ -294,7 +294,7 @@ export const projectRoutes = new Hono<AppEnv>()
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await db.delete(projectMembers).where(eq(projectMembers.id, memberId));
|
||||
await db.delete(projectMembers).where(and(eq(projectMembers.id, memberId), eq(projectMembers.projectId, projectId)));
|
||||
return c.json({ message: 'Member removed' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user