ugly-app 0.1.901 → 0.1.902

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.
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.1.901";
1
+ export declare const CLI_VERSION = "0.1.902";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.901";
2
+ export const CLI_VERSION = "0.1.902";
3
3
  //# sourceMappingURL=version.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"Image.d.ts","sourceRoot":"","sources":["../../../src/client/components/Image.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAEzD,+DAA+D;AAC/D,MAAM,WAAW,WAAW;IAC1B,yDAAyD;IACzD,GAAG,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEpE,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAC9C,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AASD,wBAAgB,KAAK,CAAC,EACpB,GAAG,EACH,UAAoB,EACpB,GAAQ,EACR,SAAc,EACd,KAAK,GACN,EAAE,UAAU,GAAG,YAAY,GAAG,IAAI,CAalC"}
1
+ {"version":3,"file":"Image.d.ts","sourceRoot":"","sources":["../../../src/client/components/Image.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAGzD,+DAA+D;AAC/D,MAAM,WAAW,WAAW;IAC1B,yDAAyD;IACzD,GAAG,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEpE,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAC9C,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AASD,wBAAgB,KAAK,CAAC,EACpB,GAAG,EACH,UAAoB,EACpB,GAAQ,EACR,SAAc,EACd,KAAK,GACN,EAAE,UAAU,GAAG,YAAY,GAAG,IAAI,CAclC"}
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { crossOriginProps } from './crossOriginProps.js';
2
3
  const objectFitMap = {
3
4
  contain: 'contain',
4
5
  cover: 'cover',
@@ -11,6 +12,6 @@ export function Image({ src, resizeMode = 'cover', alt = '', className = '', sty
11
12
  const url = typeof src === 'string' ? src : src?.url;
12
13
  if (!url)
13
14
  return null;
14
- return (_jsx("img", { src: url, alt: alt, className: className, style: { objectFit: objectFitMap[resizeMode], ...style } }));
15
+ return (_jsx("img", { src: url, alt: alt, className: className, ...crossOriginProps(url), style: { objectFit: objectFitMap[resizeMode], ...style } }));
15
16
  }
16
17
  //# sourceMappingURL=Image.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"Image.js","sourceRoot":"","sources":["../../../src/client/components/Image.tsx"],"names":[],"mappings":";AAsBA,MAAM,YAAY,GAAmD;IACnE,OAAO,EAAE,SAAS;IAClB,KAAK,EAAE,OAAO;IACd,OAAO,EAAE,MAAM;IACf,MAAM,EAAE,MAAM;CACf,CAAC;AAEF,MAAM,UAAU,KAAK,CAAC,EACpB,GAAG,EACH,UAAU,GAAG,OAAO,EACpB,GAAG,GAAG,EAAE,EACR,SAAS,GAAG,EAAE,EACd,KAAK,GACM;IACX,2EAA2E;IAC3E,oFAAoF;IACpF,MAAM,GAAG,GAAG,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC;IACrD,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,OAAO,CACL,cACE,GAAG,EAAE,GAAG,EACR,GAAG,EAAE,GAAG,EACR,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,EAAE,SAAS,EAAE,YAAY,CAAC,UAAU,CAAC,EAAE,GAAG,KAAK,EAAE,GACxD,CACH,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"Image.js","sourceRoot":"","sources":["../../../src/client/components/Image.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAsBzD,MAAM,YAAY,GAAmD;IACnE,OAAO,EAAE,SAAS;IAClB,KAAK,EAAE,OAAO;IACd,OAAO,EAAE,MAAM;IACf,MAAM,EAAE,MAAM;CACf,CAAC;AAEF,MAAM,UAAU,KAAK,CAAC,EACpB,GAAG,EACH,UAAU,GAAG,OAAO,EACpB,GAAG,GAAG,EAAE,EACR,SAAS,GAAG,EAAE,EACd,KAAK,GACM;IACX,2EAA2E;IAC3E,oFAAoF;IACpF,MAAM,GAAG,GAAG,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC;IACrD,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,OAAO,CACL,cACE,GAAG,EAAE,GAAG,EACR,GAAG,EAAE,GAAG,EACR,SAAS,EAAE,SAAS,KAChB,gBAAgB,CAAC,GAAG,CAAC,EACzB,KAAK,EAAE,EAAE,SAAS,EAAE,YAAY,CAAC,UAAU,CAAC,EAAE,GAAG,KAAK,EAAE,GACxD,CACH,CAAC;AACJ,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"UserImage.d.ts","sourceRoot":"","sources":["../../../src/client/components/UserImage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,iBAAiB,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAG5E,MAAM,WAAW,cAAe,SAAQ,IAAI,CAC1C,iBAAiB,CAAC,gBAAgB,CAAC,EACnC,KAAK,CACN;IACC,iFAAiF;IACjF,MAAM,EAAE,MAAM,CAAC;IACf,qCAAqC;IACrC,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,EACxB,MAAM,EACN,IAAS,EACT,KAAK,EACL,GAAG,IAAI,EACR,EAAE,cAAc,GAAG,YAAY,CA8B/B"}
1
+ {"version":3,"file":"UserImage.d.ts","sourceRoot":"","sources":["../../../src/client/components/UserImage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,iBAAiB,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAI5E,MAAM,WAAW,cAAe,SAAQ,IAAI,CAC1C,iBAAiB,CAAC,gBAAgB,CAAC,EACnC,KAAK,CACN;IACC,iFAAiF;IACjF,MAAM,EAAE,MAAM,CAAC;IACf,qCAAqC;IACrC,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,EACxB,MAAM,EACN,IAAS,EACT,KAAK,EACL,GAAG,IAAI,EACR,EAAE,cAAc,GAAG,YAAY,CAyC/B"}
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useCachedDoc } from '../cachedDoc.js';
3
+ import { crossOriginProps } from './crossOriginProps.js';
3
4
  /**
4
5
  * Renders a user's avatar. Resolves through the framework's getter-backed
5
6
  * `userPublic` collection (cached client-side). When no avatar is available,
@@ -23,6 +24,9 @@ export function UserImage({ userId, size = 32, style, ...rest }) {
23
24
  display: 'inline-block',
24
25
  } }));
25
26
  }
26
- return _jsx("img", { src: src, style: boxStyle, alt: "", ...rest });
27
+ // crossOriginProps: user avatars live on blob.ugly.bot, which sends CORS but
28
+ // not CORP — without opting into CORS they are blocked outright on any
29
+ // cross-origin-isolated (COEP require-corp) page. See crossOriginProps.ts.
30
+ return (_jsx("img", { src: src, style: boxStyle, alt: "", ...crossOriginProps(src), ...rest }));
27
31
  }
28
32
  //# sourceMappingURL=UserImage.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"UserImage.js","sourceRoot":"","sources":["../../../src/client/components/UserImage.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAY/C;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAC,EACxB,MAAM,EACN,IAAI,GAAG,EAAE,EACT,KAAK,EACL,GAAG,IAAI,EACQ;IACf,MAAM,OAAO,GAAG,YAAY,CAC1B,YAAY,EACZ,MAAM,CACP,CAAC;IACF,MAAM,GAAG,GAAG,OAAO,EAAE,SAAS,IAAI,SAAS,CAAC;IAE5C,MAAM,QAAQ,GAAkB;QAC9B,KAAK,EAAE,IAAI;QACX,MAAM,EAAE,IAAI;QACZ,YAAY,EAAE,CAAC;QACf,SAAS,EAAE,OAAO;QAClB,UAAU,EAAE,CAAC;QACb,GAAG,KAAK;KACT,CAAC;IAEF,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,CACL,6BACc,MAAM,EAClB,KAAK,EAAE;gBACL,GAAG,QAAQ;gBACX,UAAU,EAAE,+BAA+B;gBAC3C,OAAO,EAAE,cAAc;aACxB,GACD,CACH,CAAC;IACJ,CAAC;IAED,OAAO,cAAK,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,EAAC,EAAE,KAAK,IAAI,GAAI,CAAC;AAC7D,CAAC"}
1
+ {"version":3,"file":"UserImage.js","sourceRoot":"","sources":["../../../src/client/components/UserImage.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAYzD;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAC,EACxB,MAAM,EACN,IAAI,GAAG,EAAE,EACT,KAAK,EACL,GAAG,IAAI,EACQ;IACf,MAAM,OAAO,GAAG,YAAY,CAC1B,YAAY,EACZ,MAAM,CACP,CAAC;IACF,MAAM,GAAG,GAAG,OAAO,EAAE,SAAS,IAAI,SAAS,CAAC;IAE5C,MAAM,QAAQ,GAAkB;QAC9B,KAAK,EAAE,IAAI;QACX,MAAM,EAAE,IAAI;QACZ,YAAY,EAAE,CAAC;QACf,SAAS,EAAE,OAAO;QAClB,UAAU,EAAE,CAAC;QACb,GAAG,KAAK;KACT,CAAC;IAEF,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,CACL,6BACc,MAAM,EAClB,KAAK,EAAE;gBACL,GAAG,QAAQ;gBACX,UAAU,EAAE,+BAA+B;gBAC3C,OAAO,EAAE,cAAc;aACxB,GACD,CACH,CAAC;IACJ,CAAC;IAED,6EAA6E;IAC7E,uEAAuE;IACvE,2EAA2E;IAC3E,OAAO,CACL,cACE,GAAG,EAAE,GAAG,EACR,KAAK,EAAE,QAAQ,EACf,GAAG,EAAC,EAAE,KACF,gBAAgB,CAAC,GAAG,CAAC,KACrB,IAAI,GACR,CACH,CAAC;AACJ,CAAC"}
@@ -0,0 +1,6 @@
1
+ /** `crossOrigin`/`referrerPolicy` props for an <img>, or `{}` when same-origin. */
2
+ export declare function crossOriginProps(url: string): {
3
+ crossOrigin?: 'anonymous';
4
+ referrerPolicy?: 'no-referrer';
5
+ };
6
+ //# sourceMappingURL=crossOriginProps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crossOriginProps.d.ts","sourceRoot":"","sources":["../../../src/client/components/crossOriginProps.ts"],"names":[],"mappings":"AA0BA,mFAAmF;AACnF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG;IAC7C,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,cAAc,CAAC,EAAE,aAAa,CAAC;CAChC,CAYA"}
@@ -0,0 +1,44 @@
1
+ // Cross-origin media under COEP `require-corp`.
2
+ //
3
+ // Apps that enable cross-origin isolation (needed for SharedArrayBuffer) send
4
+ // `Cross-Origin-Embedder-Policy: require-corp`. Under that policy the browser
5
+ // BLOCKS every cross-origin subresource unless one of two things is true:
6
+ //
7
+ // 1. the response carries `Cross-Origin-Resource-Policy: cross-origin`, or
8
+ // 2. the element opts into CORS (`crossorigin`) AND the response carries
9
+ // permissive CORS headers.
10
+ //
11
+ // `blob.ugly.bot` (the R2 custom domain serving user avatars and generated
12
+ // media) sends `Access-Control-Allow-Origin: *` but NOT `Cross-Origin-Resource-
13
+ // Policy` — CORP is not expressible through R2's bucket CORS config. So a plain
14
+ // `<img src="https://blob.ugly.bot/…">` on a cross-origin-isolated page is
15
+ // blocked and renders as a broken image. In prod this silently ate user avatars
16
+ // on ugly.chat (reported as "Icon don't appear properly"); the request itself is
17
+ // a perfectly healthy 200 image/webp, which is why it never looked like a server
18
+ // problem.
19
+ //
20
+ // Opting into CORS satisfies condition 2 using the headers blob.ugly.bot
21
+ // already sends, so this needs no infrastructure change.
22
+ //
23
+ // Only applied to cross-origin URLs: adding `crossorigin` to a same-origin image
24
+ // is unnecessary, and on a server that does not emit CORS headers it would turn
25
+ // a working image into a broken one.
26
+ /** `crossOrigin`/`referrerPolicy` props for an <img>, or `{}` when same-origin. */
27
+ export function crossOriginProps(url) {
28
+ // Relative URLs and data:/blob: are same-origin by construction.
29
+ if (!/^https?:\/\//i.test(url))
30
+ return {};
31
+ try {
32
+ if (typeof window === 'undefined')
33
+ return {};
34
+ if (new URL(url).origin === window.location.origin)
35
+ return {};
36
+ }
37
+ catch {
38
+ return {};
39
+ }
40
+ // `no-referrer` keeps the CORS preflight-free request cacheable and avoids
41
+ // leaking the current page path to the media host.
42
+ return { crossOrigin: 'anonymous', referrerPolicy: 'no-referrer' };
43
+ }
44
+ //# sourceMappingURL=crossOriginProps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crossOriginProps.js","sourceRoot":"","sources":["../../../src/client/components/crossOriginProps.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,EAAE;AACF,8EAA8E;AAC9E,8EAA8E;AAC9E,0EAA0E;AAC1E,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,gCAAgC;AAChC,EAAE;AACF,2EAA2E;AAC3E,gFAAgF;AAChF,gFAAgF;AAChF,2EAA2E;AAC3E,gFAAgF;AAChF,iFAAiF;AACjF,iFAAiF;AACjF,WAAW;AACX,EAAE;AACF,yEAAyE;AACzE,yDAAyD;AACzD,EAAE;AACF,iFAAiF;AACjF,gFAAgF;AAChF,qCAAqC;AAErC,mFAAmF;AACnF,MAAM,UAAU,gBAAgB,CAAC,GAAW;IAI1C,iEAAiE;IACjE,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IAC1C,IAAI,CAAC;QACH,IAAI,OAAO,MAAM,KAAK,WAAW;YAAE,OAAO,EAAE,CAAC;QAC7C,IAAI,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,KAAK,MAAM,CAAC,QAAQ,CAAC,MAAM;YAAE,OAAO,EAAE,CAAC;IAChE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,2EAA2E;IAC3E,mDAAmD;IACnD,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,cAAc,EAAE,aAAa,EAAE,CAAC;AACrE,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ugly-app",
3
3
  "packageManager": "pnpm@10.34.5",
4
- "version": "0.1.901",
4
+ "version": "0.1.902",
5
5
  "type": "module",
6
6
  "comment:files": "Allowlist what ships to npm. dist = runtime; src = sourcemap targets (dist/*.js.map reference ../../src/); templates = CLI scaffold. Everything else at repo root (.pgdata local Postgres, coverage, assets/icons sources, test/, test-results/) is excluded by omission. The !negations strip the scaffold's installed deps + cruft (templates/node_modules is 200MB+ and must never ship). package.json/README/LICENSE ship automatically.",
7
7
  "files": [
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.901";
2
+ export const CLI_VERSION = "0.1.902";
@@ -1,4 +1,5 @@
1
1
  import type { CSSProperties, ReactElement } from 'react';
2
+ import { crossOriginProps } from './crossOriginProps.js';
2
3
 
3
4
  /** Minimal image result — compatible with app's ImageResult */
4
5
  export interface ImageSource {
@@ -43,6 +44,7 @@ export function Image({
43
44
  src={url}
44
45
  alt={alt}
45
46
  className={className}
47
+ {...crossOriginProps(url)}
46
48
  style={{ objectFit: objectFitMap[resizeMode], ...style }}
47
49
  />
48
50
  );
@@ -1,5 +1,6 @@
1
1
  import type { CSSProperties, ImgHTMLAttributes, ReactElement } from 'react';
2
2
  import { useCachedDoc } from '../cachedDoc.js';
3
+ import { crossOriginProps } from './crossOriginProps.js';
3
4
 
4
5
  export interface UserImageProps extends Omit<
5
6
  ImgHTMLAttributes<HTMLImageElement>,
@@ -50,5 +51,16 @@ export function UserImage({
50
51
  );
51
52
  }
52
53
 
53
- return <img src={src} style={boxStyle} alt="" {...rest} />;
54
+ // crossOriginProps: user avatars live on blob.ugly.bot, which sends CORS but
55
+ // not CORP — without opting into CORS they are blocked outright on any
56
+ // cross-origin-isolated (COEP require-corp) page. See crossOriginProps.ts.
57
+ return (
58
+ <img
59
+ src={src}
60
+ style={boxStyle}
61
+ alt=""
62
+ {...crossOriginProps(src)}
63
+ {...rest}
64
+ />
65
+ );
54
66
  }
@@ -0,0 +1,35 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Getting this wrong in either direction breaks images:
4
+ // - omitting `crossorigin` on a cross-origin URL leaves it blocked under COEP
5
+ // - adding it to a same-origin URL (or a host without CORS headers) turns a
6
+ // working image into a broken one
7
+ // so both directions are pinned.
8
+ import { describe, it, expect } from 'vitest';
9
+ import { crossOriginProps } from './crossOriginProps.js';
10
+
11
+ describe('crossOriginProps', () => {
12
+ it('opts a cross-origin image into CORS so COEP require-corp allows it', () => {
13
+ expect(crossOriginProps('https://blob.ugly.bot/user/abc/def.webp')).toEqual(
14
+ { crossOrigin: 'anonymous', referrerPolicy: 'no-referrer' },
15
+ );
16
+ });
17
+
18
+ it('leaves same-origin images alone', () => {
19
+ expect(crossOriginProps(`${window.location.origin}/logo.png`)).toEqual({});
20
+ });
21
+
22
+ it('leaves relative URLs alone', () => {
23
+ expect(crossOriginProps('/assets/logo.png')).toEqual({});
24
+ expect(crossOriginProps('logo.png')).toEqual({});
25
+ });
26
+
27
+ it('leaves data: and blob: URLs alone', () => {
28
+ expect(crossOriginProps('data:image/png;base64,iVBORw0KGgo=')).toEqual({});
29
+ expect(crossOriginProps('blob:https://example.com/abc-123')).toEqual({});
30
+ });
31
+
32
+ it('does not throw on a malformed URL', () => {
33
+ expect(crossOriginProps('https://')).toEqual({});
34
+ });
35
+ });
@@ -0,0 +1,43 @@
1
+ // Cross-origin media under COEP `require-corp`.
2
+ //
3
+ // Apps that enable cross-origin isolation (needed for SharedArrayBuffer) send
4
+ // `Cross-Origin-Embedder-Policy: require-corp`. Under that policy the browser
5
+ // BLOCKS every cross-origin subresource unless one of two things is true:
6
+ //
7
+ // 1. the response carries `Cross-Origin-Resource-Policy: cross-origin`, or
8
+ // 2. the element opts into CORS (`crossorigin`) AND the response carries
9
+ // permissive CORS headers.
10
+ //
11
+ // `blob.ugly.bot` (the R2 custom domain serving user avatars and generated
12
+ // media) sends `Access-Control-Allow-Origin: *` but NOT `Cross-Origin-Resource-
13
+ // Policy` — CORP is not expressible through R2's bucket CORS config. So a plain
14
+ // `<img src="https://blob.ugly.bot/…">` on a cross-origin-isolated page is
15
+ // blocked and renders as a broken image. In prod this silently ate user avatars
16
+ // on ugly.chat (reported as "Icon don't appear properly"); the request itself is
17
+ // a perfectly healthy 200 image/webp, which is why it never looked like a server
18
+ // problem.
19
+ //
20
+ // Opting into CORS satisfies condition 2 using the headers blob.ugly.bot
21
+ // already sends, so this needs no infrastructure change.
22
+ //
23
+ // Only applied to cross-origin URLs: adding `crossorigin` to a same-origin image
24
+ // is unnecessary, and on a server that does not emit CORS headers it would turn
25
+ // a working image into a broken one.
26
+
27
+ /** `crossOrigin`/`referrerPolicy` props for an <img>, or `{}` when same-origin. */
28
+ export function crossOriginProps(url: string): {
29
+ crossOrigin?: 'anonymous';
30
+ referrerPolicy?: 'no-referrer';
31
+ } {
32
+ // Relative URLs and data:/blob: are same-origin by construction.
33
+ if (!/^https?:\/\//i.test(url)) return {};
34
+ try {
35
+ if (typeof window === 'undefined') return {};
36
+ if (new URL(url).origin === window.location.origin) return {};
37
+ } catch {
38
+ return {};
39
+ }
40
+ // `no-referrer` keeps the CORS preflight-free request cacheable and avoids
41
+ // leaking the current page path to the media host.
42
+ return { crossOrigin: 'anonymous', referrerPolicy: 'no-referrer' };
43
+ }