diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 1486b55..933ca84 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -5,6 +5,7 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react' import { collabApi } from '../../api/client' +import { getAuthUrl } from '../../api/authUrl' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { addListener, removeListener } from '../../api/websocket' @@ -96,22 +97,33 @@ interface FilePreviewPortalProps { } function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { + const [authUrl, setAuthUrl] = useState('') + const rawUrl = file?.url || '' + useEffect(() => { + if (!rawUrl) return + getAuthUrl(rawUrl, 'download').then(setAuthUrl) + }, [rawUrl]) + if (!file) return null - const url = file.url || `/uploads/${file.filename}` const isImage = file.mime_type?.startsWith('image/') const isPdf = file.mime_type === 'application/pdf' const isTxt = file.mime_type?.startsWith('text/') + const openInNewTab = async () => { + const u = await getAuthUrl(rawUrl, 'download') + window.open(u, '_blank', 'noreferrer') + } + return ReactDOM.createPortal(
{isImage ? ( /* Image lightbox — floating controls */
e.stopPropagation()}> - {file.original_name} + {file.original_name}
{file.original_name}
- +
@@ -122,19 +134,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
{file.original_name}
- +
{(isPdf || isTxt) ? ( - +

- Download +

) : (
- Download {file.original_name} +
)} @@ -144,6 +156,14 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { ) } +function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; alt?: string }) { + const [authSrc, setAuthSrc] = useState('') + useEffect(() => { + getAuthUrl(src, 'download').then(setAuthSrc) + }, [src]) + return authSrc ? {alt} : null +} + const NOTE_COLORS = [ { value: '#6366f1', label: 'Indigo' }, { value: '#ef4444', label: 'Red' }, @@ -460,7 +480,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
{t('collab.notes.attachFiles')}
- { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} /> + { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
{/* Existing attachments (edit mode) */} {existingAttachments.map(a => { @@ -484,10 +504,10 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
))} - + } @@ -845,7 +865,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi const isImage = a.mime_type?.startsWith('image/') const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?' return isImage ? ( - {a.original_name} onPreviewFile?.(a)} onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} @@ -974,7 +994,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) { for (const file of pendingFiles) { const fd = new FormData() fd.append('file', file) - try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch {} + try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) } } // Reload note with attachments const fresh = await collabApi.getNotes(tripId) diff --git a/server/src/services/collabService.ts b/server/src/services/collabService.ts index cb5af9a..e592f67 100644 --- a/server/src/services/collabService.ts +++ b/server/src/services/collabService.ts @@ -101,7 +101,7 @@ export function formatNote(note: CollabNote) { return { ...note, avatar_url: avatarUrl(note), - attachments: attachments.map(a => ({ ...a, url: `/uploads/${a.filename}` })), + attachments: attachments.map(a => ({ ...a, url: `/api/trips/${note.trip_id}/files/${a.id}/download` })), }; } @@ -190,7 +190,7 @@ export function addNoteFile(tripId: string | number, noteId: string | number, fi ).run(tripId, noteId, `files/${file.filename}`, file.originalname, file.size, file.mimetype); const saved = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid) as TripFile; - return { file: { ...saved, url: `/uploads/${saved.filename}` } }; + return { file: { ...saved, url: `/api/trips/${tripId}/files/${saved.id}/download` } }; } export function getFormattedNoteById(noteId: string | number) { diff --git a/server/tests/integration/collab.test.ts b/server/tests/integration/collab.test.ts index c6391bb..c77bf44 100644 --- a/server/tests/integration/collab.test.ts +++ b/server/tests/integration/collab.test.ts @@ -47,7 +47,7 @@ import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; import { createUser, createTrip, addTripMember } from '../helpers/factories'; -import { authCookie } from '../helpers/auth'; +import { authCookie, generateToken } from '../helpers/auth'; import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; const app: Application = createApp(); @@ -249,6 +249,103 @@ describe('Collab notes', () => { expect(del.status).toBe(200); expect(del.body.success).toBe(true); }); + + it('COLLAB-028 — uploaded note file URL uses authenticated download path, not /uploads/', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const create = await request(app) + .post(`/api/trips/${trip.id}/collab/notes`) + .set('Cookie', authCookie(user.id)) + .send({ title: 'URL check' }); + const noteId = create.body.note.id; + + const upload = await request(app) + .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) + .set('Cookie', authCookie(user.id)) + .attach('file', FIXTURE_PDF); + expect(upload.status).toBe(201); + + const fileUrl = upload.body.file.url; + expect(fileUrl).toMatch(/^\/api\/trips\/\d+\/files\/\d+\/download$/); + expect(fileUrl).not.toContain('/uploads/'); + }); + + it('COLLAB-029 — note attachments in listing use authenticated download URLs', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const create = await request(app) + .post(`/api/trips/${trip.id}/collab/notes`) + .set('Cookie', authCookie(user.id)) + .send({ title: 'List URL check' }); + const noteId = create.body.note.id; + + await request(app) + .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) + .set('Cookie', authCookie(user.id)) + .attach('file', FIXTURE_PDF); + + const list = await request(app) + .get(`/api/trips/${trip.id}/collab/notes`) + .set('Cookie', authCookie(user.id)); + expect(list.status).toBe(200); + + const note = list.body.notes.find((n: any) => n.id === noteId); + expect(note.attachments.length).toBe(1); + expect(note.attachments[0].url).toMatch(/^\/api\/trips\/\d+\/files\/\d+\/download$/); + expect(note.attachments[0].url).not.toContain('/uploads/'); + }); + + it('COLLAB-030 — note file is downloadable via files endpoint with ephemeral token', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const create = await request(app) + .post(`/api/trips/${trip.id}/collab/notes`) + .set('Cookie', authCookie(user.id)) + .send({ title: 'Downloadable note' }); + const noteId = create.body.note.id; + + const upload = await request(app) + .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) + .set('Cookie', authCookie(user.id)) + .attach('file', FIXTURE_PDF); + const fileUrl = upload.body.file.url; + + // Obtain an ephemeral resource token (same flow as getAuthUrl on the client) + const tokenRes = await request(app) + .post('/api/auth/resource-token') + .set('Cookie', authCookie(user.id)) + .send({ purpose: 'download' }); + expect(tokenRes.status).toBe(200); + const { token } = tokenRes.body; + + // Download with ?token= should succeed + const dl = await request(app).get(`${fileUrl}?token=${token}`); + expect(dl.status).toBe(200); + }); + + it('COLLAB-031 — note file download without auth returns 401', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const create = await request(app) + .post(`/api/trips/${trip.id}/collab/notes`) + .set('Cookie', authCookie(user.id)) + .send({ title: 'Auth required note' }); + const noteId = create.body.note.id; + + const upload = await request(app) + .post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`) + .set('Cookie', authCookie(user.id)) + .attach('file', FIXTURE_PDF); + const fileUrl = upload.body.file.url; + + // Download without any auth should fail + const dl = await request(app).get(fileUrl); + expect(dl.status).toBe(401); + }); }); // ─────────────────────────────────────────────────────────────────────────────