From d9924a95b9ddd1f1539200e89d82732ddfe34aba Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 2 Aug 2026 12:00:30 -0400 Subject: [PATCH] (feat) Discord build share --- Dockerfile | 2 +- docs/DESIGN_SYSTEM.md | 31 ++- docs/architecture.md | 34 ++++ src/lib/build-sharing.test.ts | 84 ++++++++ src/lib/build-sharing.ts | 72 +++++++ src/lib/components/BuildShareControl.svelte | 108 +++++++++++ src/lib/components/ImageUploadInput.svelte | 8 +- src/lib/server/build-social-card.test.ts | 105 ++++++++++ src/lib/server/build-social-card.ts | 170 +++++++++++++++++ src/lib/server/public-origin.test.ts | 22 +++ src/lib/server/public-origin.ts | 13 ++ src/lib/server/shared-build.test.ts | 100 ++++++++++ src/lib/server/shared-build.ts | 104 ++++++++++ src/routes/builder/+page.server.ts | 2 + src/routes/builder/+page.svelte | 15 +- src/routes/builds/[slug]/+page.server.ts | 94 +-------- src/routes/builds/[slug]/+page.svelte | 26 ++- .../builds/[slug]/preview.png/+server.ts | 180 ++++++++++++++++++ .../builds/[slug]/preview.png/server.test.ts | 119 ++++++++++++ 19 files changed, 1193 insertions(+), 96 deletions(-) create mode 100644 src/lib/build-sharing.test.ts create mode 100644 src/lib/build-sharing.ts create mode 100644 src/lib/components/BuildShareControl.svelte create mode 100644 src/lib/server/build-social-card.test.ts create mode 100644 src/lib/server/build-social-card.ts create mode 100644 src/lib/server/public-origin.test.ts create mode 100644 src/lib/server/public-origin.ts create mode 100644 src/lib/server/shared-build.test.ts create mode 100644 src/lib/server/shared-build.ts create mode 100644 src/routes/builds/[slug]/preview.png/+server.ts create mode 100644 src/routes/builds/[slug]/preview.png/server.test.ts diff --git a/Dockerfile b/Dockerfile index a43a522..b53008f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ ENV NODE_ENV=production \ TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl gosu \ + && apt-get install -y --no-install-recommends ca-certificates curl fonts-dejavu-core gosu \ && useradd --uid 10001 --gid node --no-create-home --shell /usr/sbin/nologin shd-runtime \ && case "$TARGETARCH" in \ amd64) infisical_sha256=53abc96a3bc872ade81efe156275bd30c53f9ee8b52965b0c9cb320a9c814fbc ;; \ diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 71b1e51..88ecf58 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1920,10 +1920,33 @@ selector: - `PUBLIC` is accessible by direct link and included in the public archive. Keep visibility adjacent to the account-save action and explain its selected -state in plain language. Show a share-page action only after a non-private build -has been saved. Public and unlisted build pages are read-only tactical -summaries; owner editing returns to the builder. On narrow layouts, persistence -controls and shared-loadout columns stack vertically. +state in plain language. Show Build Uplink after an Agent saves a Build. Public +and unlisted Builds expose its active controls; private Builds show the locked +state. Public and unlisted Build pages are read-only tactical summaries; owner +editing returns to the builder. + +Build Uplink uses a compact tactical control surface. Use one bordered dark +panel with a raised telemetry header, a textual `PUBLIC LINK READY` or +`UNLISTED LINK READY` state, and a small orange signal marker. The primary +`SHARE BUILD` action opens the native Web Share sheet when the browser supports +it. Keep `COPY LINK` visible as a separate explicit action for every eligible +Build. When Web Share is unavailable, promote Copy Link to the primary action +instead of presenting a control that cannot run. Supporting copy may state that +Discord renders the Build preview after an Agent posts the link. It must not +imply that SHD.build posts to Discord or operates a bot. + +Private and unsaved Builds use the locked Uplink state. Show `PRIVATE BUILD` or +`UNSAVED BUILD`, `PUBLIC LINK UNAVAILABLE`, and a short instruction to change +visibility or save. Render no active Share or Copy Link control in that state. +Do not expose a crawler-preview action or suggest that owner access makes a +private Build shareable. + +Uplink actions retain textual pending, success, cancellation, unsupported, and +failure feedback. They use at least 44-pixel targets, orange focus-visible +treatment, pressed feedback, and disabled duplicate-action protection. Stack +Share above Copy Link at phone widths and remove pressed motion under +`prefers-reduced-motion`. On narrow layouts, persistence controls and +shared-loadout columns also stack vertically. ### Tiered compatibility editors diff --git a/docs/architecture.md b/docs/architecture.md index 53ecb10..7f54bbd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -187,6 +187,40 @@ boundary. An owned Build page links to `/guides/new?build=`, where the server verifies ownership and initializes a removable Build block in a new private Guide. +### Discord-compatible Build sharing experiment + +Persisted public and unlisted Builds expose absolute Open Graph metadata for +their canonical `/builds/[slug]` URL. Their `og:image` and Twitter card metadata +reference the same-origin 1200 × 630 PNG at `/builds/[slug]/preview.png` so +Discord and other link unfurlers can render a Build summary. Private Builds set +`noindex,nofollow`, expose no crawler image, and provide no usable sharing +control. + +The preview endpoint derives its image from canonical Build facts and the +current known-stat summary in the shared Build projection. It includes the Build +title, author, visibility, direct URL, equipped weapon names, core totals, and +up to three supporting Attributes. The renderer performs no external fetches. It +normalizes and bounds every text field by grapheme count, escapes text before +inserting it into XML, builds a text-only SVG, and rasterizes that SVG to PNG +with Sharp. Production installs DejaVu Sans from the Debian package and the +renderer names that fixed family explicitly. + +The endpoint hashes a versioned serialization of the image input for its bounded +in-process render cache and derives the HTTP ETag from the resulting PNG bytes. +A bounded request queue limits concurrent database projections and Sharp work. +Responses require cache revalidation so a visibility change to Private blocks +the next request instead of leaving a stale response window. Matching +conditional requests receive `304 Not Modified`. Private, missing, and +inaccessible Builds receive no preview. Unlisted images send the +`X-Robots-Tag: noindex` response header. + +For eligible Builds, the Build Uplink invokes the native Web Share API as the +primary action when the browser supports it. An explicit Copy Link action +remains available for every public or unlisted persisted Build. This experiment +does not install a Discord bot, post to Discord, extend Discord OAuth scopes, or +store Discord tokens or webhooks. Discord receives only a link that an Agent +chooses to share and may fetch the public metadata and same-origin preview. + ## Public data corrections and discussion Every public database card links to its canonical ISAC record with an exact diff --git a/src/lib/build-sharing.test.ts b/src/lib/build-sharing.test.ts new file mode 100644 index 0000000..349dfea --- /dev/null +++ b/src/lib/build-sharing.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildShareUrl, copyBuildLink, shareBuild } from './build-sharing'; + +describe('Build sharing', () => { + it('constructs Build URLs from slugs and preserves absolute URLs', () => { + expect(buildShareUrl('field-medic', 'https://shd.build')).toBe( + 'https://shd.build/builds/field-medic', + ); + expect(buildShareUrl('build name', 'https://shd.build/ignored')).toBe( + 'https://shd.build/builds/build%20name', + ); + expect(buildShareUrl('https://stage.shd.build/builds/test')).toBe( + 'https://stage.shd.build/builds/test', + ); + }); + + it('uses the native share API', async () => { + const share = vi.fn().mockResolvedValue(undefined); + + await expect( + shareBuild( + { share }, + { title: 'Field Medic', url: 'https://shd.build/builds/field-medic' }, + ), + ).resolves.toEqual({ state: 'shared' }); + expect(share).toHaveBeenCalledWith({ + title: 'Field Medic', + text: 'View Field Medic on SHD.build.', + url: 'https://shd.build/builds/field-medic', + }); + }); + + it('treats AbortError as cancellation', async () => { + const share = vi + .fn() + .mockRejectedValue(new DOMException('Cancelled', 'AbortError')); + + await expect( + shareBuild( + { share }, + { title: 'Build', url: 'https://shd.build/builds/a' }, + ), + ).resolves.toEqual({ state: 'cancelled' }); + }); + + it('reports unsupported and failed native sharing', async () => { + await expect( + shareBuild({}, { title: 'Build', url: 'https://shd.build/builds/a' }), + ).resolves.toEqual({ state: 'unsupported' }); + + const error = new Error('Share failed'); + await expect( + shareBuild( + { share: vi.fn().mockRejectedValue(error) }, + { title: 'Build', url: 'https://shd.build/builds/a' }, + ), + ).resolves.toEqual({ state: 'failed', error }); + }); + + it('copies the Build URL to the clipboard', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + + await expect( + copyBuildLink({ clipboard: { writeText } }, 'https://shd.build/builds/a'), + ).resolves.toEqual({ state: 'copied' }); + expect(writeText).toHaveBeenCalledWith('https://shd.build/builds/a'); + }); + + it('reports unsupported and failed clipboard access', async () => { + await expect( + copyBuildLink({}, 'https://shd.build/builds/a'), + ).resolves.toEqual({ + state: 'unsupported', + }); + + const error = new Error('Clipboard denied'); + await expect( + copyBuildLink( + { clipboard: { writeText: vi.fn().mockRejectedValue(error) } }, + 'https://shd.build/builds/a', + ), + ).resolves.toEqual({ state: 'failed', error }); + }); +}); diff --git a/src/lib/build-sharing.ts b/src/lib/build-sharing.ts new file mode 100644 index 0000000..0f7f3ab --- /dev/null +++ b/src/lib/build-sharing.ts @@ -0,0 +1,72 @@ +export type BuildShareNavigator = { + share?: (data: ShareData) => Promise; + clipboard?: { + writeText: (text: string) => Promise; + }; +}; + +export type BuildShareResult = + | { state: 'shared' } + | { state: 'cancelled' } + | { state: 'unsupported' } + | { state: 'failed'; error: unknown }; + +export type BuildCopyResult = + | { state: 'copied' } + | { state: 'unsupported' } + | { state: 'failed'; error: unknown }; + +export function buildShareUrl(slugOrUrl: string, origin?: string): string { + const value = slugOrUrl.trim(); + if (!value) throw new Error('A Build slug or URL is required.'); + + if (/^https?:\/\//i.test(value)) return new URL(value).toString(); + + const base = origin ?? globalThis.location?.origin; + if (!base) throw new Error('An origin is required to construct a Build URL.'); + + const slug = value.replace(/^\/+|\/+$/g, ''); + return new URL(`/builds/${encodeURIComponent(slug)}`, base).toString(); +} + +export async function shareBuild( + navigatorApi: BuildShareNavigator, + input: { title: string; url: string }, +): Promise { + if (typeof navigatorApi.share !== 'function') return { state: 'unsupported' }; + + try { + await navigatorApi.share({ + title: input.title, + text: `View ${input.title} on SHD.build.`, + url: input.url, + }); + return { state: 'shared' }; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError' + ) { + return { state: 'cancelled' }; + } + return { state: 'failed', error }; + } +} + +export async function copyBuildLink( + navigatorApi: BuildShareNavigator, + url: string, +): Promise { + if (typeof navigatorApi.clipboard?.writeText !== 'function') { + return { state: 'unsupported' }; + } + + try { + await navigatorApi.clipboard.writeText(url); + return { state: 'copied' }; + } catch (error) { + return { state: 'failed', error }; + } +} diff --git a/src/lib/components/BuildShareControl.svelte b/src/lib/components/BuildShareControl.svelte new file mode 100644 index 0000000..33bea08 --- /dev/null +++ b/src/lib/components/BuildShareControl.svelte @@ -0,0 +1,108 @@ + + +
+ {#if canShare} +
+
+ BUILD UPLINK + {visibility === 'unlisted' ? 'UNLISTED LINK READY' : 'PUBLIC LINK READY'} +
+ +
+
+ {#if nativeShareAvailable} + + {/if} + +
+

Discord will render the Build preview when you post the copied link.

+ {:else} +
+ {slugOrUrl?.trim() ? 'PRIVATE BUILD' : 'UNSAVED BUILD'} + PUBLIC LINK UNAVAILABLE +

{slugOrUrl?.trim() ? 'Change visibility to Public or Unlisted before sharing this Build.' : 'Save this Build before creating a public or unlisted sharing link.'}

+
+ {/if} +

{status}

+
+ + diff --git a/src/lib/components/ImageUploadInput.svelte b/src/lib/components/ImageUploadInput.svelte index 4c91df0..d0b9d8a 100644 --- a/src/lib/components/ImageUploadInput.svelte +++ b/src/lib/components/ImageUploadInput.svelte @@ -75,7 +75,11 @@ } function paste(event: ClipboardEvent) { - const file = Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/')); + const file = + Array.from(event.clipboardData?.items ?? []) + .find((item) => item.kind === 'file' && item.type.startsWith('image/')) + ?.getAsFile() ?? + Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/')); if (file) { event.preventDefault(); setFile(file); @@ -107,10 +111,12 @@ }); +
{ event.preventDefault(); dragging = true; }} ondragover={(event) => event.preventDefault()} diff --git a/src/lib/server/build-social-card.test.ts b/src/lib/server/build-social-card.test.ts new file mode 100644 index 0000000..e8dc38c --- /dev/null +++ b/src/lib/server/build-social-card.test.ts @@ -0,0 +1,105 @@ +import sharp from 'sharp'; +import { describe, expect, it } from 'vitest'; +import { + BUILD_SOCIAL_CARD_HEIGHT, + BUILD_SOCIAL_CARD_WIDTH, + buildBuildSocialCardSvg, + renderBuildSocialCardPng, + type BuildSocialCardInput, +} from './build-social-card'; + +const input: BuildSocialCardInput = { + title: 'Vanguard Counter-Offensive', + author: 'Agent Kelso', + primaryWeapon: 'Police M4', + secondaryWeapon: 'Super 90', + firepower: 1280, + toughness: 940, + engineering: 615, + supportingAttributes: [ + { label: 'Critical Hit Chance', value: '18.5%' }, + { label: 'Headshot Damage', value: '42%' }, + { label: 'Armor', value: '12,400' }, + ], + visibility: 'public', + directUrl: 'shd.build/builds/vanguard-counter-offensive', +}; + +describe('build social card', () => { + it('escapes XML injection and keeps inputs out of markup and URLs', () => { + const attack = ``; + const svg = buildBuildSocialCardSvg({ + ...input, + title: attack, + author: `A\u0000gent <&> "one" 'two'`, + directUrl: `javascript:alert(1)\n${attack}`, + supportingAttributes: [{ label: attack, value: attack }], + }); + + expect(svg).not.toContain(']+\s(?:href|xlink:href)\s*=/gu); + }); + + it('wraps and truncates long Unicode text without splitting graphemes', () => { + const family = '👨‍👩‍👧‍👦'; + const svg = buildBuildSocialCardSvg({ + ...input, + title: `${family.repeat(31)} ${family.repeat(31)} ${family.repeat(31)}`, + author: `Zoë ${'e\u0301'.repeat(60)}`, + directUrl: `shd.build/builds/${'路'.repeat(130)}`, + }); + + const titleBlock = + svg.match(/(.*?)<\/text>/u)?.[1] ?? ''; + expect(titleBlock.match(/ { + const withExtraFacts = { + ...input, + supportingAttributes: [ + ...input.supportingAttributes!, + { label: 'Must not render', value: '999' }, + ], + }; + const first = buildBuildSocialCardSvg(withExtraFacts); + const second = buildBuildSocialCardSvg(withExtraFacts); + + expect(first).toBe(second); + expect(first).toContain('width="1200" height="630"'); + expect(first).toContain('SHD { + const png = await renderBuildSocialCardPng(input); + const metadata = await sharp(png).metadata(); + + expect(png.subarray(0, 8)).toEqual( + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + ); + expect(metadata.format).toBe('png'); + expect(metadata.width).toBe(BUILD_SOCIAL_CARD_WIDTH); + expect(metadata.height).toBe(BUILD_SOCIAL_CARD_HEIGHT); + expect(png.byteLength).toBeLessThan(1_000_000); + }); +}); diff --git a/src/lib/server/build-social-card.ts b/src/lib/server/build-social-card.ts new file mode 100644 index 0000000..d43dba0 --- /dev/null +++ b/src/lib/server/build-social-card.ts @@ -0,0 +1,170 @@ +import sharp from 'sharp'; + +export const BUILD_SOCIAL_CARD_WIDTH = 1200; +export const BUILD_SOCIAL_CARD_HEIGHT = 630; + +export type BuildVisibility = 'public' | 'unlisted' | 'private'; + +export interface BuildSocialCardFact { + label: string; + value: string; +} + +export interface BuildSocialCardInput { + title: string; + author: string; + primaryWeapon: string; + secondaryWeapon: string; + firepower: string | number; + toughness: string | number; + engineering: string | number; + supportingAttributes?: readonly BuildSocialCardFact[]; + visibility: BuildVisibility; + directUrl: string; +} + +const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); + +function graphemes(value: string): string[] { + return Array.from(segmenter.segment(value), ({ segment }) => segment); +} + +function normalizeText(value: string | number, maximum: number): string { + const normalized = String(value) + .normalize('NFC') + .replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ') + .replace(/\s+/gu, ' ') + .trim(); + const characters = graphemes(normalized); + if (characters.length <= maximum) return normalized; + return `${characters + .slice(0, Math.max(0, maximum - 1)) + .join('') + .trimEnd()}…`; +} + +function escapeXml(value: string): string { + return value + .replace(/&/gu, '&') + .replace(//gu, '>') + .replace(/"/gu, '"') + .replace(/'/gu, '''); +} + +function wrapText( + value: string, + width: number, + maximumLines: number, +): string[] { + const characters = graphemes(value); + const lines: string[] = []; + let offset = 0; + + while (offset < characters.length && lines.length < maximumLines) { + const remaining = characters.slice(offset); + if (remaining.length <= width) { + lines.push(remaining.join('').trim()); + offset = characters.length; + break; + } + + const candidate = remaining.slice(0, width + 1); + let splitAt = -1; + for ( + let index = Math.min(width, candidate.length - 1); + index > 0; + index-- + ) { + if (/\s/u.test(candidate[index])) { + splitAt = index; + break; + } + } + if (splitAt < Math.floor(width * 0.55)) splitAt = width; + lines.push(remaining.slice(0, splitAt).join('').trim()); + offset += splitAt; + while (offset < characters.length && /\s/u.test(characters[offset])) + offset++; + } + + if (offset < characters.length && lines.length > 0) { + const last = graphemes(lines.at(-1) ?? ''); + lines[lines.length - 1] = `${last + .slice(0, Math.max(0, width - 1)) + .join('') + .trimEnd()}…`; + } + return lines.length > 0 ? lines : ['UNTITLED BUILD']; +} + +function textLines( + lines: readonly string[], + x: number, + y: number, + lineHeight: number, +): string { + return lines + .map( + (line, index) => + `${escapeXml(line)}`, + ) + .join(''); +} + +function visibilityLabel(visibility: BuildVisibility): string { + if (visibility === 'public') return 'PUBLIC'; + if (visibility === 'unlisted') return 'UNLISTED'; + return 'PRIVATE'; +} + +/** Builds the complete, text-only SVG supplied to Sharp. */ +export function buildBuildSocialCardSvg(input: BuildSocialCardInput): string { + const title = wrapText(normalizeText(input.title, 112), 31, 2); + const author = normalizeText(input.author, 42) || 'UNKNOWN AGENT'; + const primaryWeapon = normalizeText(input.primaryWeapon, 52) || 'UNSPECIFIED'; + const secondaryWeapon = + normalizeText(input.secondaryWeapon, 52) || 'UNSPECIFIED'; + const firepower = normalizeText(input.firepower, 18) || '—'; + const toughness = normalizeText(input.toughness, 18) || '—'; + const engineering = normalizeText(input.engineering, 18) || '—'; + const directUrl = normalizeText(input.directUrl, 92) || 'shd.build'; + const facts = (input.supportingAttributes ?? []).slice(0, 3).map((fact) => ({ + label: normalizeText(fact.label.toUpperCase(), 28) || 'ATTRIBUTE', + value: normalizeText(fact.value, 30) || '—', + })); + + const factMarkup = facts + .map((fact, index) => { + const y = 421 + index * 48; + return `${escapeXml(fact.label)}${escapeXml(fact.value)}`; + }) + .join(''); + + return ` + + +SHD.buildBUILD DOSSIER / SOCIAL TRANSMISSION +AGENT LOADOUT${textLines(title, 52, 201, 61)}AUTHORED BY${escapeXml(author)} +PRIMARY WEAPON${escapeXml(primaryWeapon)} +SECONDARY WEAPON${escapeXml(secondaryWeapon)} +CORE ATTRIBUTESFIREPOWER${escapeXml(firepower)}TOUGHNESS${escapeXml(toughness)}ENGINEERING${escapeXml(engineering)} +SUPPORTING ATTRIBUTES / ${String(facts.length).padStart(2, '0')}${factMarkup} +DIRECT LINK${escapeXml(directUrl)}${visibilityLabel(input.visibility)} +`; +} + +/** Renders a deterministic 1200x630 PNG without loading external resources. */ +export async function renderBuildSocialCardPng( + input: BuildSocialCardInput, +): Promise { + return sharp(Buffer.from(buildBuildSocialCardSvg(input)), { + density: 72, + failOn: 'error', + unlimited: false, + }) + .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false }) + .toBuffer(); +} diff --git a/src/lib/server/public-origin.test.ts b/src/lib/server/public-origin.test.ts new file mode 100644 index 0000000..d829de2 --- /dev/null +++ b/src/lib/server/public-origin.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { publicOrigin } from './public-origin'; + +describe('publicOrigin', () => { + it('uses the configured public origin instead of a request host', () => { + expect( + publicOrigin('https://host-header.invalid', 'https://shd.build/app/path'), + ).toBe('https://shd.build'); + }); + + it('uses the request origin when APP_ORIGIN is not configured', () => { + expect(publicOrigin('http://localhost:5173', '')).toBe( + 'http://localhost:5173', + ); + }); + + it('rejects non-HTTP origins', () => { + expect(() => + publicOrigin('https://shd.build', 'javascript:alert(1)'), + ).toThrow('APP_ORIGIN must use HTTP or HTTPS.'); + }); +}); diff --git a/src/lib/server/public-origin.ts b/src/lib/server/public-origin.ts new file mode 100644 index 0000000..e725ddc --- /dev/null +++ b/src/lib/server/public-origin.ts @@ -0,0 +1,13 @@ +import { env } from '$env/dynamic/private'; + +export function publicOrigin( + fallback: string, + configuredOrigin: string | undefined = env.APP_ORIGIN, +): string { + const value = configuredOrigin?.trim() || fallback; + const url = new URL(value); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('APP_ORIGIN must use HTTP or HTTPS.'); + } + return url.origin; +} diff --git a/src/lib/server/shared-build.test.ts b/src/lib/server/shared-build.test.ts new file mode 100644 index 0000000..4506f94 --- /dev/null +++ b/src/lib/server/shared-build.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDraft } from '$lib/builder/loadout'; + +const mocks = vi.hoisted(() => ({ + row: null as Record | null, + loadCatalog: vi.fn(), +})); + +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => ({ + prepare: vi.fn(async (sql: string) => ({ + get: vi.fn(async () => (sql.includes('FROM builds') ? mocks.row : null)), + all: vi.fn(async () => []), + })), + })), +})); +vi.mock('$lib/server/shared-build-summary', () => ({ + loadSharedBuildSummaryCatalog: mocks.loadCatalog, +})); + +import { loadSharedBuildProjection } from './shared-build'; + +function buildRow(visibility: 'private' | 'unlisted' | 'public') { + return { + id: 'build-id', + owner_id: 'owner-id', + title: 'Field Medic', + slug: 'field-medic-12345678', + visibility, + author: 'Aewyn', + profile_is_public: 1, + loadout_json: JSON.stringify(createDraft()), + updated_at: '2026-08-02T00:00:00.000Z', + }; +} + +describe('shared Build access policy', () => { + beforeEach(() => { + mocks.row = null; + mocks.loadCatalog.mockReset().mockResolvedValue({ + attributes: [], + gear: [], + gearAugments: [], + brands: [], + weapons: [], + talents: [], + chipSets: [], + protocols: [], + specializations: [], + }); + }); + + it.each(['public', 'unlisted'] as const)( + 'permits %s Builds in public previews', + async (visibility) => { + mocks.row = buildRow(visibility); + const result = await loadSharedBuildProjection('field-medic-12345678', { + kind: 'public-preview', + }); + expect(result?.build.visibility).toBe(visibility); + expect(result?.build.owner).toBe(false); + }, + ); + + it('does not expose private Builds to public previews or other viewers', async () => { + mocks.row = buildRow('private'); + await expect( + loadSharedBuildProjection('field-medic-12345678', { + kind: 'public-preview', + }), + ).resolves.toBeNull(); + await expect( + loadSharedBuildProjection('field-medic-12345678', { + kind: 'viewer-page', + viewerId: 'another-agent', + }), + ).resolves.toBeNull(); + expect(mocks.loadCatalog).not.toHaveBeenCalled(); + }); + + it('permits the owner to view a private Build page', async () => { + mocks.row = buildRow('private'); + const result = await loadSharedBuildProjection('field-medic-12345678', { + kind: 'viewer-page', + viewerId: 'owner-id', + }); + expect(result?.build.owner).toBe(true); + expect(result?.build.visibility).toBe('private'); + }); + + it('returns null for malformed loadout snapshots', async () => { + mocks.row = { ...buildRow('public'), loadout_json: '{invalid' }; + await expect( + loadSharedBuildProjection('field-medic-12345678', { + kind: 'public-preview', + }), + ).resolves.toBeNull(); + expect(mocks.loadCatalog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/shared-build.ts b/src/lib/server/shared-build.ts new file mode 100644 index 0000000..a4417e7 --- /dev/null +++ b/src/lib/server/shared-build.ts @@ -0,0 +1,104 @@ +import { avatarUrl } from '$lib/avatar'; +import { parseLoadoutDraft } from '$lib/builder/loadout'; +import { summarizeLoadout } from '$lib/builder/loadout-summary'; +import { getDatabase } from '$lib/server/database'; +import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary'; + +export type SharedBuildAccessPolicy = + { kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' }; +type BuildVisibility = 'public' | 'unlisted' | 'private'; + +export async function loadSharedBuildProjection( + slug: string, + access: SharedBuildAccessPolicy, +) { + const database = await getDatabase(); + const build = await ( + await database.prepare(` + SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author, + user_profiles.public_slug, + COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id, + user_avatars.source AS avatar_source,user_avatars.value AS avatar_value, + user_avatars.updated_at AS avatar_updated_at + FROM builds + JOIN users ON users.id=builds.owner_id + LEFT JOIN user_profiles ON user_profiles.user_id=users.id + LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id + LEFT JOIN user_avatars ON user_avatars.user_id=users.id + WHERE builds.slug=? + `) + ).get(slug); + + if (!build) return null; + + const ownerId = String(build.owner_id); + const visibility = String(build.visibility) as BuildVisibility; + const viewerId = access.kind === 'viewer-page' ? access.viewerId : null; + const accessible = + access.kind === 'public-preview' + ? visibility === 'public' || visibility === 'unlisted' + : visibility === 'public' || + visibility === 'unlisted' || + (visibility === 'private' && ownerId === viewerId); + if (!accessible) return null; + + let loadout; + try { + loadout = parseLoadoutDraft(JSON.parse(String(build.loadout_json))); + } catch { + return null; + } + if (!loadout) return null; + + const [gear, weapons, chips, protocols, brands, summaryCatalog] = + await Promise.all([ + ( + await database.prepare( + 'SELECT id,name,slot,tier,base_armor_min FROM gear', + ) + ).all(), + ( + await database.prepare( + 'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons', + ) + ).all(), + (await database.prepare('SELECT id,name FROM skill_chip_sets')).all(), + ( + await database.prepare('SELECT id,name,quality FROM os_protocols') + ).all(), + (await database.prepare('SELECT id,name FROM brand_sets')).all(), + loadSharedBuildSummaryCatalog(database), + ]); + const summary = summarizeLoadout(loadout, summaryCatalog); + + return { + build: { + id: String(build.id), + title: String(build.title), + slug: String(build.slug), + visibility, + author: String(build.author), + ownerId, + authorPublicSlug: build.public_slug ? String(build.public_slug) : null, + profileAvailable: + Boolean(build.profile_is_public) && !build.deleted_user_id, + authorAvatarUrl: avatarUrl( + build.avatar_source, + build.avatar_value, + ownerId, + build.avatar_updated_at, + ), + owner: ownerId === viewerId, + updatedAt: String(build.updated_at), + }, + loadout, + gear, + weapons, + chips, + protocols, + brands, + summary, + attributes: summaryCatalog.attributes, + talents: summaryCatalog.talents, + }; +} diff --git a/src/routes/builder/+page.server.ts b/src/routes/builder/+page.server.ts index cf8b299..9a29d15 100644 --- a/src/routes/builder/+page.server.ts +++ b/src/routes/builder/+page.server.ts @@ -4,6 +4,7 @@ import { getSessionUser } from '$lib/server/auth/session'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import { parseLoadoutDraft } from '$lib/builder/loadout'; +import { publicOrigin } from '$lib/server/public-origin'; import type { Actions, PageServerLoad } from './$types'; import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses'; @@ -284,6 +285,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { })); return { viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null, + publicOrigin: publicOrigin(url.origin), initialBuild, attributes, coreRanges, diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index e1565b1..15c0bc8 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -8,6 +8,7 @@ import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import PendingForm from '$lib/components/PendingForm.svelte'; import ItemIcon from '$lib/components/ItemIcon.svelte'; + import BuildShareControl from '$lib/components/BuildShareControl.svelte'; import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte'; import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte'; import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte'; @@ -25,7 +26,10 @@ let message = $state(''); let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? '')); let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? '')); - let persistedVisibility = $state(untrack(() => data.initialBuild?.visibility ?? 'private')); + type BuildVisibility = 'private' | 'unlisted' | 'public'; + let persistedVisibility = $state(untrack(() => (data.initialBuild?.visibility ?? 'private') as BuildVisibility)); + let savedBuildVisibility = $state(untrack(() => (data.initialBuild?.visibility ?? 'private') as BuildVisibility)); + let savedBuildTitle = $state(untrack(() => draft.buildName)); let handledBuildToken = ''; const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const; const coreKeys = ['firepower','toughness','engineering'] as const; @@ -378,7 +382,9 @@ handledBuildToken = form.buildToken; persistedBuildId = form.savedBuild.id; persistedSlug = form.savedBuild.slug; - persistedVisibility = form.savedBuild.visibility; + persistedVisibility = form.savedBuild.visibility as BuildVisibility; + savedBuildVisibility = form.savedBuild.visibility as BuildVisibility; + savedBuildTitle = form.savedBuild.title; message = form.savedBuild.visibility === 'private' ? 'Private build saved to your account.' : form.savedBuild.visibility === 'unlisted' @@ -419,15 +425,16 @@ { value:'private', label:'PRIVATE' }, { value:'unlisted', label:'UNLISTED' }, { value:'public', label:'PUBLIC' } - ]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} required/> + ]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>

{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}

- {#if persistedSlug && persistedVisibility !== 'private'}VIEW SHARE PAGE →{/if} + {#if persistedSlug && savedBuildVisibility !== 'private'}VIEW SHARE PAGE →{/if}
{/if}
KNOWN STATS ONLINEADVANCED MODEL · COMING SOON12 LOADOUT CELLS + 9 CHIPS{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS
{#if message}

{message}

{/if} {#if form?.buildError}{/if} + {#if data.viewer && persistedSlug}{/if} {#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}
ISAC DATA LINK EMPTYAdd active equipment definitions in ISAC to populate this workbench.
{/if}
{ - const database = await getDatabase(); +export const load: PageServerLoad = async ({ params, cookies, url }) => { const viewer = await getSessionUser(cookies); - const build = await ( - await database.prepare(` - SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author, - user_profiles.public_slug, - COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id, - user_avatars.source AS avatar_source,user_avatars.value AS avatar_value, - user_avatars.updated_at AS avatar_updated_at - FROM builds - JOIN users ON users.id=builds.owner_id - LEFT JOIN user_profiles ON user_profiles.user_id=users.id - LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id - LEFT JOIN user_avatars ON user_avatars.user_id=users.id - WHERE builds.slug=? - `) - ).get(params.slug); - if ( - !build || - (build.visibility === 'private' && build.owner_id !== viewer?.id) - ) - error(404, 'Build not found'); - let loadout: LoadoutDraft; - try { - const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json))); - if (!parsed) error(404, 'Build data is unavailable'); - loadout = parsed; - } catch { - error(404, 'Build data is unavailable'); - } - const [gear, weapons, chips, protocols, brands, summaryCatalog] = - await Promise.all([ - ( - await database.prepare( - 'SELECT id,name,slot,tier,base_armor_min FROM gear', - ) - ).all(), - ( - await database.prepare( - 'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons', - ) - ).all(), - (await database.prepare('SELECT id,name FROM skill_chip_sets')).all(), - ( - await database.prepare('SELECT id,name,quality FROM os_protocols') - ).all(), - (await database.prepare('SELECT id,name FROM brand_sets')).all(), - loadSharedBuildSummaryCatalog(database), - ]); - const summary = summarizeLoadout(loadout, summaryCatalog); - return { - build: { - id: String(build.id), - title: String(build.title), - slug: String(build.slug), - visibility: String(build.visibility), - author: String(build.author), - ownerId: String(build.owner_id), - authorPublicSlug: build.public_slug ? String(build.public_slug) : null, - profileAvailable: - Boolean(build.profile_is_public) && !build.deleted_user_id, - authorAvatarUrl: avatarUrl( - build.avatar_source, - build.avatar_value, - String(build.owner_id), - build.avatar_updated_at, - ), - owner: build.owner_id === viewer?.id, - updatedAt: String(build.updated_at), - }, - loadout, - gear, - weapons, - chips, - protocols, - brands, - summary, - attributes: summaryCatalog.attributes, - talents: summaryCatalog.talents, - }; + const projection = await loadSharedBuildProjection(params.slug, { + kind: 'viewer-page', + viewerId: viewer?.id ?? null, + }); + if (!projection) error(404, 'Build not found'); + return { ...projection, publicOrigin: publicOrigin(url.origin) }; }; diff --git a/src/routes/builds/[slug]/+page.svelte b/src/routes/builds/[slug]/+page.svelte index f962cf5..15f49aa 100644 --- a/src/routes/builds/[slug]/+page.svelte +++ b/src/routes/builds/[slug]/+page.svelte @@ -1,6 +1,7 @@ -{data.build.title} — SHD.build + + {data.build.title} — SHD.build + + + + + + + + {#if data.build.visibility !== 'private'} + + + + + + + + {/if} + {#if data.build.visibility !== 'public'}{/if} +

SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}

{data.build.title}

+
diff --git a/src/routes/builds/[slug]/preview.png/+server.ts b/src/routes/builds/[slug]/preview.png/+server.ts new file mode 100644 index 0000000..f80dbb3 --- /dev/null +++ b/src/routes/builds/[slug]/preview.png/+server.ts @@ -0,0 +1,180 @@ +import { createHash } from 'node:crypto'; +import { error } from '@sveltejs/kit'; +import { + renderBuildSocialCardPng, + type BuildSocialCardFact, + type BuildSocialCardInput, +} from '$lib/server/build-social-card'; +import { publicOrigin } from '$lib/server/public-origin'; +import { loadSharedBuildProjection } from '$lib/server/shared-build'; +import type { RequestHandler } from './$types'; + +const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }); +const MAX_CACHE_BYTES = 16 * 1024 * 1024; +const MAX_ACTIVE_REQUESTS = 4; +const MAX_QUEUED_REQUESTS = 32; +type CachedImage = { body: Buffer; etag: string }; +const imageCache = new Map(); +const pendingImages = new Map>(); +const requestQueue: Array<(release: () => void) => void> = []; +let cacheBytes = 0; +let activeRequests = 0; + +function unitValue(value: number, unit: string | null | undefined) { + const rendered = number.format(value); + return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit ?? ''}`; +} + +export function _socialCardInput( + projection: NonNullable< + Awaited> + >, + directUrl: string, +): BuildSocialCardInput { + const attribute = (id: string) => + projection.attributes.find((item) => String(item.id) === id); + const statByName = (name: string) => + projection.summary.stats.find( + (stat) => String(attribute(stat.id)?.name) === name, + ); + const statValue = (name: string) => { + const stat = statByName(name); + return stat ? unitValue(Number(stat.value), attribute(stat.id)?.unit) : '0'; + }; + const weaponName = (index: number) => { + const selected = projection.loadout.weapons[index]; + return ( + projection.weapons.find((item) => String(item.id) === selected?.itemId) + ?.name ?? 'Unassigned' + ); + }; + const coreNames = new Set(['Firepower', 'Toughness', 'Engineering']); + const supportingAttributes: BuildSocialCardFact[] = projection.summary.stats + .filter((stat) => !coreNames.has(String(attribute(stat.id)?.name))) + .slice(0, 3) + .map((stat) => ({ + label: String(attribute(stat.id)?.name ?? 'Attribute'), + value: unitValue(Number(stat.value), attribute(stat.id)?.unit), + })); + + return { + title: projection.build.title, + author: projection.build.author, + primaryWeapon: String(weaponName(0)), + secondaryWeapon: String(weaponName(1)), + firepower: statValue('Firepower'), + toughness: statValue('Toughness'), + engineering: statValue('Engineering'), + supportingAttributes, + visibility: projection.build.visibility as 'public' | 'unlisted', + directUrl, + }; +} + +function matchesEtag(request: Request, etag: string) { + return request.headers + .get('if-none-match') + ?.split(',') + .some((value) => value.trim() === etag); +} + +function releaseRequest() { + const next = requestQueue.shift(); + if (next) { + next(releaseRequest); + return; + } + activeRequests -= 1; +} + +function acquireRequest(): Promise<(() => void) | null> { + if (activeRequests < MAX_ACTIVE_REQUESTS) { + activeRequests += 1; + return Promise.resolve(releaseRequest); + } + if (requestQueue.length >= MAX_QUEUED_REQUESTS) return Promise.resolve(null); + return new Promise((resolve) => requestQueue.push(resolve)); +} + +function storeImage(key: string, image: CachedImage) { + imageCache.set(key, image); + cacheBytes += image.body.byteLength; + while (cacheBytes > MAX_CACHE_BYTES && imageCache.size > 1) { + const oldestKey = imageCache.keys().next().value; + if (!oldestKey) break; + const oldest = imageCache.get(oldestKey); + imageCache.delete(oldestKey); + cacheBytes -= oldest?.body.byteLength ?? 0; + } +} + +async function loadImage(input: BuildSocialCardInput, inputKey: string) { + const cached = imageCache.get(inputKey); + if (cached) { + imageCache.delete(inputKey); + imageCache.set(inputKey, cached); + return cached; + } + const pending = pendingImages.get(inputKey); + if (pending) return pending; + + const request = renderBuildSocialCardPng(input).then((body) => { + const image = { + body, + etag: `"${createHash('sha256').update(body).digest('base64url')}"`, + }; + storeImage(inputKey, image); + return image; + }); + pendingImages.set(inputKey, request); + try { + return await request; + } finally { + pendingImages.delete(inputKey); + } +} + +export const GET: RequestHandler = async ({ params, request, url }) => { + const release = await acquireRequest(); + if (!release) error(503, 'Build preview capacity exceeded'); + try { + const projection = await loadSharedBuildProjection(params.slug, { + kind: 'public-preview', + }); + if (!projection) error(404, 'Build preview not found'); + + const origin = publicOrigin(url.origin); + const directUrl = new URL( + `/builds/${encodeURIComponent(projection.build.slug)}`, + origin, + ).toString(); + const input = _socialCardInput(projection, directUrl); + const inputKey = createHash('sha256') + .update(JSON.stringify({ version: 1, input })) + .digest('base64url'); + const image = await loadImage(input, inputKey); + const cacheControl = 'public, max-age=0, must-revalidate'; + if (matchesEtag(request, image.etag)) { + return new Response(null, { + status: 304, + headers: { 'cache-control': cacheControl, etag: image.etag }, + }); + } + + return new Response(new Uint8Array(image.body), { + headers: { + 'cache-control': cacheControl, + 'content-length': String(image.body.byteLength), + 'content-type': 'image/png', + 'cross-origin-resource-policy': 'same-origin', + etag: image.etag, + 'x-content-type-options': 'nosniff', + ...(projection.build.visibility === 'unlisted' + ? { 'x-robots-tag': 'noindex' } + : {}), + }, + }); + } finally { + release(); + } +}; diff --git a/src/routes/builds/[slug]/preview.png/server.test.ts b/src/routes/builds/[slug]/preview.png/server.test.ts new file mode 100644 index 0000000..cc0743c --- /dev/null +++ b/src/routes/builds/[slug]/preview.png/server.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + loadProjection: vi.fn(), + render: vi.fn(), +})); + +vi.mock('$lib/server/shared-build', () => ({ + loadSharedBuildProjection: mocks.loadProjection, +})); +vi.mock('$lib/server/build-social-card', () => ({ + renderBuildSocialCardPng: mocks.render, +})); + +import { GET, _socialCardInput } from './+server'; + +const projection = { + build: { + title: 'Field Medic', + slug: 'field-medic-12345678', + author: 'Aewyn', + visibility: 'unlisted', + }, + loadout: { + weapons: [{ itemId: 'weapon-1' }, { itemId: 'weapon-2' }], + }, + weapons: [ + { id: 'weapon-1', name: 'ACR' }, + { id: 'weapon-2', name: 'Super 90' }, + ], + attributes: [ + { id: 'fp', name: 'Firepower', unit: '' }, + { id: 'tgh', name: 'Toughness', unit: '' }, + { id: 'eng', name: 'Engineering', unit: '' }, + { id: 'crit', name: 'Critical Hit Chance', unit: '%' }, + ], + summary: { + stats: [ + { id: 'fp', value: 1200 }, + { id: 'tgh', value: 950 }, + { id: 'eng', value: 610 }, + { id: 'crit', value: 18.5 }, + ], + }, +}; + +describe('Build social preview endpoint', () => { + beforeEach(() => { + mocks.loadProjection.mockReset().mockResolvedValue(projection); + mocks.render.mockReset().mockResolvedValue(Buffer.from([1, 2, 3])); + }); + + it('projects canonical Build facts into the card input', () => { + expect( + _socialCardInput( + projection as never, + 'https://shd.build/builds/field-medic-12345678', + ), + ).toEqual({ + title: 'Field Medic', + author: 'Aewyn', + primaryWeapon: 'ACR', + secondaryWeapon: 'Super 90', + firepower: '1,200', + toughness: '950', + engineering: '610', + supportingAttributes: [{ label: 'Critical Hit Chance', value: '18.5%' }], + visibility: 'unlisted', + directUrl: 'https://shd.build/builds/field-medic-12345678', + }); + }); + + it('serves a cacheable PNG and honors its ETag', async () => { + const first = await GET({ + params: { slug: 'field-medic-12345678' }, + request: new Request( + 'https://shd.build/builds/field-medic-12345678/preview.png', + ), + url: new URL('https://shd.build/builds/field-medic-12345678/preview.png'), + } as never); + expect(first.status).toBe(200); + expect(first.headers.get('content-type')).toBe('image/png'); + expect(first.headers.get('x-content-type-options')).toBe('nosniff'); + expect(first.headers.get('cache-control')).toBe( + 'public, max-age=0, must-revalidate', + ); + expect(first.headers.get('x-robots-tag')).toBe('noindex'); + expect(mocks.loadProjection).toHaveBeenCalledWith('field-medic-12345678', { + kind: 'public-preview', + }); + const etag = first.headers.get('etag'); + expect(etag).toBeTruthy(); + + const second = await GET({ + params: { slug: 'field-medic-12345678' }, + request: new Request( + 'https://shd.build/builds/field-medic-12345678/preview.png', + { headers: { 'if-none-match': String(etag) } }, + ), + url: new URL('https://shd.build/builds/field-medic-12345678/preview.png'), + } as never); + expect(second.status).toBe(304); + expect(mocks.render).toHaveBeenCalledTimes(1); + }); + + it('returns a generic 404 when the public projection is unavailable', async () => { + mocks.loadProjection.mockResolvedValue(null); + await expect( + GET({ + params: { slug: 'private-build' }, + request: new Request( + 'https://shd.build/builds/private-build/preview.png', + ), + url: new URL('https://shd.build/builds/private-build/preview.png'), + } as never), + ).rejects.toMatchObject({ status: 404 }); + expect(mocks.render).not.toHaveBeenCalled(); + }); +});