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()}>
-

+
@@ -122,19 +134,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
{(isPdf || isTxt) ? (
-
@@ -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 ?
: 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 ? (
-
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);
+ });
});
// ─────────────────────────────────────────────────────────────────────────────