fix(collab): keep every panel on screen, and stop leaking spool and links

The desktop layout rendered notes and links as a pair and dropped whichever of
the two was on its own, so turning the new Links feature off on an otherwise
default install made the Notes panel disappear. Both rows are now split by kind
and rendered only when they hold something, and one function decides what a
panel id renders, because the branch that lost a panel did so by not listing it.

The message and its attachment rows are written in one transaction. The caller
has already committed the image bytes by then, so a message that landed without
them left those bytes with nothing pointing at them and nothing to sweep them.
Deleting one now drops the blobs after the rows are gone rather than before.

A body the Zod pipe rejects used to leave up to four spooled images on disk:
pipes run after interceptors, so multer had already written them and the
handler's own cleanup was never reached. An interceptor removes them on any
failure before the handler, which also covers every other schema mismatch, and
the length check that could never fire is gone.

Adding, pinning and deleting a link now says something when it fails instead of
raising an unhandled rejection, the list has real types, and the rejected-image
toast is translated in all 23 locales rather than shipping English everywhere.
The picking, previewing and clearing of chat images moved into one hook both
shells use: it was copied into two, and both rebuilt every preview on each
change and leaked an object URL per image per pick.

Tests: the four link routes end to end with the validation pipe the harness was
missing, the image filter against the disguised-extension case, and the layout
combinations that drop a panel.
This commit is contained in:
Maurice
2026-09-08 22:42:22 +02:00
committed by Maurice
parent 07bb899c50
commit 7e3b9fd039
34 changed files with 429 additions and 118 deletions
+62 -12
View File
@@ -4,6 +4,14 @@ import { collabApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
interface CollabLink {
id: number
title: string
url: string
pinned: boolean | number
}
function LinkIcon({ url, title }: { url: string; title: string }) {
const [failed, setFailed] = useState(false)
@@ -17,31 +25,73 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
const { t } = useTranslation()
const trip = useTripStore(s => s.trip)
const canEdit = useCanDo()('collab_edit', trip)
const [links, setLinks] = useState<any[]>([])
const toast = useToast()
const [links, setLinks] = useState<CollabLink[]>([])
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [expanded, setExpanded] = useState(false)
const load = () => collabApi.getLinks(tripId).then(d => setLinks(d.links || [])).catch(() => {})
useEffect(() => { load() }, [tripId])
useEffect(() => {
let cancelled = false
collabApi.getLinks(tripId)
.then(d => { if (!cancelled) setLinks(d.links || []) })
.catch(err => {
if (cancelled) return
console.error('Failed to load collab links:', err)
toast.error(t('common.error'))
})
return () => { cancelled = true }
}, [tripId, toast, t])
// Every write says something when it fails. Silence left the pin looking
// unchanged and the delete looking ignored, with an unhandled rejection in
// the console as the only trace.
const add = async (event?: FormEvent) => {
event?.preventDefault()
if (!title.trim() || !url.trim() || busy) return
setBusy(true)
try { const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() }); setLinks(v => [d.link, ...v]); setTitle(''); setUrl(''); setExpanded(false) } finally { setBusy(false) }
try {
const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() })
setLinks(v => [d.link, ...v])
setTitle(''); setUrl(''); setExpanded(false)
} catch (err) {
console.error('Failed to add collab link:', err)
toast.error(t('common.error'))
} finally {
setBusy(false)
}
}
const toggle = async (link: CollabLink) => {
try {
const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned })
setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned)))
} catch (err) {
console.error('Failed to pin collab link:', err)
toast.error(t('common.error'))
}
}
const remove = async (id: number) => {
try {
await collabApi.deleteLink(tripId, id)
setLinks(v => v.filter(x => x.id !== id))
} catch (err) {
console.error('Failed to delete collab link:', err)
toast.error(t('common.error'))
}
}
const toggle = async (link: any) => { const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned }); setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned))) }
const remove = async (id: number) => { await collabApi.deleteLink(tripId, id); setLinks(v => v.filter(x => x.id !== id)) }
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px', borderBottom: '1px solid var(--border-faint)' }}><Link2 size={18} aria-hidden="true" /><strong>{t('collab.tabs.links') || 'Links'}</strong><span style={{ marginLeft: 'auto', color: 'var(--text-faint)', fontSize: 12 }}>{links.length}</span></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px', borderBottom: '1px solid var(--border-faint)' }}><Link2 size={18} aria-hidden="true" /><strong>{t('collab.tabs.links')}</strong><span style={{ marginLeft: 'auto', color: 'var(--text-faint)', fontSize: 12 }}>{links.length}</span></div>
{canEdit && <div style={{ padding: 10, borderBottom: '1px solid var(--border-faint)' }}>
<button type="button" onClick={() => setExpanded(v => !v)} aria-expanded={expanded} aria-controls={`collab-link-form-${tripId}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 0, borderRadius: 8, padding: '7px 10px', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontWeight: 600 }}>{expanded ? <X size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}{expanded ? (t('collab.links.cancel') || 'Cancel') : (t('collab.links.add') || 'Add link')}</button>
<button type="button" onClick={() => setExpanded(v => !v)} aria-expanded={expanded} aria-controls={`collab-link-form-${tripId}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 0, borderRadius: 8, padding: '7px 10px', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontWeight: 600 }}>{expanded ? <X size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}{expanded ? t('collab.links.cancel') : t('collab.links.add')}</button>
{expanded && <form id={`collab-link-form-${tripId}`} onSubmit={add} style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
<label htmlFor={`collab-link-title-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.titlePlaceholder') || 'Link title'}</label>
<input id={`collab-link-title-${tripId}`} value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder') || 'Link title'} autoFocus style={{ padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
<div style={{ display: 'flex', gap: 6 }}><label htmlFor={`collab-link-url-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.urlPlaceholder') || 'Link URL'}</label><input id={`collab-link-url-${tripId}`} type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder') || 'https://...'} style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} /><button type="submit" disabled={busy || !title.trim() || !url.trim()} aria-label={t('collab.links.save') || 'Save link'} style={{ width: 36, border: 0, borderRadius: 8, background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer' }}><Plus size={18} aria-hidden="true" /></button></div>
<label htmlFor={`collab-link-title-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.titlePlaceholder')}</label>
<input id={`collab-link-title-${tripId}`} value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={{ padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
<div style={{ display: 'flex', gap: 6 }}><label htmlFor={`collab-link-url-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.urlPlaceholder')}</label><input id={`collab-link-url-${tripId}`} type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} /><button type="submit" disabled={busy || !title.trim() || !url.trim()} aria-label={t('collab.links.save')} style={{ width: 36, border: 0, borderRadius: 8, background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer' }}><Plus size={18} aria-hidden="true" /></button></div>
</form>}
</div>}
<div style={{ flex: 1, overflowY: 'auto', padding: 10 }}>{links.length === 0 ? <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-faint)' }}>{t('collab.links.empty') || 'No shared links yet'}</div> : links.map(link => <div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}><LinkIcon url={link.url} title={link.title} /><a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}><div style={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div><div style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div></a><ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />{canEdit && <><button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? (t('collab.links.unpin') || 'Unpin link') : (t('collab.links.pin') || 'Pin link')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}><Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /></button><button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete') || 'Delete link'} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}><Trash2 size={15} aria-hidden="true" /></button></>}</div>)}</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 10 }}>{links.length === 0 ? <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-faint)' }}>{t('collab.links.empty')}</div> : links.map(link => <div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}><LinkIcon url={link.url} title={link.title} /><a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}><div style={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div><div style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div></a><ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />{canEdit && <><button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}><Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /></button><button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}><Trash2 size={15} aria-hidden="true" /></button></>}</div>)}</div>
</div>
}
@@ -264,6 +264,42 @@ describe('CollabPanel feature combinations', () => {
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
// The three-or-more branch used to render notes and links as a pair and drop
// whichever of the two was on its own, so these walk every shape of it.
it('FE-W5CPN-014: three right panels including notes but not links keeps notes', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: false, polls: true, whatsnext: true }} />)
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
})
it('FE-W5CPN-015: three right panels including links but not notes keeps links', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: false, links: true, polls: true, whatsnext: true }} />)
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
it('FE-W5CPN-016: all four right panels render, notes and links above the other two', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }} />)
for (const id of ['collab-chat', 'collab-notes', 'collab-links', 'collab-polls', 'whats-next']) {
expect(screen.getByTestId(id)).toBeInTheDocument()
}
})
it('FE-W5CPN-017: notes and links alone with a third panel and no chat all render', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, links: true, polls: true }} />)
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
})
it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => {
setViewport(375)
const { rerender } = render(<CollabPanel tripId={1} />)
+31 -49
View File
@@ -80,6 +80,23 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
features.whatsnext && 'whatsnext',
].filter(Boolean) as string[]
// One place decides what a panel id renders. It was spelled out at five call
// sites, and the layout branch below dropped a panel precisely because one of
// those copies did not list it.
const renderPanel = (p: string) => (
<>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'links' && <CollabLinks tripId={tripId} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</>
)
const panelRow = (ids: string[]) => (
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
{ids.map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>{renderPanel(p)}</div>)}
</div>
)
if (tabs.length === 0) return null
if (isDesktop) {
@@ -104,36 +121,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
<CollabChat tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{rightPanels.length === 1 && (
<div className={cardClass} style={{ flex: 1 }}>
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'links' && <CollabLinks tripId={tripId} />}
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
)}
{rightPanels.length === 2 && <div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
{rightPanels.map(p => (
<div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'links' && <CollabLinks tripId={tripId} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
))}
</div>}
{rightPanels.length >= 3 && <>
{rightPanels.includes('notes') && rightPanels.includes('links') && <div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
<div className={cardClass} style={{ flex: 1, minWidth: 0 }}><CollabNotes tripId={tripId} currentUser={user} /></div>
<div className={cardClass} style={{ flex: 1, minWidth: 0 }}><CollabLinks tripId={tripId} /></div>
</div>}
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
{rightPanels.filter(p => !(p === 'notes' || p === 'links')).map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>)}
</div>
</>}
{rightPanels.length <= 2 && panelRow(rightPanels)}
{rightPanels.length >= 3 && (() => {
// Two rows, split by kind rather than by a pair being present:
// the old condition needed notes AND links together, so turning
// the new Links feature off dropped Notes out of the layout
// entirely. Each row renders only when it has something in it.
const written = rightPanels.filter(p => p === 'notes' || p === 'links')
const rest = rightPanels.filter(p => p !== 'notes' && p !== 'links')
return <>
{written.length > 0 && panelRow(written)}
{rest.length > 0 && panelRow(rest)}
</>
})()}
</div>
</div>
)
@@ -141,28 +141,10 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
// Chat off — remaining panels share full width
const panels = rightPanels
if (panels.length === 1) {
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div className={cardClass} style={{ flex: 1 }}>
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{panels[0] === 'links' && <CollabLinks tripId={tripId} />}
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
)
}
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
{panels.map(p => (
<div key={p} className={cardClass} style={{ flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'links' && <CollabLinks tripId={tripId} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
<div key={p} className={cardClass} style={{ flex: 1 }}>{renderPanel(p)}</div>
))}
</div>
)
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* The images pinned to a chat message before it is sent.
*
* One hook for both shells. The desktop hook and the phone screen each carried
* their own copy of the same picking, previewing and clearing, which is two
* places for the same leak and a duplication bill on every line.
*
* Object URLs are held in a ref and revoked when the file they belong to goes,
* when the list is cleared and on unmount. Rebuilding every preview on each
* change, which is what both copies did, leaked one URL per image per keystroke
* of picking, and did it from inside a setState updater, so React ran it twice
* in development and leaked twice as fast.
*/
export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
export const MAX_CHAT_IMAGES = 4
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024
export interface ChatImages {
files: File[]
previews: string[]
/** Returns false when something was dropped, so the caller can say why. */
add: (incoming: FileList | File[]) => boolean
remove: (index: number) => void
clear: () => void
}
export function useChatImages(): ChatImages {
const [files, setFiles] = useState<File[]>([])
const [previews, setPreviews] = useState<string[]>([])
const urls = useRef<string[]>([])
const revokeAll = useCallback(() => {
urls.current.forEach(URL.revokeObjectURL)
urls.current = []
}, [])
useEffect(() => revokeAll, [revokeAll])
const add = useCallback((incoming: FileList | File[]) => {
const all = Array.from(incoming)
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
if (valid.length) {
setFiles(prev => {
const room = MAX_CHAT_IMAGES - prev.length
const added = valid.slice(0, Math.max(0, room))
// Only the new files get a URL; the ones already in the list keep theirs.
const fresh = added.map(URL.createObjectURL)
urls.current = [...urls.current, ...fresh]
setPreviews(p => [...p, ...fresh])
return [...prev, ...added]
})
}
return valid.length === all.length
}, [])
const remove = useCallback((index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index))
setPreviews(prev => {
const gone = prev[index]
if (gone) {
URL.revokeObjectURL(gone)
urls.current = urls.current.filter(u => u !== gone)
}
return prev.filter((_, i) => i !== index)
})
}, [])
const clear = useCallback(() => {
revokeAll()
setFiles([])
setPreviews([])
}, [revokeAll])
return { files, previews, add, remove, clear }
}
+12 -20
View File
@@ -5,6 +5,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useChatImages } from './useChatImages'
import { useToast } from '../shared/Toast'
export function useCollabChat(tripId: any, currentUser: any) {
@@ -23,8 +24,7 @@ export function useCollabChat(tripId: any, currentUser: any) {
const [replyTo, setReplyTo] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [sending, setSending] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [imagePreviews, setImagePreviews] = useState<string[]>([])
const images = useChatImages()
const [uploadProgress, setUploadProgress] = useState(0)
const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
@@ -122,28 +122,20 @@ export function useCollabChat(tripId: any, currentUser: any) {
/* ── send ── */
const addImageFiles = useCallback((incoming: File[] | FileList) => {
const valid = Array.from(incoming).filter(f => ['image/jpeg','image/png','image/gif','image/webp'].includes(f.type) && f.size <= 10 * 1024 * 1024)
if (valid.length !== Array.from(incoming).length) toast.error('Only JPEG, PNG, GIF, and WebP images up to 10 MB are allowed')
setImageFiles(prev => {
const next = [...prev, ...valid].slice(0, 4)
setImagePreviews(next.map(f => URL.createObjectURL(f)))
return next
})
}, [toast])
const removeImage = useCallback((index: number) => {
setImageFiles(prev => { const next = prev.filter((_, i) => i !== index); setImagePreviews(urls => { if (urls[index]) URL.revokeObjectURL(urls[index]); return next.map(f => URL.createObjectURL(f)) }); return next })
}, [])
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
}, [images, toast, t])
const removeImage = images.remove
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); addImageFiles(e.dataTransfer.files) }, [addImageFiles])
const handleSend = useCallback(async () => {
const body = text.trim()
if ((!body && !imageFiles.length) || sending) return
if ((!body && !images.files.length) || sending) return
setSending(true)
try {
let data
if (imageFiles.length) {
const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); imageFiles.forEach(file => form.append('images', file))
if (images.files.length) {
const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); images.files.forEach(file => form.append('images', file))
data = await collabApi.sendMessage(tripId, form, { onUploadProgress: e => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) })
} else {
const payload: { text: string; reply_to?: number } = { text: body }; if (replyTo) payload.reply_to = replyTo.id
@@ -152,12 +144,12 @@ export function useCollabChat(tripId: any, currentUser: any) {
if (data?.message) {
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
}
setText(''); setReplyTo(null); setShowEmoji(false); imagePreviews.forEach(URL.revokeObjectURL); setImageFiles([]); setImagePreviews([]); setUploadProgress(0)
setText(''); setReplyTo(null); setShowEmoji(false); images.clear()
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
} catch { toast.error(t('common.error')) } finally { setSending(false) }
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, imageFiles, imagePreviews])
} catch { toast.error(t('common.error')) } finally { setSending(false); setUploadProgress(0) }
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
@@ -199,5 +191,5 @@ export function useCollabChat(tripId: any, currentUser: any) {
return emojiRegex.test(text.trim())
}
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles: images.files, imagePreviews: images.previews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ArrowUp, ChevronUp, ImagePlus, Loader2, Reply, Trash2 } from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { useChatImages } from '../../../../components/Collab/useChatImages'
import { collabApi } from '../../../../api/client'
import { addListener, removeListener } from '../../../../api/websocket'
import { useAuthStore } from '../../../../store/authStore'
@@ -63,8 +64,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
const [text, setText] = useState('')
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null)
const [sending, setSending] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [imagePreviews, setImagePreviews] = useState<string[]>([])
const images = useChatImages()
const [popover, setPopover] = useState<{ msg: ChatMessage; x: number; y: number } | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
@@ -160,25 +160,20 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
}
const addImageFiles = (incoming: FileList | File[]) => {
const valid = Array.from(incoming).filter(f => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(f.type) && f.size <= 10 * 1024 * 1024)
setImageFiles(prev => {
const next = [...prev, ...valid].slice(0, 4)
setImagePreviews(next.map(f => URL.createObjectURL(f)))
return next
})
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
}
const handleSend = useCallback(async () => {
const body = text.trim()
if ((!body && !imageFiles.length) || sending || !canEdit) return
if ((!body && !images.files.length) || sending || !canEdit) return
setSending(true)
try {
let data: SendMessageResponse
if (imageFiles.length) {
if (images.files.length) {
const form = new FormData()
if (body) form.append('text', body)
if (replyTo) form.append('reply_to', String(replyTo.id))
imageFiles.forEach(file => form.append('images', file))
images.files.forEach(file => form.append('images', file))
data = (await collabApi.sendMessage(tripId, form)) as SendMessageResponse
} else {
const payload: { text: string; reply_to?: number } = { text: body }
@@ -190,9 +185,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
}
setText('')
setReplyTo(null)
imagePreviews.forEach(URL.revokeObjectURL)
setImageFiles([])
setImagePreviews([])
images.clear()
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottomRef.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
@@ -201,7 +194,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
} finally {
setSending(false)
}
}, [text, sending, canEdit, replyTo, tripId, scrollToBottom, toast, t, imageFiles, imagePreviews])
}, [text, sending, canEdit, replyTo, tripId, scrollToBottom, toast, t, images])
const handleDelete = useCallback(async (msgId: number) => {
setPopover(null)
@@ -318,12 +311,12 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
{canEdit ? (
<>
{imagePreviews.length > 0 && (
{images.previews.length > 0 && (
<div className="flex gap-2 overflow-x-auto">
{imagePreviews.map((url, i) => (
{images.previews.map((url, i) => (
<div key={url} className="relative">
<img src={url} alt="" className="h-14 w-14 rounded-lg object-cover" />
<button type="button" aria-label="Remove image" onClick={() => { setImageFiles(prev => prev.filter((_, idx) => idx !== i)); setImagePreviews(prev => prev.filter((_, idx) => idx !== i)) }} className="absolute -right-1 -top-1 h-4 w-4 rounded-full bg-m-ink text-[10px] text-m-bg">×</button>
<button type="button" aria-label="Remove image" onClick={() => images.remove(i)} className="absolute -right-1 -top-1 h-4 w-4 rounded-full bg-m-ink text-[10px] text-m-bg">×</button>
</div>
))}
</div>
@@ -346,7 +339,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
<button
type="button"
onClick={handleSend}
disabled={(!text.trim() && !imageFiles.length) || sending}
disabled={(!text.trim() && !images.files.length) || sending}
aria-label={t('collab.chat.send')}
className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg disabled:opacity-40"
>
+9 -5
View File
@@ -36,6 +36,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { BLOCKED_EXTENSIONS } from '../files/files.constants';
import { SpoolCleanupInterceptor } from '../common/spool-cleanup.interceptor';
export const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024;
const MAX_CHAT_IMAGES = 4;
@@ -330,14 +331,17 @@ export class CollabController {
// client is still streaming the body and the socket dies as ECONNRESET
// instead of carrying the error envelope. Both checks happen in the handler.
@Post('messages')
@UseInterceptors(FilesInterceptor('images', MAX_CHAT_IMAGES, { fileFilter: collabChatImageFilter, limits: { files: MAX_CHAT_IMAGES, fileSize: 10 * 1024 * 1024 } }))
// SpoolCleanupInterceptor sits after the file one on purpose: the Zod pipe
// runs when the handler's parameters are resolved, which is after both have
// been entered, so a rejected body would otherwise leave up to four spooled
// images on disk with nothing to sweep them.
@UseInterceptors(
FilesInterceptor('images', MAX_CHAT_IMAGES, { fileFilter: collabChatImageFilter, limits: { files: MAX_CHAT_IMAGES, fileSize: 10 * 1024 * 1024 } }),
SpoolCleanupInterceptor,
)
async createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabMessageCreateDto, @UploadedFiles() files: Express.Multer.File[] | undefined, @Headers('x-socket-id') socketId?: string) {
const uploaded = files || [];
const cleanupSpool = () => uploaded.forEach(file => { if (file.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } });
if (body.text && body.text.length > 5000) {
cleanupSpool();
throw new HttpException({ error: 'text must be 5000 characters or less' }, 400);
}
let trip;
try {
trip = this.requireTrip(tripId, user);
+21 -10
View File
@@ -561,17 +561,23 @@ export class CollabService {
if (!replyMsg) return { error: 'reply_not_found' };
}
const result = this.db.run(`
// One transaction: the caller has already committed the image bytes to
// storage, so a message row that lands without its attachment rows would
// leave those bytes with nothing pointing at them and nothing to sweep them.
const result = this.db.transaction(() => {
const inserted = this.db.run(`
INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?)
`, tripId, userId, text.trim(), replyTo || null);
for (const file of files) {
this.db.run(
`INSERT INTO trip_files (trip_id, message_id, filename, original_name, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
tripId, result.lastInsertRowid, file.filename, file.originalname, file.size, file.mimetype, userId,
);
}
for (const file of files) {
this.db.run(
`INSERT INTO trip_files (trip_id, message_id, filename, original_name, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
tripId, inserted.lastInsertRowid, file.filename, file.originalname, file.size, file.mimetype, userId,
);
}
return inserted;
});
const message = this.db.get<CollabMessage>(`
SELECT m.*, u.username, u.avatar,
@@ -593,11 +599,16 @@ export class CollabService {
if (Number(message.user_id) !== Number(userId)) return { error: 'not_owner' };
const attachments = this.db.all<{ filename: string }>('SELECT filename FROM trip_files WHERE message_id = ? AND trip_id = ?', messageId, tripId);
this.db.transaction(() => {
this.db.run('DELETE FROM trip_files WHERE message_id = ? AND trip_id = ?', messageId, tripId);
this.db.run('UPDATE collab_messages SET deleted = 1 WHERE id = ?', messageId);
});
// Only once the rows are actually gone. Dropping the blobs first left a
// live message pointing at attachments whose bytes no longer existed
// whenever the second statement failed.
for (const file of attachments) {
void this.storage.delete('files', path.basename(file.filename)).catch(() => { /* best effort */ });
}
this.db.run('DELETE FROM trip_files WHERE message_id = ? AND trip_id = ?', messageId, tripId);
this.db.run('UPDATE collab_messages SET deleted = 1 WHERE id = ?', messageId);
return { username: message.username };
}
@@ -0,0 +1,38 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, catchError, throwError } from 'rxjs';
import * as fs from 'fs';
/**
* Unlink whatever multer spooled when the request dies before the handler.
*
* Nest resolves handler parameters, and therefore runs the validation pipe,
* only after every interceptor has been entered. `FilesInterceptor` has already
* written the parts to the spool directory by then, so a body the Zod pipe
* rejects throws with the bytes on disk and the handler's own cleanup never
* reached. Nothing sweeps that directory, and the request is repeatable, so it
* is a disk-fill anybody with upload rights can trigger in a loop.
*
* Register it AFTER the file interceptor on a route, so `req.files` is
* populated by the time this one wraps the call. It only cleans up on failure:
* a handler that succeeds owns the spooled files and moves them itself.
*/
@Injectable()
export class SpoolCleanupInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
catchError(err => {
const req = context.switchToHttp().getRequest<{ files?: Array<{ path?: string }>; file?: { path?: string } }>();
const spooled = [...(req.files ?? []), ...(req.file ? [req.file] : [])];
for (const file of spooled) {
if (!file?.path) continue;
try {
fs.unlinkSync(file.path);
} catch {
// Best effort: the handler may already have moved or removed it.
}
}
return throwError(() => err);
}),
);
}
}
+78 -2
View File
@@ -35,6 +35,9 @@ const { db } = vi.hoisted(() => {
user_id INTEGER NOT NULL, category TEXT DEFAULT 'General', title TEXT NOT NULL, content TEXT,
color TEXT DEFAULT '#6366f1', pinned INTEGER DEFAULT 0, website TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE collab_links (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, pinned INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE collab_polls (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, question TEXT NOT NULL, options TEXT NOT NULL, multiple INTEGER DEFAULT 0,
closed INTEGER DEFAULT 0, deadline TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
@@ -48,8 +51,9 @@ const { db } = vi.hoisted(() => {
message_id INTEGER NOT NULL, user_id INTEGER NOT NULL, emoji TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE trip_files (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
note_id INTEGER, filename TEXT NOT NULL, original_name TEXT NOT NULL, file_size INTEGER,
mime_type TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
note_id INTEGER, message_id INTEGER, filename TEXT NOT NULL, original_name TEXT NOT NULL,
file_size INTEGER, mime_type TEXT, uploaded_by INTEGER, deleted_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
// StorageRegistryService (behind StorageModule, now in this module chain) reads
// this at onModuleInit.
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
@@ -69,6 +73,7 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi
let checkPermission: MockInstance;
import { CollabModule } from '../../src/nest/collab/collab.module';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { RateLimitService } from '../../src/nest/common/rate-limit.service';
@@ -81,6 +86,10 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
// AppModule registers this as APP_PIPE; the harness only pulls CollabModule,
// so without it the write routes would run unvalidated here and a broken
// contract would still look green.
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
@@ -101,6 +110,7 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
db.prepare('DELETE FROM collab_polls').run();
db.prepare('DELETE FROM trip_files').run();
db.prepare('DELETE FROM collab_notes').run();
db.prepare('DELETE FROM collab_links').run();
});
afterAll(async () => {
@@ -223,4 +233,70 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'URL is required' });
});
describe('shared links', () => {
it('401 without a session cookie', async () => {
expect((await request(server).get('/api/trips/5/collab/links')).status).toBe(401);
});
it('404 when the trip is not accessible', async () => {
canAccessTrip.mockReturnValue(undefined);
const res = await request(server).get('/api/trips/5/collab/links').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
});
it('201 on create, and the row is persisted with the acting user', async () => {
const res = await request(server).post('/api/trips/5/collab/links').set('Cookie', sessionCookie(1))
.send({ title: 'Ferry', url: 'https://example.com/ferry' });
expect(res.status).toBe(201);
expect(res.body.link).toMatchObject({ title: 'Ferry', url: 'https://example.com/ferry' });
const row = db.prepare('SELECT * FROM collab_links WHERE trip_id = 5').get() as { title: string; user_id: number };
expect(row).toMatchObject({ title: 'Ferry', user_id: 1 });
});
it('403 on create without collab_edit', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server).post('/api/trips/5/collab/links').set('Cookie', sessionCookie(1))
.send({ title: 'Ferry', url: 'https://example.com/ferry' });
expect(res.status).toBe(403);
});
it('400 when the body does not satisfy the contract', async () => {
const res = await request(server).post('/api/trips/5/collab/links').set('Cookie', sessionCookie(1)).send({ title: 'Ferry' });
expect(res.status).toBe(400);
});
it('200 on list, pinned first', async () => {
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url, pinned) VALUES (1, 5, 1, 'Plain', 'https://a.test', 0)").run();
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url, pinned) VALUES (2, 5, 1, 'Pinned', 'https://b.test', 1)").run();
const res = await request(server).get('/api/trips/5/collab/links').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.links.map((l: { title: string }) => l.title)).toEqual(['Pinned', 'Plain']);
});
it('200 on update and the pin lands in the row', async () => {
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url) VALUES (1, 5, 1, 'Plain', 'https://a.test')").run();
const res = await request(server).put('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1)).send({ pinned: true });
expect(res.status).toBe(200);
expect(res.body.link).toMatchObject({ id: 1, pinned: 1 });
});
it('404 on update and delete of a link that is not there', async () => {
expect((await request(server).put('/api/trips/5/collab/links/99').set('Cookie', sessionCookie(1)).send({ pinned: true })).status).toBe(404);
expect((await request(server).delete('/api/trips/5/collab/links/99').set('Cookie', sessionCookie(1))).status).toBe(404);
});
it('200 on delete and the row is gone', async () => {
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url) VALUES (1, 5, 1, 'Plain', 'https://a.test')").run();
const res = await request(server).delete('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(db.prepare('SELECT COUNT(*) c FROM collab_links').get()).toEqual({ c: 0 });
});
it('403 on delete without collab_edit', async () => {
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url) VALUES (1, 5, 1, 'Plain', 'https://a.test')").run();
checkPermission.mockReturnValue(false);
expect((await request(server).delete('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1))).status).toBe(403);
});
});
});
@@ -4,7 +4,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { CollabController } from '../../../src/nest/collab/collab.controller';
import { CollabController, collabChatImageFilter } from '../../../src/nest/collab/collab.controller';
import { TripAccessGuard, TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import type { CollabService } from '../../../src/nest/collab/collab.service';
@@ -202,6 +202,35 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
// The decorators, not the handler body. Constructing the controller directly,
// which every test above does, runs no guard at all — so removing the trip check
// again would leave this file green. It shipped without one once.
describe('chat image filter', () => {
const run = (originalname: string, mimetype: string) => {
let outcome: { err: Error | null; ok?: boolean } = { err: null };
collabChatImageFilter!({} as never, { originalname, mimetype } as never, ((err: Error | null, ok?: boolean) => { outcome = { err, ok }; }) as never);
return outcome;
};
it('accepts a real image', () => {
expect(run('holiday.jpg', 'image/jpeg').ok).toBe(true);
expect(run('map.PNG', 'image/png').ok).toBe(true);
});
it('refuses a type it does not serve', () => {
expect(run('notes.pdf', 'application/pdf').err).toBeInstanceOf(Error);
});
it('refuses a name whose extension disagrees with the type it claims', () => {
// The mimetype is the header the client wrote. The stored name keeps the
// extension of the name the client sent, and the download route decides
// what to serve from that extension and sends it inline, so believing the
// header alone served attacker HTML on our own origin.
for (const name of ['pwn.html', 'pwn.svg', 'pwn.js', 'pwn.htm', 'pwn']) {
const out = run(name, 'image/png');
expect(out.err, name).toBeInstanceOf(Error);
expect((out.err as Error & { statusCode?: number }).statusCode).toBe(400);
}
});
});
describe('link preview guard chain', () => {
const guardsOn = (target: object): unknown[] => (Reflect.getMetadata('__guards__', target) as unknown[]) ?? [];
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'الاستطلاعات',
'collab.tabs.links': 'الروابط',
'collab.links.add': 'إضافة رابط',
'collab.chat.imageRejected': 'يُسمح فقط بصور JPEG وPNG وGIF وWebP حتى 10 ميغابايت',
'collab.links.cancel': 'إلغاء',
'collab.links.save': 'حفظ الرابط',
'collab.links.titlePlaceholder': 'عنوان الرابط',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Enquetes',
'collab.tabs.links': 'Links',
'collab.links.add': 'Adicionar link',
'collab.chat.imageRejected': 'Apenas imagens JPEG, PNG, GIF e WebP de até 10 MB são permitidas',
'collab.links.cancel': 'Cancelar',
'collab.links.save': 'Salvar link',
'collab.links.titlePlaceholder': 'Título do link',
+1
View File
@@ -6,6 +6,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Enquestes',
'collab.tabs.links': 'Enllaços',
'collab.links.add': 'Afegeix un enllaç',
'collab.chat.imageRejected': "Només s'admeten imatges JPEG, PNG, GIF i WebP de fins a 10 MB",
'collab.links.cancel': 'Cancel·la',
'collab.links.save': 'Desa l\'enllaç',
'collab.links.titlePlaceholder': 'Títol de l\'enllaç',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Hlasování',
'collab.tabs.links': 'Odkazy',
'collab.links.add': 'Přidat odkaz',
'collab.chat.imageRejected': 'Povoleny jsou pouze obrázky JPEG, PNG, GIF a WebP do 10 MB',
'collab.links.cancel': 'Zrušit',
'collab.links.save': 'Uložit odkaz',
'collab.links.titlePlaceholder': 'Název odkazu',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Umfragen',
'collab.tabs.links': 'Links',
'collab.links.add': 'Link hinzufügen',
'collab.chat.imageRejected': 'Nur JPEG, PNG, GIF und WebP bis 10 MB sind erlaubt',
'collab.links.cancel': 'Abbrechen',
'collab.links.save': 'Link speichern',
'collab.links.titlePlaceholder': 'Linktitel',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Polls',
'collab.tabs.links': 'Links',
'collab.links.add': 'Add link',
'collab.chat.imageRejected': 'Only JPEG, PNG, GIF and WebP images up to 10 MB are allowed',
'collab.links.cancel': 'Cancel',
'collab.links.save': 'Save link',
'collab.links.titlePlaceholder': 'Link title',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Encuestas',
'collab.tabs.links': 'Enlaces',
'collab.links.add': 'Añadir enlace',
'collab.chat.imageRejected': 'Solo se permiten imágenes JPEG, PNG, GIF y WebP de hasta 10 MB',
'collab.links.cancel': 'Cancelar',
'collab.links.save': 'Guardar enlace',
'collab.links.titlePlaceholder': 'Título del enlace',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Sondages',
'collab.tabs.links': 'Liens',
'collab.links.add': 'Ajouter un lien',
'collab.chat.imageRejected': "Seules les images JPEG, PNG, GIF et WebP jusqu'à 10 Mo sont autorisées",
'collab.links.cancel': 'Annuler',
'collab.links.save': 'Enregistrer le lien',
'collab.links.titlePlaceholder': 'Titre du lien',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Δημοσκοπήσεις',
'collab.tabs.links': 'Σύνδεσμοι',
'collab.links.add': 'Προσθήκη συνδέσμου',
'collab.chat.imageRejected': 'Επιτρέπονται μόνο εικόνες JPEG, PNG, GIF και WebP έως 10 MB',
'collab.links.cancel': 'Ακύρωση',
'collab.links.save': 'Αποθήκευση συνδέσμου',
'collab.links.titlePlaceholder': 'Τίτλος συνδέσμου',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Szavazások',
'collab.tabs.links': 'Linkek',
'collab.links.add': 'Link hozzáadása',
'collab.chat.imageRejected': 'Csak legfeljebb 10 MB méretű JPEG, PNG, GIF és WebP képek engedélyezettek',
'collab.links.cancel': 'Mégse',
'collab.links.save': 'Link mentése',
'collab.links.titlePlaceholder': 'Link címe',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Polling',
'collab.tabs.links': 'Tautan',
'collab.links.add': 'Tambah tautan',
'collab.chat.imageRejected': 'Hanya gambar JPEG, PNG, GIF, dan WebP hingga 10 MB yang diizinkan',
'collab.links.cancel': 'Batal',
'collab.links.save': 'Simpan tautan',
'collab.links.titlePlaceholder': 'Judul tautan',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Sondaggi',
'collab.tabs.links': 'Link',
'collab.links.add': 'Aggiungi link',
'collab.chat.imageRejected': 'Sono ammesse solo immagini JPEG, PNG, GIF e WebP fino a 10 MB',
'collab.links.cancel': 'Annulla',
'collab.links.save': 'Salva link',
'collab.links.titlePlaceholder': 'Titolo del link',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': '投票',
'collab.tabs.links': 'リンク',
'collab.links.add': 'リンクを追加',
'collab.chat.imageRejected': 'JPEG、PNG、GIF、WebP の画像のみ、10 MB までです',
'collab.links.cancel': 'キャンセル',
'collab.links.save': 'リンクを保存',
'collab.links.titlePlaceholder': 'リンクのタイトル',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': '투표',
'collab.tabs.links': '링크',
'collab.links.add': '링크 추가',
'collab.chat.imageRejected': 'JPEG, PNG, GIF, WebP 이미지만 최대 10 MB까지 허용됩니다',
'collab.links.cancel': '취소',
'collab.links.save': '링크 저장',
'collab.links.titlePlaceholder': '링크 제목',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Polls',
'collab.tabs.links': 'Links',
'collab.links.add': 'Link toevoegen',
'collab.chat.imageRejected': 'Alleen JPEG-, PNG-, GIF- en WebP-afbeeldingen tot 10 MB zijn toegestaan',
'collab.links.cancel': 'Annuleren',
'collab.links.save': 'Link opslaan',
'collab.links.titlePlaceholder': 'Linktitel',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Ankiety',
'collab.tabs.links': 'Linki',
'collab.links.add': 'Dodaj link',
'collab.chat.imageRejected': 'Dozwolone są tylko obrazy JPEG, PNG, GIF i WebP do 10 MB',
'collab.links.cancel': 'Anuluj',
'collab.links.save': 'Zapisz link',
'collab.links.titlePlaceholder': 'Tytuł linku',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Опросы',
'collab.tabs.links': 'Ссылки',
'collab.links.add': 'Добавить ссылку',
'collab.chat.imageRejected': 'Допускаются только изображения JPEG, PNG, GIF и WebP размером до 10 МБ',
'collab.links.cancel': 'Отмена',
'collab.links.save': 'Сохранить ссылку',
'collab.links.titlePlaceholder': 'Название ссылки',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Omröstningar',
'collab.tabs.links': 'Länkar',
'collab.links.add': 'Lägg till länk',
'collab.chat.imageRejected': 'Endast JPEG-, PNG-, GIF- och WebP-bilder upp till 10 MB tillåts',
'collab.links.cancel': 'Avbryt',
'collab.links.save': 'Spara länk',
'collab.links.titlePlaceholder': 'Länktitel',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Anketler',
'collab.tabs.links': 'Bağlantılar',
'collab.links.add': 'Bağlantı ekle',
'collab.chat.imageRejected': 'Yalnızca 10 MB boyutuna kadar JPEG, PNG, GIF ve WebP görselleri kabul edilir',
'collab.links.cancel': 'İptal',
'collab.links.save': 'Bağlantıyı kaydet',
'collab.links.titlePlaceholder': 'Bağlantı başlığı',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Опитування',
'collab.tabs.links': 'Посилання',
'collab.links.add': 'Додати посилання',
'collab.chat.imageRejected': 'Дозволено лише зображення JPEG, PNG, GIF і WebP розміром до 10 МБ',
'collab.links.cancel': 'Скасувати',
'collab.links.save': 'Зберегти посилання',
'collab.links.titlePlaceholder': 'Назва посилання',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': 'Thăm dò ý kiến',
'collab.tabs.links': 'Liên kết',
'collab.links.add': 'Thêm liên kết',
'collab.chat.imageRejected': 'Chỉ chấp nhận ảnh JPEG, PNG, GIF và WebP tối đa 10 MB',
'collab.links.cancel': 'Hủy',
'collab.links.save': 'Lưu liên kết',
'collab.links.titlePlaceholder': 'Tiêu đề liên kết',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': '投票',
'collab.tabs.links': '連結',
'collab.links.add': '新增連結',
'collab.chat.imageRejected': '僅支援 10 MB 以內的 JPEG、PNG、GIF 和 WebP 圖片',
'collab.links.cancel': '取消',
'collab.links.save': '儲存連結',
'collab.links.titlePlaceholder': '連結標題',
+1
View File
@@ -14,6 +14,7 @@ const collab: TranslationStrings = {
'collab.tabs.polls': '投票',
'collab.tabs.links': '链接',
'collab.links.add': '添加链接',
'collab.chat.imageRejected': '仅支持 10 MB 以内的 JPEG、PNG、GIF 和 WebP 图片',
'collab.links.cancel': '取消',
'collab.links.save': '保存链接',
'collab.links.titlePlaceholder': '链接标题',