@inferencesh/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +68 -2
  3. package/components.json +22 -0
  4. package/package.json +231 -4
  5. package/src/components/audio-player.tsx +549 -0
  6. package/src/components/button.tsx +13 -0
  7. package/src/components/chart.tsx +403 -0
  8. package/src/components/circuit.tsx +276 -0
  9. package/src/components/code-block/code-block.tsx +260 -0
  10. package/src/components/code-block/index.ts +18 -0
  11. package/src/components/code-block/languages.ts +311 -0
  12. package/src/components/code-block/styles.ts +39 -0
  13. package/src/components/code-block/tokenizer.ts +290 -0
  14. package/src/components/code-block/types.ts +38 -0
  15. package/src/components/code-block/utils.ts +39 -0
  16. package/src/components/code.tsx +48 -0
  17. package/src/components/cookie-consent.tsx +137 -0
  18. package/src/components/copy-button.tsx +44 -0
  19. package/src/components/country-dropdown.tsx +188 -0
  20. package/src/components/glow-loader-fast.tsx +32 -0
  21. package/src/components/glow-loader-mono.tsx +33 -0
  22. package/src/components/glow-loader-sm-fast.tsx +32 -0
  23. package/src/components/glow-loader-sm-mono.tsx +34 -0
  24. package/src/components/glow-loader-sm.tsx +32 -0
  25. package/src/components/glow-loader.tsx +32 -0
  26. package/src/components/glow-logo.tsx +32 -0
  27. package/src/components/gradient.tsx +80 -0
  28. package/src/components/index.ts +45 -0
  29. package/src/components/inline-sidebar.tsx +351 -0
  30. package/src/components/input-color.tsx +349 -0
  31. package/src/components/logo-base.tsx +213 -0
  32. package/src/components/logo-color.tsx +23 -0
  33. package/src/components/logo-full.tsx +71 -0
  34. package/src/components/logo-svg.tsx +23 -0
  35. package/src/components/menu-navigation.tsx +359 -0
  36. package/src/components/multi-select.tsx +721 -0
  37. package/src/components/pastel-gradient.tsx +25 -0
  38. package/src/components/pixel-symmetry.tsx +151 -0
  39. package/src/components/progress.tsx +28 -0
  40. package/src/components/scroll-more.tsx +131 -0
  41. package/src/components/select.tsx +85 -0
  42. package/src/components/shimmering-text.tsx +108 -0
  43. package/src/components/sidebar-light.tsx +102 -0
  44. package/src/components/sonner.tsx +38 -0
  45. package/src/components/theme-switcher-sidebar.tsx +56 -0
  46. package/src/components/theme-switcher-simple.tsx +110 -0
  47. package/src/components/theme-switcher.tsx +47 -0
  48. package/src/components/toggle-group.tsx +55 -0
  49. package/src/components/tooltip-button.tsx +91 -0
  50. package/src/components/tooltip.tsx +71 -0
  51. package/src/components/tree-view.tsx +499 -0
  52. package/src/components/unglow-loader.tsx +33 -0
  53. package/src/components/waveform.tsx +1632 -0
  54. package/src/components/zoomable-image.tsx +76 -0
  55. package/src/docs/api-docs.tsx +763 -0
  56. package/src/docs/callout.tsx +160 -0
  57. package/src/docs/code-group.tsx +215 -0
  58. package/src/docs/copy-menu.tsx +143 -0
  59. package/src/docs/doc-tabs.tsx +84 -0
  60. package/src/docs/index.ts +16 -0
  61. package/src/docs/steps.tsx +105 -0
  62. package/src/docs/table-of-contents.tsx +174 -0
  63. package/src/examples/guide-markdown.ts +129 -0
  64. package/src/examples/index.ts +1153 -0
  65. package/src/global.d.ts +1 -0
  66. package/src/hooks/use-copy-to-clipboard.ts +36 -0
  67. package/src/hooks/use-mobile.ts +19 -0
  68. package/src/lib/display-name.ts +15 -0
  69. package/src/lib/example-input.ts +130 -0
  70. package/src/lib/plans.ts +44 -0
  71. package/src/lib/safe-content.ts +137 -0
  72. package/src/lib/schema.ts +320 -0
  73. package/src/lib/storage.ts +108 -0
  74. package/src/markdown/docs-markdown-renderer.tsx +518 -0
  75. package/src/markdown/helpers.ts +95 -0
  76. package/src/markdown/index.ts +24 -0
  77. package/src/markdown/markdown-renderer.tsx +232 -0
  78. package/src/markdown/youtube-embed.tsx +43 -0
  79. package/src/og/image.ts +312 -0
  80. package/src/og/index.tsx +430 -0
  81. package/src/og/query.ts +26 -0
  82. package/src/shadcn/accordion.tsx +71 -0
  83. package/src/shadcn/alert-dialog.tsx +187 -0
  84. package/src/shadcn/alert.tsx +75 -0
  85. package/src/shadcn/aspect-ratio.tsx +22 -0
  86. package/src/shadcn/attachment.tsx +207 -0
  87. package/src/shadcn/avatar.tsx +106 -0
  88. package/src/shadcn/badge.tsx +51 -0
  89. package/src/shadcn/breadcrumb.tsx +124 -0
  90. package/src/shadcn/bubble.tsx +127 -0
  91. package/src/shadcn/button-group.tsx +87 -0
  92. package/src/shadcn/button.tsx +57 -0
  93. package/src/shadcn/calendar.tsx +219 -0
  94. package/src/shadcn/card.tsx +102 -0
  95. package/src/shadcn/carousel.tsx +242 -0
  96. package/src/shadcn/chart.tsx +372 -0
  97. package/src/shadcn/checkbox.tsx +28 -0
  98. package/src/shadcn/collapsible.tsx +19 -0
  99. package/src/shadcn/combobox.tsx +297 -0
  100. package/src/shadcn/command.tsx +194 -0
  101. package/src/shadcn/context-menu.tsx +270 -0
  102. package/src/shadcn/dialog.tsx +158 -0
  103. package/src/shadcn/direction.tsx +4 -0
  104. package/src/shadcn/drawer.tsx +227 -0
  105. package/src/shadcn/dropdown-menu.tsx +265 -0
  106. package/src/shadcn/empty.tsx +103 -0
  107. package/src/shadcn/field.tsx +238 -0
  108. package/src/shadcn/form.tsx +173 -0
  109. package/src/shadcn/hover-card.tsx +50 -0
  110. package/src/shadcn/index.ts +53 -0
  111. package/src/shadcn/input-group.tsx +156 -0
  112. package/src/shadcn/input-otp.tsx +84 -0
  113. package/src/shadcn/input.tsx +19 -0
  114. package/src/shadcn/item.tsx +201 -0
  115. package/src/shadcn/kbd.tsx +26 -0
  116. package/src/shadcn/label.tsx +19 -0
  117. package/src/shadcn/marker.tsx +70 -0
  118. package/src/shadcn/menubar.tsx +278 -0
  119. package/src/shadcn/message-scroller.tsx +131 -0
  120. package/src/shadcn/message.tsx +91 -0
  121. package/src/shadcn/native-select.tsx +60 -0
  122. package/src/shadcn/navigation-menu.tsx +167 -0
  123. package/src/shadcn/pagination.tsx +130 -0
  124. package/src/shadcn/popover.tsx +87 -0
  125. package/src/shadcn/progress.tsx +82 -0
  126. package/src/shadcn/radio-group.tsx +35 -0
  127. package/src/shadcn/resizable.tsx +49 -0
  128. package/src/shadcn/scroll-area.tsx +52 -0
  129. package/src/shadcn/select.tsx +200 -0
  130. package/src/shadcn/separator.tsx +22 -0
  131. package/src/shadcn/sheet.tsx +136 -0
  132. package/src/shadcn/sidebar.tsx +721 -0
  133. package/src/shadcn/skeleton.tsx +13 -0
  134. package/src/shadcn/slider.tsx +51 -0
  135. package/src/shadcn/sonner.tsx +49 -0
  136. package/src/shadcn/spinner.tsx +10 -0
  137. package/src/shadcn/switch.tsx +29 -0
  138. package/src/shadcn/table.tsx +115 -0
  139. package/src/shadcn/tabs.tsx +79 -0
  140. package/src/shadcn/textarea.tsx +17 -0
  141. package/src/shadcn/toggle-group.tsx +89 -0
  142. package/src/shadcn/toggle.tsx +44 -0
  143. package/src/shadcn/tooltip.tsx +63 -0
  144. package/src/theme/config.ts +48 -0
  145. package/src/theme/css/base.css +92 -0
  146. package/src/theme/css/bitboy.css +75 -0
  147. package/src/theme/css/dark.css +47 -0
  148. package/src/theme/css/designer.css +122 -0
  149. package/src/theme/css/fonts.css +33 -0
  150. package/src/theme/css/globals.css +14 -0
  151. package/src/theme/css/hackintosh.css +57 -0
  152. package/src/theme/css/llama.css +103 -0
  153. package/src/theme/css/safelist.css +39 -0
  154. package/src/theme/css/shadcn.css +77 -0
  155. package/src/theme/css/theme.css +216 -0
  156. package/src/theme/embed-theme.ts +146 -0
  157. package/src/theme/fonts/FiraCode-VF.woff2 +0 -0
  158. package/src/theme/fonts/Hack-Regular.woff2 +0 -0
  159. package/src/theme/fonts/LICENSE-FiraCode.txt +93 -0
  160. package/src/theme/fonts/LICENSE-Hack.txt +45 -0
  161. package/src/theme/fonts/LICENSE-MonaSans.txt +93 -0
  162. package/src/theme/fonts/Mona-Sans.woff2 +0 -0
  163. package/src/theme/index.ts +15 -0
  164. package/src/utils/cn.ts +3 -0
  165. package/src/utils/index.ts +2 -0
@@ -0,0 +1,232 @@
1
+ 'use client'
2
+
3
+ import { cn } from "cn"
4
+ import ZoomableImage from "../components/zoomable-image"
5
+ import React, { memo, useMemo } from 'react'
6
+ import Markdown from 'react-markdown'
7
+ import { safeUrlTransform } from '../lib/safe-content'
8
+ import remarkGfm from 'remark-gfm'
9
+ import { isCloudInferenceUrl, isVideoUrl, getYouTubeVideoId, stripHtmlComments } from './helpers'
10
+ import { YouTubeEmbed } from './youtube-embed'
11
+ import { CompactCodeBlock } from '../components/code-block'
12
+
13
+ export interface MarkdownRendererProps {
14
+ content: string
15
+ className?: string
16
+ /** Compact mode for dense UI like chat */
17
+ compact?: boolean
18
+ /** Custom file preview renderer for cloud URLs */
19
+ renderFilePreview?: (props: { uri: string; filename: string }) => React.ReactNode
20
+ /** Custom image renderer for cloud URLs */
21
+ renderCloudImage?: (props: { src: string; alt?: string }) => React.ReactNode
22
+ }
23
+
24
+ const textSizeMap = {
25
+ default: {
26
+ h1: 'text-xl',
27
+ h2: 'text-lg',
28
+ h3: 'text-md',
29
+ h4: 'text-base',
30
+ h5: 'text-sm',
31
+ h6: 'text-sm',
32
+ p: 'text-sm',
33
+ li: 'text-sm',
34
+ code: 'text-xs',
35
+ pre: 'text-xs',
36
+ blockquote: 'text-sm',
37
+ ul: 'text-sm',
38
+ ol: 'text-sm',
39
+ th: 'text-sm',
40
+ td: 'text-sm',
41
+ },
42
+ compact: {
43
+ h1: 'text-xl',
44
+ h2: 'text-lg',
45
+ h3: 'text-base',
46
+ h4: 'text-xs',
47
+ h5: 'text-xs',
48
+ h6: 'text-xs',
49
+ p: 'text-xs',
50
+ li: 'text-xs',
51
+ code: 'text-xs',
52
+ pre: 'text-xs',
53
+ blockquote: 'text-xs',
54
+ ul: 'text-xs',
55
+ ol: 'text-xs',
56
+ th: 'text-xs',
57
+ td: 'text-xs',
58
+ },
59
+ }
60
+
61
+ export const MarkdownRenderer = memo(function MarkdownRenderer({
62
+ content,
63
+ className,
64
+ compact = false,
65
+ renderFilePreview,
66
+ renderCloudImage,
67
+ }: MarkdownRendererProps) {
68
+ const processedContent = useMemo(
69
+ () => stripHtmlComments(content),
70
+ [content]
71
+ )
72
+
73
+ const textSize = compact ? textSizeMap.compact : textSizeMap.default
74
+ const fontClass = 'legible'
75
+ const paragraphClass = cn(textSize.p, 'break-words whitespace-pre-wrap my-0 py-0 text-justify leading-relaxed', fontClass)
76
+
77
+ return (
78
+ <div className={className}>
79
+ <Markdown
80
+ urlTransform={safeUrlTransform}
81
+ remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
82
+ components={{
83
+ // Block elements
84
+ h1: ({ children }) => <h1 className={cn(textSize.h1, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h1>,
85
+ h2: ({ children }) => <h2 className={cn(textSize.h2, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h2>,
86
+ h3: ({ children }) => <h3 className={cn(textSize.h3, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h3>,
87
+ h4: ({ children }) => <h4 className={cn(textSize.h4, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h4>,
88
+ h5: ({ children }) => <h5 className={cn(textSize.h5, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h5>,
89
+ h6: ({ children }) => <h6 className={cn(textSize.h6, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h6>,
90
+
91
+ p: ({ children }) => {
92
+ // Detect if paragraph contains block-level elements that can't be inside <p>
93
+ // This includes YouTube embeds, cloud file previews, and images
94
+ const hasBlockContent = React.Children.toArray(children).some((child) => {
95
+ if (!React.isValidElement(child)) return false
96
+ const props = child.props as { href?: string; src?: string }
97
+ // Check for YouTube links
98
+ if (props.href && getYouTubeVideoId(props.href)) return true
99
+ // Check for cloud URLs
100
+ if (props.href && isCloudInferenceUrl(props.href)) return true
101
+ // Check for images
102
+ if (props.src) return true
103
+ return false
104
+ })
105
+ // Use div for block content to avoid invalid <div> inside <p> nesting
106
+ return hasBlockContent && !compact ? (
107
+ <div className={paragraphClass}>{children}</div>
108
+ ) : (
109
+ <p className={paragraphClass}>{children}</p>
110
+ )
111
+ },
112
+
113
+ blockquote: ({ children }) => (
114
+ <blockquote className={cn('border-l-4 pl-4 my-2 py-0', fontClass)}>{children}</blockquote>
115
+ ),
116
+
117
+ // Lists
118
+ ul: ({ children }) => <ul className={cn('list-disc list-outside ml-5 my-0 mt-2 py-0', fontClass)}>{children}</ul>,
119
+ ol: ({ children }) => <ol className={cn('list-decimal list-outside ml-5 my-0 mt-2 py-0', fontClass)}>{children}</ol>,
120
+ li: ({ children }) => <li className={cn(textSize.li, 'my-0 mt-2 py-0 pl-1', fontClass)}>{children}</li>,
121
+
122
+ // Inline elements
123
+ strong: ({ children }) => <strong className={cn('font-bold', fontClass)}>{children}</strong>,
124
+ em: ({ children }) => <em className={cn('italic', fontClass)}>{children}</em>,
125
+
126
+ code(props) {
127
+ const { children, className } = props
128
+ const hasLang = /language-(\w+)/.test(className || '')
129
+ const hasNewlines = String(children).includes('\n')
130
+ const isBlock = hasLang || hasNewlines
131
+
132
+ if (isBlock) {
133
+ return <code className={cn(textSize.code, fontClass, className)}>{children}</code>
134
+ }
135
+ // Inline code
136
+ return (
137
+ <span className="inline bg-muted/30 rounded px-1 text-brand-pink/80">
138
+ <code className={cn(textSize.code, fontClass)}>{children}</code>
139
+ </span>
140
+ )
141
+ },
142
+
143
+ pre: ({ children }) => (
144
+ <CompactCodeBlock textSize={textSize.pre} className={cn(textSize.pre, fontClass)}>
145
+ {children}
146
+ </CompactCodeBlock>
147
+ ),
148
+
149
+ // Links and images
150
+ a: ({ href, children }) => {
151
+ if (href && !compact) {
152
+ // YouTube videos
153
+ const videoId = getYouTubeVideoId(href)
154
+ if (videoId) {
155
+ return <YouTubeEmbed videoId={videoId} />
156
+ }
157
+
158
+ // Cloud video URLs
159
+ if (isCloudInferenceUrl(href) && isVideoUrl(href)) {
160
+ return (
161
+ <video
162
+ src={href}
163
+ controls
164
+ playsInline
165
+ className="my-6 rounded-xl w-full max-w-2xl"
166
+ />
167
+ )
168
+ }
169
+
170
+ // Cloud file previews
171
+ if (isCloudInferenceUrl(href) && renderFilePreview) {
172
+ return renderFilePreview({
173
+ uri: href,
174
+ filename: typeof children === 'string' ? children : 'file',
175
+ })
176
+ }
177
+
178
+ // Default link
179
+ return (
180
+ <a
181
+ href={href}
182
+ className={cn('hover:underline', fontClass)}
183
+ target="_blank"
184
+ rel="noopener noreferrer"
185
+ >
186
+ {children}
187
+ </a>
188
+ )
189
+ }
190
+ return <>{children}</>
191
+ },
192
+
193
+ img: ({ src, alt }) => {
194
+ if (!src || typeof src !== 'string') return null
195
+
196
+ if (isCloudInferenceUrl(src) && renderCloudImage) {
197
+ return renderCloudImage({ src, alt })
198
+ }
199
+
200
+ return (
201
+ <ZoomableImage
202
+ src={src}
203
+ alt={alt}
204
+ className={cn('max-w-full h-auto my-0 py-0 rounded', fontClass)}
205
+ />
206
+ )
207
+ },
208
+
209
+ // Table elements
210
+ table: ({ children }) => (
211
+ <div className={cn('min-w-0 overflow-x-auto my-2 border border-border rounded-md', fontClass)}>
212
+ <table className="">{children}</table>
213
+ </div>
214
+ ),
215
+ thead: ({ children }) => <thead className={cn('border-b border-border', fontClass)}>{children}</thead>,
216
+ tbody: ({ children }) => <tbody className={fontClass}>{children}</tbody>,
217
+ tr: ({ children }) => <tr className="border-b border-border last:border-0">{children}</tr>,
218
+ th: ({ children }) => <th className={cn('px-3 py-1.5 text-left text-xs text-muted-foreground', fontClass)}>{children}</th>,
219
+ td: ({ children }) => <td className={cn('px-3 py-1.5 text-sm', fontClass)}>{children}</td>,
220
+
221
+ // Horizontal rule
222
+ hr: () => <hr className="my-2 border-t" />,
223
+
224
+ // Strikethrough
225
+ s: ({ children }) => <s className="line-through">{children}</s>,
226
+ }}
227
+ >
228
+ {processedContent}
229
+ </Markdown>
230
+ </div>
231
+ )
232
+ })
@@ -0,0 +1,43 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../utils'
4
+ import { useState } from 'react'
5
+ import GlowLoaderSmMono from '../components/glow-loader-sm-mono'
6
+
7
+ interface YouTubeEmbedProps {
8
+ videoId: string
9
+ title?: string
10
+ className?: string
11
+ }
12
+
13
+ export function YouTubeEmbed({ videoId, title, className }: YouTubeEmbedProps) {
14
+ const [isLoading, setIsLoading] = useState(true)
15
+
16
+ return (
17
+ <div className={cn(
18
+ 'flex flex-col space-y-4 w-full h-full relative my-6 rounded-xl overflow-hidden border border-border',
19
+ className
20
+ )}>
21
+ {isLoading && (
22
+ <div className="absolute inset-0 flex items-center justify-center bg-muted">
23
+ <GlowLoaderSmMono width={32} height={32} className="opacity-60" />
24
+ </div>
25
+ )}
26
+ <iframe
27
+ src={`https://www.youtube.com/embed/${videoId}`}
28
+ title={title || 'YouTube video player'}
29
+ frameBorder="0"
30
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
31
+ referrerPolicy="strict-origin-when-cross-origin"
32
+ allowFullScreen
33
+ loading="lazy"
34
+ onLoad={() => setIsLoading(false)}
35
+ className={cn(
36
+ 'w-full aspect-video h-full bg-transparent transition-opacity duration-300',
37
+ isLoading ? 'opacity-0' : 'opacity-100'
38
+ )}
39
+ />
40
+ </div>
41
+ )
42
+ }
43
+
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Fetching artwork for OG cards, safely.
3
+ *
4
+ * Card backgrounds come from app metadata (`images.card`, `images.banner`),
5
+ * which any app owner can set to any string. INC-2026-11-02: card URLs aimed
6
+ * at internal services (redis, postgres, the metadata endpoint), at open
7
+ * redirectors, and at a 17000x17000 PNG decompression bomb that took the app
8
+ * container from 125 MiB to 1.34 GB inside satori/resvg.
9
+ *
10
+ * So an image is used only when all of these hold, and the card renders
11
+ * without it otherwise:
12
+ * - the URL is https on the default port, with a host on the allowlist (our
13
+ * storage and CDN hosts by default; never an IP literal);
14
+ * - the response is not a redirect, arrives within the timeout, and its body
15
+ * stays under the byte cap while it streams;
16
+ * - the declared content type and the file's own signature are PNG, JPEG,
17
+ * WebP or GIF (no SVG);
18
+ * - the dimensions read from the file header are within the pixel limits.
19
+ */
20
+
21
+ export const DEFAULT_OG_IMAGE_HOSTS: readonly string[] = ['cloud.inference.sh', 'cdn.inference.sh']
22
+
23
+ export const OG_IMAGE_LIMITS = {
24
+ timeoutMs: 5000,
25
+ maxBytes: 5 * 1024 * 1024,
26
+ maxDimension: 4096,
27
+ maxPixels: 4096 * 4096,
28
+ } as const
29
+
30
+ export type OgImageType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
31
+
32
+ const ALLOWED_TYPES: ReadonlySet<string> = new Set<OgImageType>([
33
+ 'image/png',
34
+ 'image/jpeg',
35
+ 'image/webp',
36
+ 'image/gif',
37
+ ])
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Host allowlist
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
44
+
45
+ let allowedHosts: ReadonlySet<string> = new Set(DEFAULT_OG_IMAGE_HOSTS)
46
+
47
+ /**
48
+ * Parse an allowlist from config, e.g. the OG_IMAGE_HOSTS env var: hostnames
49
+ * separated by commas or whitespace. Unset or blank gives the defaults. A
50
+ * malformed entry (scheme, port, path, IP literal) throws, so a bad value
51
+ * stops the server at startup instead of silently allowing nothing.
52
+ */
53
+ export function parseOgImageHosts(value: string | undefined | null): string[] {
54
+ const entries = (value ?? '').split(/[\s,]+/).map((h) => h.trim().toLowerCase()).filter(Boolean)
55
+ if (entries.length === 0) return [...DEFAULT_OG_IMAGE_HOSTS]
56
+ for (const host of entries) {
57
+ if (!HOSTNAME_RE.test(host)) {
58
+ throw new Error(`OG_IMAGE_HOSTS: "${host}" is not a bare hostname`)
59
+ }
60
+ }
61
+ return entries
62
+ }
63
+
64
+ /** Replace the hosts card images may be fetched from (process-wide). */
65
+ export function configureOgImageHosts(hosts: readonly string[]): void {
66
+ allowedHosts = new Set(hosts.map((h) => h.toLowerCase()))
67
+ }
68
+
69
+ export function getOgImageHosts(): string[] {
70
+ return [...allowedHosts]
71
+ }
72
+
73
+ /** The URL if it may be fetched under the allowlist, else the reason it may not. */
74
+ export function checkOgImageUrl(
75
+ raw: string,
76
+ hosts: ReadonlySet<string> = allowedHosts,
77
+ ): { ok: true; url: URL } | { ok: false; reason: string } {
78
+ let url: URL
79
+ try {
80
+ url = new URL(raw)
81
+ } catch {
82
+ return { ok: false, reason: 'unparseable url' }
83
+ }
84
+ if (url.protocol !== 'https:') return { ok: false, reason: 'not https' }
85
+ if (url.username || url.password) return { ok: false, reason: 'credentials in url' }
86
+ if (url.port !== '') return { ok: false, reason: 'non-default port' }
87
+ // URL normalises IPv4 forms (decimal, hex, octal) to dotted quads and keeps
88
+ // IPv6 in brackets, so these catch every IP literal.
89
+ if (url.hostname.startsWith('[') || /^\d+\.\d+\.\d+\.\d+$/.test(url.hostname)) {
90
+ return { ok: false, reason: 'ip literal host' }
91
+ }
92
+ if (!hosts.has(url.hostname.toLowerCase())) return { ok: false, reason: 'host not allowed' }
93
+ return { ok: true, url }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Header parsing
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export interface ImageInfo {
101
+ type: OgImageType
102
+ width: number
103
+ height: number
104
+ }
105
+
106
+ const u16be = (b: Uint8Array, o: number) => (b[o] << 8) | b[o + 1]
107
+ const u16le = (b: Uint8Array, o: number) => b[o] | (b[o + 1] << 8)
108
+ const u24le = (b: Uint8Array, o: number) => b[o] | (b[o + 1] << 8) | (b[o + 2] << 16)
109
+ const u32be = (b: Uint8Array, o: number) =>
110
+ ((b[o] << 24) >>> 0) + ((b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3])
111
+ const ascii = (b: Uint8Array, o: number, n: number) =>
112
+ String.fromCharCode(...b.subarray(o, o + n))
113
+
114
+ const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
115
+
116
+ function pngInfo(b: Uint8Array): ImageInfo | null {
117
+ if (b.length < 24 || !PNG_SIG.every((v, i) => b[i] === v)) return null
118
+ if (ascii(b, 12, 4) !== 'IHDR') return null
119
+ return { type: 'image/png', width: u32be(b, 16), height: u32be(b, 20) }
120
+ }
121
+
122
+ function jpegInfo(b: Uint8Array): ImageInfo | null {
123
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null
124
+ let o = 2
125
+ while (o + 4 <= b.length) {
126
+ if (b[o] !== 0xff) return null
127
+ const marker = b[o + 1]
128
+ if (marker === 0xff) {
129
+ o += 1 // fill byte
130
+ continue
131
+ }
132
+ // Standalone markers carry no length.
133
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
134
+ o += 2
135
+ continue
136
+ }
137
+ if (marker === 0xd9 || marker === 0xda) return null // EOI / SOS before any SOF
138
+ const len = u16be(b, o + 2)
139
+ if (len < 2) return null
140
+ // SOF0-SOF15, minus DHT (C4), JPG (C8) and DAC (CC).
141
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
142
+ if (o + 9 > b.length) return null
143
+ return { type: 'image/jpeg', height: u16be(b, o + 5), width: u16be(b, o + 7) }
144
+ }
145
+ o += 2 + len
146
+ }
147
+ return null
148
+ }
149
+
150
+ function webpInfo(b: Uint8Array): ImageInfo | null {
151
+ if (b.length < 30 || ascii(b, 0, 4) !== 'RIFF' || ascii(b, 8, 4) !== 'WEBP') return null
152
+ const chunk = ascii(b, 12, 4)
153
+ if (chunk === 'VP8 ') {
154
+ // Frame tag (3 bytes), then the start code 9d 01 2a.
155
+ if (b[23] !== 0x9d || b[24] !== 0x01 || b[25] !== 0x2a) return null
156
+ return { type: 'image/webp', width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff }
157
+ }
158
+ if (chunk === 'VP8L') {
159
+ if (b[20] !== 0x2f) return null
160
+ const bits = b[21] | (b[22] << 8) | (b[23] << 16) | (b[24] << 24)
161
+ return { type: 'image/webp', width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }
162
+ }
163
+ if (chunk === 'VP8X') {
164
+ return { type: 'image/webp', width: u24le(b, 24) + 1, height: u24le(b, 27) + 1 }
165
+ }
166
+ return null
167
+ }
168
+
169
+ /**
170
+ * GIF: the logical screen size, and also every frame's own size, because a
171
+ * frame descriptor may claim more than the screen and decoders allocate for it.
172
+ * Reports the largest width and height seen.
173
+ */
174
+ function gifInfo(b: Uint8Array): ImageInfo | null {
175
+ if (b.length < 13) return null
176
+ const sig = ascii(b, 0, 6)
177
+ if (sig !== 'GIF87a' && sig !== 'GIF89a') return null
178
+ let width = u16le(b, 6)
179
+ let height = u16le(b, 8)
180
+ let o = 13
181
+ if (b[10] & 0x80) o += 3 * (1 << ((b[10] & 0x07) + 1))
182
+ const skipSubBlocks = () => {
183
+ while (o < b.length) {
184
+ const n = b[o]
185
+ o += 1 + n
186
+ if (n === 0) return true
187
+ }
188
+ return false
189
+ }
190
+ while (o < b.length) {
191
+ const block = b[o]
192
+ if (block === 0x3b) break // trailer
193
+ if (block === 0x21) {
194
+ o += 2 // introducer + label
195
+ if (!skipSubBlocks()) break
196
+ } else if (block === 0x2c) {
197
+ if (o + 10 > b.length) break
198
+ width = Math.max(width, u16le(b, o + 5))
199
+ height = Math.max(height, u16le(b, o + 7))
200
+ const flags = b[o + 9]
201
+ o += 10
202
+ if (flags & 0x80) o += 3 * (1 << ((flags & 0x07) + 1))
203
+ o += 1 // LZW minimum code size
204
+ if (!skipSubBlocks()) break
205
+ } else {
206
+ return null
207
+ }
208
+ }
209
+ return { type: 'image/gif', width, height }
210
+ }
211
+
212
+ /** Format and pixel size from the file header, or null if not a supported raster. */
213
+ export function readImageInfo(bytes: Uint8Array): ImageInfo | null {
214
+ return pngInfo(bytes) ?? jpegInfo(bytes) ?? webpInfo(bytes) ?? gifInfo(bytes)
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Fetch
219
+ // ---------------------------------------------------------------------------
220
+
221
+ export class OgImageRejected extends Error {}
222
+
223
+ export interface FetchOgImageOptions {
224
+ /** Overrides the configured allowlist for this call. */
225
+ allowedHosts?: readonly string[]
226
+ timeoutMs?: number
227
+ maxBytes?: number
228
+ maxDimension?: number
229
+ maxPixels?: number
230
+ }
231
+
232
+ /** Read a body, aborting as soon as it passes maxBytes. */
233
+ async function readCapped(res: Response, maxBytes: number, controller: AbortController) {
234
+ const declared = Number(res.headers.get('content-length'))
235
+ if (Number.isFinite(declared) && declared > maxBytes) {
236
+ throw new OgImageRejected(`content-length ${declared} over ${maxBytes}`)
237
+ }
238
+ if (!res.body) throw new OgImageRejected('empty body')
239
+ const reader = res.body.getReader()
240
+ const chunks: Uint8Array[] = []
241
+ let total = 0
242
+ for (;;) {
243
+ const { done, value } = await reader.read()
244
+ if (done) break
245
+ total += value.byteLength
246
+ if (total > maxBytes) {
247
+ controller.abort()
248
+ await reader.cancel().catch(() => {})
249
+ throw new OgImageRejected(`body over ${maxBytes} bytes`)
250
+ }
251
+ chunks.push(value)
252
+ }
253
+ const out = new Uint8Array(total)
254
+ let o = 0
255
+ for (const c of chunks) {
256
+ out.set(c, o)
257
+ o += c.byteLength
258
+ }
259
+ return out
260
+ }
261
+
262
+ /**
263
+ * Fetch a card image under the rules at the top of this file. Returns the
264
+ * bytes and their verified format; throws OgImageRejected (or a fetch/abort
265
+ * error) otherwise.
266
+ */
267
+ export async function fetchOgImage(
268
+ raw: string,
269
+ opts: FetchOgImageOptions = {},
270
+ ): Promise<{ bytes: Uint8Array; info: ImageInfo }> {
271
+ const hosts = opts.allowedHosts ? new Set(opts.allowedHosts.map((h) => h.toLowerCase())) : allowedHosts
272
+ const checked = checkOgImageUrl(raw, hosts)
273
+ if (!checked.ok) throw new OgImageRejected(checked.reason)
274
+
275
+ const maxBytes = opts.maxBytes ?? OG_IMAGE_LIMITS.maxBytes
276
+ const maxDimension = opts.maxDimension ?? OG_IMAGE_LIMITS.maxDimension
277
+ const maxPixels = opts.maxPixels ?? OG_IMAGE_LIMITS.maxPixels
278
+ const controller = new AbortController()
279
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? OG_IMAGE_LIMITS.timeoutMs)
280
+ try {
281
+ const res = await fetch(checked.url, { redirect: 'error', signal: controller.signal })
282
+ if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
283
+ throw new OgImageRejected(`redirect ${res.status}`)
284
+ }
285
+ if (!res.ok) throw new OgImageRejected(`status ${res.status}`)
286
+ const declaredType = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
287
+ if (!ALLOWED_TYPES.has(declaredType)) {
288
+ throw new OgImageRejected(`content-type ${declaredType || 'missing'}`)
289
+ }
290
+ const bytes = await readCapped(res, maxBytes, controller)
291
+ const info = readImageInfo(bytes)
292
+ if (!info) throw new OgImageRejected('unrecognised image header')
293
+ if (info.width === 0 || info.height === 0) throw new OgImageRejected('zero dimension')
294
+ if (info.width > maxDimension || info.height > maxDimension || info.width * info.height > maxPixels) {
295
+ throw new OgImageRejected(`dimensions ${info.width}x${info.height}`)
296
+ }
297
+ return { bytes, info }
298
+ } finally {
299
+ clearTimeout(timer)
300
+ }
301
+ }
302
+
303
+ /** The URL for a log line: origin and path only, never the query. */
304
+ export function redactUrl(raw: string): string {
305
+ try {
306
+ const u = new URL(raw)
307
+ const s = `${u.protocol}//${u.host}${u.pathname}`
308
+ return s.length > 160 ? `${s.slice(0, 160)}...` : s
309
+ } catch {
310
+ return '<unparseable>'
311
+ }
312
+ }