Files
TREK/client/src/components/Collab/CollabNotesWebsiteThumbnail.tsx
T
Maurice 818021908f fix(client): correct the second pass over this branch's own client fixes
Four of the fixes already on this branch were wrong, and an adversarial read
of the diff is what turned them up.

The shared merge helper claimed to implement the server's
COALESCE(assignment_time, place_time) and did not: it pinned the embedded
times to whatever the assignment already showed, ignoring the override
column entirely. Sharing that with the WS applier traded one bug for
another - a collaborator moving a place from 08:00 to 10:00 stopped reaching
the day card for every assignment WITHOUT an override, which is most of them.
It merges the way the server projects now, and the test that was supposed to
pin this seeded a fixture the server could never produce (embedded times that
differ from the pool row with no override to explain the difference).

The per-iteration auth gate went into every loop in tripSyncManager except
the one that matters most: the mutation-queue replay, which writes server
responses straight into Dexie. After a logout that is the shared anonymous
database. The fire-and-forget tags/categories writes two lines below a gate
added in the same commit were ungated for the same reason - they resolve
later, and the check has to happen then.

safeHttpUrl is backed by placeWebsiteSchema: http(s) only, capped at 500
characters. A booking url is deliberately free-form - shared's own contract
says so in as many words, because people paste bare hosts and long provider
deep links - so guarding it with the stricter schema turned a link somebody
saved last year into dead text. safeExternalHref refuses the schemes that
execute in this origin and gives a scheme-less value https://, which is what
an address bar does with the same input.

And the memories timeout was 20s while the server's own budget for a Synology
call is 30s, doubled again when the session has expired and it re-authenticates:
a slow NAS that answered before this branch would have started reading as
disconnected. Those calls had no client timeout at all before; they have none
again, and the ones that need to give up early carry an AbortSignal.

The notes thumbnail also loses its NUL byte cache separator - it made git treat
the whole file as binary, so a security fix landed in it unreviewable - and
gains the same cache cap its chat sibling got.
2026-08-23 11:59:12 +02:00

83 lines
3.6 KiB
TypeScript

import { useState, useEffect } from 'react'
import { ExternalLink } from 'lucide-react'
import { collabApi } from '../../api/client'
import { safeExternalHref } from '../../utils/safeUrl'
// ── Website Thumbnail (fetches OG image) ────────────────────────────────────
interface OgPreview { title?: string; image?: string }
// linkPreview is trip-scoped on the server, so the cache key has to be too.
// Capped like the chat preview's: a long session would otherwise keep every
// thumbnail it ever rendered for as long as the tab lives. The separator is a
// space because a trip id has none and a URL cannot contain a raw one - it used
// to be a NUL, which made git treat this whole file as binary and its diffs
// unreadable.
const ogCache = new Map<string, OgPreview>()
const cacheKey = (tripId: number, url: string): string => `${tripId} ${url}`
const MAX_CACHED_PREVIEWS = 200
interface WebsiteThumbnailProps {
url: string
tripId: number
color: string
}
export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) {
const [data, setData] = useState<OgPreview | null>(null)
const [failed, setFailed] = useState(false)
// The note contract takes any string, so a stored javascript: URL would run in
// this origin the moment someone clicks the tile. Refuse those, and leave a
// bare host linkable - people paste them here the same way they do on a
// booking. The tile still renders either way; it just is not a link.
const href = safeExternalHref(url)
useEffect(() => {
if (!href) return
const key = cacheKey(tripId, url)
setFailed(false)
const cached = ogCache.get(key)
if (cached) { setData(cached); return }
setData(null)
let current = true
collabApi.linkPreview(tripId, url)
.then(d => {
if (ogCache.size >= MAX_CACHED_PREVIEWS) ogCache.delete(ogCache.keys().next().value!)
ogCache.set(key, d)
if (current) setData(d)
})
.catch(() => { if (current) setFailed(true) })
return () => { current = false }
}, [url, tripId, href])
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return 'link' } })()
const tileStyle = {
width: 48, height: 48, borderRadius: 8, cursor: href ? 'pointer' : 'default', overflow: 'hidden',
background: data?.image ? 'none' : 'var(--bg-tertiary)', border: 'none',
display: 'flex', flexDirection: 'column' as const, alignItems: 'center', justifyContent: 'center', gap: 2,
textDecoration: 'none', transition: 'transform 0.12s, box-shadow 0.12s', flexShrink: 0,
}
const tile = data?.image && !failed ? (
<img src={data.image} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} onError={() => setFailed(true)} />
) : (
<>
<ExternalLink size={14} color="var(--text-muted)" />
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
{domain}
</span>
</>
)
if (!href) return <span title={data?.title || url} style={tileStyle}>{tile}</span>
return (
<a href={href} target="_blank" rel="noopener noreferrer" title={data?.title || url}
style={tileStyle}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
{tile}
</a>
)
}