diff --git a/src/components/Preview.svelte b/src/components/Preview.svelte index 91426fb..91f8bdd 100644 --- a/src/components/Preview.svelte +++ b/src/components/Preview.svelte @@ -9,26 +9,49 @@ let loading = true let preview: Preview | undefined = undefined + let renderNonce = 0 let title = '' $: { if (url) { const decodedUrl = decodeURIComponent(url) + const currentRender = ++renderNonce if (typeof window !== 'undefined') { if (!preview) { preview = new Preview('#site-frame') } - preview.render(decodedUrl).then(() => { - loading = false - - if (preview?.pageData.title) { - // update with page title or URL - title = preview.pageData.title - } - }) + loading = true + title = '' + + preview + .render(decodedUrl) + .then(() => { + if (currentRender !== renderNonce) { + return + } + + if (preview?.pageData.title) { + // update with page title or URL + title = preview.pageData.title + } + }) + .catch(() => { + if (currentRender !== renderNonce) { + return + } + + title = decodedUrl + }) + .finally(() => { + if (currentRender !== renderNonce) { + return + } + + loading = false + }) } } } diff --git a/src/routes/img/[...path]/+server.ts b/src/routes/img/[...path]/+server.ts new file mode 100644 index 0000000..faf1352 --- /dev/null +++ b/src/routes/img/[...path]/+server.ts @@ -0,0 +1,67 @@ +import { error } from '@sveltejs/kit' +import { + buildImageFallbackTargets, + decodePreviewUrlFromReferer, + isSupportedImageFallbackSource, + sanitizeRelativePath, +} from '../../../utils/image-fallback' + +export const prerender = false + +export async function GET({ request, params, fetch }) { + const imagePath = sanitizeRelativePath(params.path) + if (!imagePath) { + throw error(400, 'Invalid image path') + } + + const previewUrl = decodePreviewUrlFromReferer(request.headers.get('referer')) + if (!previewUrl) { + throw error(404, 'Preview context unavailable') + } + + if (!isSupportedImageFallbackSource(previewUrl)) { + throw error(404, 'Preview source not supported for image fallback') + } + + const targets = buildImageFallbackTargets(previewUrl, imagePath) + if (!targets.length) { + throw error(404, 'Could not resolve image target') + } + + for (const target of targets) { + let upstream: Response + try { + upstream = await fetch(target, { + redirect: 'follow', + headers: { + Accept: request.headers.get('accept') ?? 'image/*,*/*', + }, + }) + } catch { + continue + } + + if (!upstream.ok) { + continue + } + + const payload = await upstream.arrayBuffer() + const headers = new Headers() + + const contentType = upstream.headers.get('content-type') + if (contentType) { + headers.set('Content-Type', contentType) + } + headers.set( + 'Cache-Control', + upstream.headers.get('cache-control') ?? 'public, max-age=300', + ) + + return new Response(payload, { + status: upstream.status, + headers, + }) + } + + throw error(404, 'Image not found') +} diff --git a/src/utils/image-fallback.ts b/src/utils/image-fallback.ts new file mode 100644 index 0000000..973fb2b --- /dev/null +++ b/src/utils/image-fallback.ts @@ -0,0 +1,98 @@ +import { resourceType } from '../types/resources' +import { getRepositoryRoot } from './lang/html' +import { getResourceType, isValidURL, processUrl } from './url' + +export function sanitizeRelativePath( + pathParam: string | undefined, +): string | undefined { + if (!pathParam) { + return undefined + } + + let decodedPath: string + try { + decodedPath = decodeURIComponent(pathParam) + } catch { + return undefined + } + + const segments = decodedPath.split('/').filter(Boolean) + if (!segments.length) { + return undefined + } + + if (segments.some((segment) => segment === '.' || segment === '..')) { + return undefined + } + + return segments.join('/') +} + +export function decodePreviewUrlFromReferer( + refererHeader: string | null, +): string | undefined { + if (!refererHeader) { + return undefined + } + + let refererUrl: URL + try { + refererUrl = new URL(refererHeader) + } catch { + return undefined + } + + const queryUrl = refererUrl.searchParams.get('url') + if (queryUrl && isValidURL(queryUrl)) { + return queryUrl + } + + const slug = refererUrl.pathname.replace(/^\/+/, '') + if (!slug) { + return undefined + } + + try { + const decodedSlug = decodeURIComponent(slug) + if (isValidURL(decodedSlug)) { + return decodedSlug + } + } catch { + return undefined + } + + return undefined +} + +export function buildImageFallbackTargets( + previewUrl: string, + imagePath: string, +): string[] { + const htmlCandidates = processUrl(previewUrl) + const targets: string[] = [] + + htmlCandidates.forEach((candidate) => { + let candidateUrl: URL + try { + candidateUrl = new URL(candidate) + } catch { + return + } + + const repositoryRoot = getRepositoryRoot(candidateUrl) + if (!repositoryRoot) { + return + } + + targets.push(new URL(`img/${imagePath}`, repositoryRoot).toString()) + }) + + return [...new Set(targets)] +} + +export function isSupportedImageFallbackSource(previewUrl: string): boolean { + const previewType = getResourceType(previewUrl, false) + return ( + previewType === resourceType.GITHUB || previewType === resourceType.GITLAB + ) +} diff --git a/src/utils/lang/html.ts b/src/utils/lang/html.ts index 1fe3d40..28134b4 100644 --- a/src/utils/lang/html.ts +++ b/src/utils/lang/html.ts @@ -4,6 +4,11 @@ export function isHTML(maybeHTML: string): boolean { return [...$div.childNodes].reverse().some(($child) => $child.nodeType === 1) } +export const PREVIEW_SCRIPT_PLACEHOLDER_TYPE = + 'application/static-preview-script' +export const PREVIEW_SCRIPT_SRC_ATTRIBUTE = 'data-preview-script-src' +export const PREVIEW_SCRIPT_TYPE_ATTRIBUTE = 'data-preview-script-type' + function getFetchResolverScript() { return ` + +