diff --git a/webui/.env.example b/webui/.env.example index 8e6e42b1a0..5d6ea591a2 100644 --- a/webui/.env.example +++ b/webui/.env.example @@ -60,6 +60,14 @@ SIMPLYPRINT_CLIENT_SECRET= # SimplyPrint and causes the token exchange to fail (exchange_failed). SIMPLYPRINT_REDIRECT_URI=https://openfilamentdatabase.org/api/auth/simplyprint/callback +# === Embedded (SimplyPrint panel) OAuth === +# When OFD is embedded in the SimplyPrint panel, providers (SimplyPrint/GitHub) +# can't be framed, so login runs in a popup and the tokens are sealed (AES-GCM) +# for the popup->frame handoff. Set a long random secret to key that sealing. +# Optional: falls back to SIMPLYPRINT_CLIENT_SECRET / PUBLIC_SIMPLYPRINT_CLIENT_ID +# if unset, but a dedicated secret is recommended in production. +OFD_EMBED_SEAL_SECRET= + # === Bot Submissions (via GitHub App) === # Enable bot PR creation for SimplyPrint-authenticated users ANON_BOT_ENABLED=false diff --git a/webui/src/hooks.server.ts b/webui/src/hooks.server.ts index 59dbee05a5..a3c5443eec 100644 --- a/webui/src/hooks.server.ts +++ b/webui/src/hooks.server.ts @@ -1,6 +1,7 @@ import { copyFileSync, existsSync } from 'fs'; import { join } from 'path'; import { env } from '$env/dynamic/public'; +import type { Handle } from '@sveltejs/kit'; import { installServerLogCapture } from '$lib/server/debugLog'; // Install server-side log capture as early as possible so all output is buffered @@ -94,3 +95,37 @@ if (env.PUBLIC_API_BASE_URL) { if (process.env.ANON_BOT_ENABLED === 'true') { console.log(`[env] Bot submissions: enabled`); } + +// Hosts allowed to embed the app in an iframe (the SimplyPrint panel modal). +// Extra origins can be added via EMBED_FRAME_ANCESTORS (space-separated). +const FRAME_ANCESTORS = [ + "'self'", + 'https://simplyprint.io', + 'https://*.simplyprint.io', + ...(process.env.EMBED_FRAME_ANCESTORS?.trim().split(/\s+/).filter(Boolean) ?? []) +].join(' '); + +/** + * Allow the app to be framed by SimplyPrint (and drop any restrictive + * X-Frame-Options a proxy might inject). `frame-ancestors` is the modern, + * origin-scoped control that browsers honour for embedding. + */ +export const handle: Handle = async ({ event, resolve }) => { + const response = await resolve(event); + + // Only relax framing for document responses; leave asset/data responses alone. + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('text/html')) { + response.headers.delete('X-Frame-Options'); + const existing = response.headers.get('Content-Security-Policy'); + const frameDirective = `frame-ancestors ${FRAME_ANCESTORS}`; + response.headers.set( + 'Content-Security-Policy', + existing && !/frame-ancestors/i.test(existing) + ? `${existing}; ${frameDirective}` + : frameDirective + ); + } + + return response; +}; diff --git a/webui/src/lib/components/layout/ChangesMenu.svelte b/webui/src/lib/components/layout/ChangesMenu.svelte index d8ce70a930..0b7326f3cb 100644 --- a/webui/src/lib/components/layout/ChangesMenu.svelte +++ b/webui/src/lib/components/layout/ChangesMenu.svelte @@ -12,6 +12,8 @@ import { authStore } from '$lib/stores/auth'; import { userPrefs } from '$lib/stores/userPrefs'; import { STORAGE_KEY_REOPEN_WIZARD } from '$lib/config/storageKeys'; + import { getEmbedState } from '$lib/stores/embed'; + import { postToHost } from '$lib/services/embedBridge'; import { onMount, onDestroy } from 'svelte'; let menuOpen = $state(false); @@ -440,7 +442,9 @@ body: JSON.stringify({ changes: exportData.changes, images: imagesWithPaths, - title: generateChangeTitle(exportData.changes) + title: generateChangeTitle(exportData.changes), + // Attribute embedded submissions to the host (e.g. "via SimplyPrint"). + wrapper: getEmbedState().wrapper || undefined }) }); @@ -455,6 +459,7 @@ }); userPrefs.addSubmission(result.uuid!, result.prUrl || '', result.prNumber || 0); changeStore.clear(); + postToHost({ type: 'ofd:submitted', prUrl: result.prUrl, prNumber: result.prNumber }); return { success: true, message: 'Your changes have been submitted for review by a maintainer.', @@ -492,7 +497,9 @@ changes: exportData.changes, images: imagesWithPaths, title: title || generateChangeTitle(exportData.changes), - description + description, + // Attribute embedded submissions to the host (e.g. "via SimplyPrint"). + wrapper: getEmbedState().wrapper || undefined }) }); @@ -508,6 +515,7 @@ }); userPrefs.addSubmission(uuid, result.prUrl || '', result.prNumber || 0); changeStore.clear(); + postToHost({ type: 'ofd:submitted', prUrl: result.prUrl, prNumber: result.prNumber }); return { success: true, message: `PR #${result.prNumber} created successfully!`, diff --git a/webui/src/lib/server/anonBot.ts b/webui/src/lib/server/anonBot.ts index 400cf62c8a..afbaea3062 100644 --- a/webui/src/lib/server/anonBot.ts +++ b/webui/src/lib/server/anonBot.ts @@ -25,6 +25,8 @@ export interface AnonSubmission { images: Record; title?: string; description?: string; + /** Resolved attribution label (e.g. "SimplyPrint") for embedded submissions. */ + wrapper?: string; } export interface AnonSubmissionResult { @@ -107,7 +109,7 @@ export async function createAnonPR(submission: AnonSubmission): Promise[1]); +} // --- GitHub --- @@ -24,12 +45,12 @@ export function getGitHubToken(cookies: Cookies): string | undefined { return cookies.get(GH_COOKIE); } -export function setGitHubToken(cookies: Cookies, token: string): void { - cookies.set(GH_COOKIE, token, COOKIE_OPTIONS); +export function setGitHubToken(cookies: Cookies, token: string, embedded = false): void { + cookies.set(GH_COOKIE, token, cookieOptions(embedded)); } export function clearGitHubToken(cookies: Cookies): void { - cookies.delete(GH_COOKIE, { path: '/' }); + clearCookie(cookies, GH_COOKIE); } export async function exchangeCodeForToken( @@ -103,12 +124,27 @@ export function getSimplyPrintToken(cookies: Cookies): string | undefined { return cookies.get(SP_COOKIE); } -export function setSimplyPrintToken(cookies: Cookies, token: string): void { - cookies.set(SP_COOKIE, token, { ...COOKIE_OPTIONS, maxAge: 3600 }); +/** Store the short-lived access token (1h — matches SimplyPrint token lifetime). */ +export function setSimplyPrintToken(cookies: Cookies, token: string, embedded = false): void { + cookies.set(SP_COOKIE, token, cookieOptions(embedded, 3600)); +} + +export function getSimplyPrintRefreshToken(cookies: Cookies): string | undefined { + return cookies.get(SP_REFRESH_COOKIE); +} + +/** + * Store the long-lived refresh token (SimplyPrint refresh tokens last ~1 year) + * so an expired access token can be renewed silently — no re-consent, which is + * what makes the embedded "one-time consent" login feel seamless on later opens. + */ +export function setSimplyPrintRefreshToken(cookies: Cookies, token: string, embedded = false): void { + cookies.set(SP_REFRESH_COOKIE, token, cookieOptions(embedded, ONE_YEAR)); } export function clearSimplyPrintToken(cookies: Cookies): void { - cookies.delete(SP_COOKIE, { path: '/' }); + clearCookie(cookies, SP_COOKIE); + clearCookie(cookies, SP_REFRESH_COOKIE); } export async function exchangeSimplyPrintCode( @@ -162,6 +198,49 @@ export async function exchangeSimplyPrintCode( return data; } +/** + * Exchange a refresh token for a fresh access token (refresh_token grant). + * Used to silently renew an expired session without re-prompting for consent. + */ +export async function refreshSimplyPrintToken( + refreshToken: string, + clientId: string, + clientSecret?: string +): Promise<{ access_token: string; refresh_token?: string }> { + clientId = clientId.trim(); + clientSecret = clientSecret?.trim(); + + const form = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken + }); + if (clientSecret) form.set('client_secret', clientSecret); + + const response = await fetch(`${SP_API_BASE}/oauth2/Token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: form + }); + + if (!response.ok) { + const body = await response.text(); + console.error('[SP OAuth] Refresh endpoint error:', response.status, body, { + client_id: clientId, + has_client_secret: !!clientSecret + }); + throw new Error('SimplyPrint token refresh failed: ' + response.status); + } + + const data = await response.json(); + if (data.error) { + console.error('[SP OAuth] Refresh returned error:', data); + throw new Error(`SimplyPrint refresh error: ${data.error_description || data.error}`); + } + + return data; +} + export interface SimplyPrintUser { id: number; name: string; diff --git a/webui/src/lib/server/popupHandoff.ts b/webui/src/lib/server/popupHandoff.ts new file mode 100644 index 0000000000..9861d2667d --- /dev/null +++ b/webui/src/lib/server/popupHandoff.ts @@ -0,0 +1,68 @@ +/** + * Renders the final page shown in the OAuth *popup* (embedded-mode login). + * + * The popup completed the provider round-trip in its own top-level (first-party + * OFD) context. It now hands the outcome back to the opener — the OFD iframe + * inside the SimplyPrint panel — over postMessage, then closes itself. The + * iframe adopts the sealed tokens into its own cookie partition (see the + * matching `/adopt` route). On failure it relays an error so the frame can + * surface it instead of hanging. + * + * Only the sealed (opaque) blob crosses the postMessage boundary; the raw tokens + * never touch page scripts. We target the OFD origin explicitly so no other + * frame can read the message. + */ + +type Outcome = { sealed: string } | { error: string }; + +export function popupHandoffPage( + origin: string, + provider: 'simplyprint' | 'github', + outcome: Outcome +): Response { + const message = { type: `ofd:${provider}-auth`, ...outcome }; + // origin + message are our own values (origin = this deploy; sealed = base64url; + // error = a fixed slug), but JSON.stringify keeps the inline script well-formed. + const payload = JSON.stringify(message); + const target = JSON.stringify(origin); + const ok = 'sealed' in outcome; + + const html = ` + +Signing in… + + +
+
+

${ok ? 'Signing you in…' : 'Sign-in failed. You can close this window.'}

+
+ + +`; + + return new Response(html, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store' + } + }); +} diff --git a/webui/src/lib/server/seal.ts b/webui/src/lib/server/seal.ts new file mode 100644 index 0000000000..1c887f59a3 --- /dev/null +++ b/webui/src/lib/server/seal.ts @@ -0,0 +1,73 @@ +/** + * Short-lived sealed envelopes for the embedded OAuth popup handoff. + * + * When the OFD editor runs inside the SimplyPrint panel (a cross-site iframe), + * the OAuth provider (SimplyPrint, GitHub) cannot be framed — so we run the + * whole OAuth round-trip in a *popup* (a top-level window where the provider is + * first-party and framing is a non-issue). The popup ends up holding the tokens + * in ITS cookie partition (keyed to the OFD origin), which the iframe — living + * in the host's partition (keyed to simplyprint.io) — cannot read. + * + * To bridge the two partitions we hand the tokens from the popup back to the + * iframe over `postMessage`, then the iframe re-submits them to `/adopt` so the + * session cookie is written in the iframe's own partition. To avoid exposing + * long-lived tokens to page scripts in plaintext, the handoff value is a sealed + * (AES-256-GCM encrypted + authenticated) opaque blob with a short TTL. Page + * scripts only ever see ciphertext; only the OFD server can open it. + * + * This is stateless (no server-side session store) so it survives multi-instance + * / serverless cloud deploys. + */ +import crypto from 'crypto'; +import { env } from '$env/dynamic/private'; +import { env as publicEnv } from '$env/dynamic/public'; + +const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes — the popup→iframe handoff is immediate. + +function key(): Buffer { + // A stable per-deploy secret. Prefer a dedicated secret; fall back to other + // deploy-stable values so embedding works out of the box. The secret never + // leaves the server — the sealed blob's confidentiality is what matters. + const secret = + env.OFD_EMBED_SEAL_SECRET || + env.SIMPLYPRINT_CLIENT_SECRET || + publicEnv.PUBLIC_SIMPLYPRINT_CLIENT_ID || + 'ofd-embed-oauth-handoff'; + return crypto.createHash('sha256').update(secret).digest(); +} + +const b64url = (buf: Buffer) => + buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + +function fromB64url(s: string): Buffer { + return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); +} + +/** Encrypt + authenticate a small JSON payload into a short-lived opaque token. */ +export function seal(payload: Record, ttlMs = DEFAULT_TTL_MS): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv); + const body = JSON.stringify({ ...payload, exp: Date.now() + ttlMs }); + const ct = Buffer.concat([cipher.update(body, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return b64url(Buffer.concat([iv, tag, ct])); +} + +/** Open a sealed token. Returns null if tampered, malformed, or expired. */ +export function unseal>(sealed: string): T | null { + try { + const buf = fromB64url(sealed); + if (buf.length < 28) return null; + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const ct = buf.subarray(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', key(), iv); + decipher.setAuthTag(tag); + const pt = Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); + const data = JSON.parse(pt) as { exp?: number } & T; + if (!data.exp || Date.now() > data.exp) return null; + return data; + } catch { + return null; + } +} diff --git a/webui/src/lib/server/wrapper.ts b/webui/src/lib/server/wrapper.ts new file mode 100644 index 0000000000..41b065074a --- /dev/null +++ b/webui/src/lib/server/wrapper.ts @@ -0,0 +1,24 @@ +import { env as publicEnv } from '$env/dynamic/public'; + +/** + * Known embed wrappers allowed to set PR attribution. Restricting to an + * allowlist prevents an embedded client from injecting an arbitrary string + * into public PR bodies ("Submitted via "). + */ +const ALLOWED_WRAPPERS: Record = { + simplyprint: 'SimplyPrint' +}; + +/** + * Resolve the attribution label for a submission. + * + * @param requested wrapper slug sent by an embedded client (e.g. "SimplyPrint") + * @returns the validated display name, else the global PUBLIC_WRAPPER_NAME, else undefined + */ +export function resolveWrapperName(requested?: unknown): string | undefined { + if (typeof requested === 'string') { + const match = ALLOWED_WRAPPERS[requested.trim().toLowerCase()]; + if (match) return match; + } + return publicEnv.PUBLIC_WRAPPER_NAME || undefined; +} diff --git a/webui/src/lib/services/embedAuthPopup.ts b/webui/src/lib/services/embedAuthPopup.ts new file mode 100644 index 0000000000..b720f62604 --- /dev/null +++ b/webui/src/lib/services/embedAuthPopup.ts @@ -0,0 +1,106 @@ +/** + * Embedded-mode OAuth via a popup window. + * + * SimplyPrint and GitHub both refuse to be framed, so when OFD runs inside the + * SimplyPrint panel iframe we can't navigate the frame to their authorize pages. + * Instead we open the OAuth flow in a top-level popup (where the provider is + * first-party) and bridge the result back: + * + * 1. Open `/api/auth//login?popup=1` in a popup. + * 2. The popup completes the round-trip and postMessages a sealed blob back to + * this window (the opener) — see server `popupHandoff` + `seal`. + * 3. We POST that blob to `/api/auth//adopt` so the session cookie is + * written in THIS frame's partition (the popup's own cookies live in a + * different, unreachable partition). + * + * `window.open` must run inside the click's user gesture, so this is invoked + * straight from the login button handlers (via the auth store). + */ +import { browser } from '$app/environment'; + +type Provider = 'simplyprint' | 'github'; + +const POPUP_W = 520; +const POPUP_H = 680; + +export function loginViaPopup(provider: Provider): Promise { + if (!browser) return Promise.resolve(false); + + const origin = window.location.origin; + + // Center over the current window when we can; harmless if the browser ignores it. + const sx = window.screenX ?? 0; + const sy = window.screenY ?? 0; + const ow = window.outerWidth || window.innerWidth || POPUP_W; + const oh = window.outerHeight || window.innerHeight || POPUP_H; + const left = Math.max(0, sx + (ow - POPUP_W) / 2); + const top = Math.max(0, sy + (oh - POPUP_H) / 2); + + const popup = window.open( + `/api/auth/${provider}/login?popup=1`, + 'ofd_oauth_' + provider, + `width=${POPUP_W},height=${POPUP_H},left=${left},top=${top},noreferrer=no` + ); + + // Blocked (no user gesture / blocker). Caller can fall back / prompt. + if (!popup) return Promise.resolve(false); + + return new Promise((resolve) => { + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + window.removeEventListener('message', onMessage); + clearInterval(closedTimer); + resolve(ok); + }; + + // Set once the handoff arrives, so the close-poll below can't resolve the + // login as failed while the (async) adopt call is still in flight — the + // popup closes itself right after posting, which would otherwise race the + // pending fetch and make the first attempt look like it did nothing. + let received = false; + + const onMessage = async (e: MessageEvent) => { + // Only trust our own origin (the popup navigates back to OFD before posting). + if (e.origin !== origin) return; + const d = e.data; + if (!d || typeof d !== 'object' || d.type !== `ofd:${provider}-auth`) return; + + received = true; + clearInterval(closedTimer); // we have the result; stop watching for a close + try { + popup.close(); + } catch { + /* already closed */ + } + + if (d.error || typeof d.sealed !== 'string') { + finish(false); + return; + } + + // Persist into this frame's cookie partition. + try { + const res = await fetch(`/api/auth/${provider}/adopt?embed=1`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ sealed: d.sealed }) + }); + finish(res.ok); + } catch { + finish(false); + } + }; + + window.addEventListener('message', onMessage); + + // If the user closes the popup WITHOUT completing, stop waiting. Ignored + // once the handoff has arrived (the popup self-closes on success). + const closedTimer = setInterval(() => { + if (received) return; + if (popup.closed) finish(false); + }, 500); + }); +} diff --git a/webui/src/lib/services/embedBridge.ts b/webui/src/lib/services/embedBridge.ts new file mode 100644 index 0000000000..8d2056c7f1 --- /dev/null +++ b/webui/src/lib/services/embedBridge.ts @@ -0,0 +1,78 @@ +import { browser } from '$app/environment'; +import { theme } from '$lib/stores/theme'; +import { setEmbedTheme } from '$lib/stores/embed'; + +/** + * postMessage bridge between the embedded OFD app and its host (the SimplyPrint + * panel modal). Outbound: lifecycle signals so the host can react (e.g. close + * the modal, toast on submit). Inbound: host-driven theme changes. + * + * Messages are namespaced `ofd:*`. Inbound messages are only honoured from a + * trusted host origin. + */ + +export type OutboundMessage = + | { type: 'ofd:ready' } + | { type: 'ofd:close' } + | { type: 'ofd:submitted'; prUrl?: string; prNumber?: number }; + +const HOST_ORIGIN_RE = /^https:\/\/([a-z0-9-]+\.)*simplyprint\.io$/i; + +function isTrustedOrigin(origin: string): boolean { + // Allow the dev panel over http://localhost as well. + return HOST_ORIGIN_RE.test(origin) || origin === 'http://localhost' || origin.startsWith('http://localhost:'); +} + +/** Send a message to the host window (no-op when not framed). */ +export function postToHost(message: OutboundMessage): void { + if (!browser) return; + try { + if (window.parent && window.parent !== window) { + // Host origin is not known ahead of time (dev vs prod panel), and these + // signals carry no secrets, so '*' is acceptable for outbound lifecycle. + window.parent.postMessage(message, '*'); + } + } catch { + /* cross-origin access can throw in some browsers — ignore */ + } +} + +let listening = false; + +/** Start listening for host → iframe messages. Returns a teardown function. */ +export function startEmbedBridge(): () => void { + if (!browser || listening) return () => {}; + listening = true; + + const onMessage = (event: MessageEvent) => { + if (!isTrustedOrigin(event.origin)) return; + const data = event.data; + if (!data || typeof data !== 'object') return; + + if (data.type === 'ofd:setTheme' && (data.theme === 'dark' || data.theme === 'light')) { + setEmbedTheme(data.theme); + // Apply immediately without persisting the host's choice as the user's + // own OFD preference. + document.documentElement.classList.toggle('dark', data.theme === 'dark'); + } + }; + + window.addEventListener('message', onMessage); + + // Tell the host we're up and ready to receive theme/config. + postToHost({ type: 'ofd:ready' }); + + return () => { + window.removeEventListener('message', onMessage); + listening = false; + }; +} + +/** Apply a host-forced theme override to the DOM (does not touch user prefs). */ +export function applyEmbedTheme(t: 'light' | 'dark' | null): void { + if (!browser || !t) return; + document.documentElement.classList.toggle('dark', t === 'dark'); +} + +// Re-export for convenience so callers can also read the user's own theme. +export { theme }; diff --git a/webui/src/lib/services/embedDraftSync.ts b/webui/src/lib/services/embedDraftSync.ts new file mode 100644 index 0000000000..05a967744a --- /dev/null +++ b/webui/src/lib/services/embedDraftSync.ts @@ -0,0 +1,243 @@ +/** + * Account-backed overlay sync for the SimplyPrint embed. + * + * When the OFD editor runs embedded in the SimplyPrint panel, the user's layered + * changeset (the "overlay") is persisted to their SimplyPrint *account* instead + * of browser localStorage, so their in-progress contribution and uploaded logos + * follow them across devices. Standalone OFD keeps using localStorage untouched. + * + * This module is a thin postMessage client — the SimplyPrint host page owns all + * persistence (cookie-authed panel endpoints + CDN upload). We only: + * - request the saved overlay on load and apply it (changeStore.importChanges) + * - autosave the overlay (debounced) whenever the change store updates + * - ship draft images out to the CDN (via the host) and store their URLs, so + * the saved changeset stays small and images are account-portable. + * + * Message protocol (must match ofd-embed.svelte on the SimplyPrint side): + * OFD → host: ofd:draft-request + * ofd:draft-save { draft:{metadata,changes}, images:{id:{url,filename,mimeType}} } + * ofd:draft-clear + * ofd:image-upload { requestId, id, image, contentType, filename } + * host → OFD: ofd:draft-restore { draft, images } + * ofd:image-uploaded { requestId, id, url } + * + * Integration: call `initEmbedDraftSync()` once, only when embedded (e.g. from + * the embed layout's onMount, alongside the theme/close bridge). + */ +import { browser } from '$app/environment'; +import { get } from 'svelte/store'; +import { changeStore } from '$lib/stores/changes'; +import { submittedStore } from '$lib/stores/submitted'; +import type { ChangeExport } from '$lib/types/changes'; + +const SAVE_DEBOUNCE_MS = 900; +const UPLOAD_TIMEOUT_MS = 30_000; + +type ImageMeta = { url: string; filename: string; mimeType: string }; + +let started = false; +let hydrated = false; // have we restored the account draft yet? +let restoring = false; // guard: don't autosave while applying a restore +let saveTimer: ReturnType | null = null; +const uploadWaiters = new Map void>(); +const imageUrlCache = new Map(); // imageId -> already-on-CDN + +function post(msg: unknown) { + if (browser && window.parent && window.parent !== window) { + // Host origin varies (test/prod panel); these messages carry no secrets. + window.parent.postMessage(msg, '*'); + } +} + +function genId(): string { + return 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); +} + +function uploadImage(id: string, img: { filename: string; mimeType: string; data: string }): Promise { + const requestId = genId(); + return new Promise((resolve) => { + uploadWaiters.set(requestId, resolve); + post({ type: 'ofd:image-upload', requestId, id, image: img.data, contentType: img.mimeType, filename: img.filename }); + setTimeout(() => { + if (uploadWaiters.has(requestId)) { + uploadWaiters.delete(requestId); + resolve(null); + } + }, UPLOAD_TIMEOUT_MS); + }); +} + +/** + * Submitted-but-not-yet-merged contributions (the OFD "submitted" overlay). These + * keep showing in OFD until their PR merges — and we ship them to the account too, + * separately from pending edits, so SimplyPrint can layer in-review contributions + * as well. They already left the pending change store on submit. + * + * Kept PR-grouped ({ prNumber, prUrl, changes }) — SimplyPrint verifies each PR is + * still open on GitHub (server-side) before layering, so it never relies on this + * client having reconciled merged/closed PRs. + */ +function collectSubmittedEntries(): unknown[] { + try { + const buffer = get(submittedStore) as { + entries?: Record< + string, + { prNumber?: number; prUrl?: string; submittedAt?: string; changes?: unknown[] } + >; + }; + return Object.values(buffer.entries ?? {}) + .filter((e) => Array.isArray(e.changes) && e.changes.length) + .map((e) => ({ + prNumber: e.prNumber ?? 0, + prUrl: e.prUrl ?? '', + submittedAt: e.submittedAt ?? '', + changes: e.changes ?? [] + })); + } catch { + return []; + } +} + +async function doSave() { + if (restoring) return; + const exp = await changeStore.exportChanges(); + const submitted = collectSubmittedEntries(); + + // Empty overlay (no pending edits AND nothing in review) -> discard the draft. + if (!exp.changes.length && !submitted.length && !Object.keys(exp.images).length) { + post({ type: 'ofd:draft-clear' }); + return; + } + + // Upload any images not yet on the CDN; build the id -> url map. + const images: Record = {}; + for (const [imageId, img] of Object.entries(exp.images)) { + let meta = imageUrlCache.get(imageId); + if (!meta) { + const url = await uploadImage(imageId, img); + if (url) { + meta = { url, filename: img.filename, mimeType: img.mimeType }; + imageUrlCache.set(imageId, meta); + } + } + if (meta) images[imageId] = meta; + } + + // Store the changeset WITHOUT base64 blobs — images travel as CDN URLs. + // `submitted` rides alongside `changes` but is kept separate so restoring the + // draft into the editor only rehydrates pending edits, never in-review ones. + post({ type: 'ofd:draft-save', draft: { metadata: exp.metadata, changes: exp.changes, submitted }, images }); +} + +function scheduleSave() { + if (restoring) return; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => void doSave().catch(() => { }), SAVE_DEBOUNCE_MS); +} + +async function dataUrlFromUrl(url: string): Promise { + try { + const res = await fetch(url); + if (!res.ok) return null; + const blob = await res.blob(); + return await new Promise((resolve) => { + const fr = new FileReader(); + fr.onload = () => resolve(typeof fr.result === 'string' ? fr.result : null); + fr.onerror = () => resolve(null); + fr.readAsDataURL(blob); + }); + } catch { + return null; + } +} + +async function applyRestore(draft: any, images: Record) { + hydrated = true; + // Guard the whole restore: autosave stays disabled until we've applied both the + // submitted (in-review) and pending state, so a debounced save can't fire with a + // half-restored — or empty — overlay and wipe the account draft. + restoring = true; + try { + // Rehydrate in-review contributions from the account draft into the + // submittedStore. These live only in the account when embedded, so without + // this a fresh browser/device — or the local 7-day TTL expiring while the PR + // is still under review — leaves the local submitted set empty, and the next + // autosave would post an empty overlay and WIPE the account draft, making the + // in-review contribution vanish from SimplyPrint. They go to submittedStore + // (never changeStore), so they're not re-opened as editable pending edits, + // and they also surface in the editor's Changes menu across devices. + if (Array.isArray(draft?.submitted) && draft.submitted.length) { + submittedStore.importInReview(draft.submitted); + } + + // Restore pending edits (if any) into the editable change store. + if (draft && Array.isArray(draft.changes) && draft.changes.length) { + // Rebuild ChangeExport.images by pulling each CDN url back into base64. + const expImages: ChangeExport['images'] = {}; + for (const [imageId, meta] of Object.entries(images || {})) { + const dataUrl = await dataUrlFromUrl(meta.url); + if (dataUrl) { + expImages[imageId] = { filename: meta.filename, mimeType: meta.mimeType, data: dataUrl }; + imageUrlCache.set(imageId, meta); // already uploaded — don't re-upload + } + } + const exportData: ChangeExport = { + metadata: draft.metadata ?? { + exportedAt: Date.now(), + version: '1.0.0', + changeCount: draft.changes.length, + imageCount: Object.keys(expImages).length + }, + changes: draft.changes, + images: expImages + }; + await changeStore.importChanges(exportData); + } + } finally { + // Let the store updates settle before re-enabling autosave, then push the + // combined (pending + submitted) state up to the account. + setTimeout(() => { + restoring = false; + scheduleSave(); + }, 0); + } +} + +function onMessage(e: MessageEvent) { + const data = e.data; + if (!data || typeof data !== 'object') return; + switch (data.type) { + case 'ofd:draft-restore': + void applyRestore(data.draft, data.images || {}); + break; + case 'ofd:image-uploaded': { + const resolve = uploadWaiters.get(data.requestId); + if (resolve) { + uploadWaiters.delete(data.requestId); + resolve(data.url ?? null); + } + break; + } + } +} + +/** Start syncing the overlay to the SimplyPrint account. Call once, embed-only. */ +export function initEmbedDraftSync() { + if (!browser || started) return; + started = true; + + window.addEventListener('message', onMessage); + + // Restore the account draft first, then autosave on subsequent changes. + post({ type: 'ofd:draft-request' }); + changeStore.subscribe(() => { + if (!hydrated) return; // ignore the initial localStorage value until restore lands + scheduleSave(); + }); + // Also re-save when a contribution is submitted/reconciled, so in-review + // contributions land in (and merged ones leave) the account draft. + submittedStore.subscribe(() => { + if (!hydrated) return; + scheduleSave(); + }); +} diff --git a/webui/src/lib/stores/auth.ts b/webui/src/lib/stores/auth.ts index 05e0129cf8..e1007def28 100644 --- a/webui/src/lib/stores/auth.ts +++ b/webui/src/lib/stores/auth.ts @@ -4,6 +4,14 @@ import { writable, derived } from 'svelte/store'; import { STORAGE_KEY_REOPEN_WIZARD } from '$lib/config/storageKeys'; +import { getEmbedState } from '$lib/stores/embed'; +import { loginViaPopup } from '$lib/services/embedAuthPopup'; + +/** `?embed=1` when running inside a host iframe, so the server issues + * partitioned (cross-site-frame-safe) auth cookies and returns to embed mode. */ +function embedQuery(): string { + return getEmbedState().embedded ? '?embed=1' : ''; +} interface GitHubUser { login: string; @@ -58,8 +66,17 @@ function createAuthStore() { }, ghLogin() { + // Embedded: GitHub can't be framed — run OAuth in a popup and adopt the + // token back into this frame instead of a full-page navigation. + if (getEmbedState().embedded) { + return loginViaPopup('github').then((ok) => { + if (ok) this.checkGitHubStatus(); + return ok; + }); + } localStorage.setItem(STORAGE_KEY_REOPEN_WIZARD, 'github'); window.location.href = '/api/auth/github/login'; + return Promise.resolve(true); }, async ghLogout() { @@ -70,7 +87,7 @@ function createAuthStore() { async checkSpStatus() { update((s) => ({ ...s, spLoading: true })); try { - const response = await fetch('/api/auth/simplyprint/status'); + const response = await fetch('/api/auth/simplyprint/status' + embedQuery()); const data = await response.json(); update((s) => ({ ...s, @@ -84,8 +101,17 @@ function createAuthStore() { }, spLogin() { + // Embedded: SimplyPrint can't be framed — run OAuth in a popup and adopt + // the tokens back into this frame instead of a full-page navigation. + if (getEmbedState().embedded) { + return loginViaPopup('simplyprint').then((ok) => { + if (ok) this.checkSpStatus(); + return ok; + }); + } localStorage.setItem(STORAGE_KEY_REOPEN_WIZARD, 'simplyprint'); window.location.href = '/api/auth/simplyprint/login'; + return Promise.resolve(true); }, async spLogout() { @@ -99,7 +125,7 @@ function createAuthStore() { try { const [ghRes, spRes] = await Promise.all([ fetch('/api/auth/github/status'), - fetch('/api/auth/simplyprint/status') + fetch('/api/auth/simplyprint/status' + embedQuery()) ]); const [ghData, spData] = await Promise.all([ghRes.json(), spRes.json()]); set({ diff --git a/webui/src/lib/stores/embed.ts b/webui/src/lib/stores/embed.ts new file mode 100644 index 0000000000..bb7f3a3b6d --- /dev/null +++ b/webui/src/lib/stores/embed.ts @@ -0,0 +1,99 @@ +import { writable, derived, get } from 'svelte/store'; +import { browser } from '$app/environment'; + +/** + * Embed mode — active when the app is loaded inside a host application's iframe + * (e.g. the SimplyPrint panel modal) via `?embed=1`. + * + * When embedded we hide the marketing chrome (footer, welcome modal, big site + * title) and keep the functional bits (browse, search, ChangesMenu preview, + * submission wizard). Auth cookies also switch to SameSite=None; Partitioned so + * the OAuth handshake survives the cross-site iframe (see lib/server/auth.ts). + * + * Embed state is sticky for the session: SvelteKit client navigations and the + * OAuth callback redirect drop `?embed=1` from the URL, so once detected we + * persist it in sessionStorage (partitioned to the host by the browser) and + * keep treating the session as embedded. + */ + +const SS_EMBED = 'ofd_embed'; +const SS_WRAPPER = 'ofd_embed_wrapper'; +const SS_THEME = 'ofd_embed_theme'; + +export interface EmbedState { + /** Running inside a host iframe. */ + embedded: boolean; + /** Attribution label for PRs, e.g. "SimplyPrint" (from `?wrapper=`). */ + wrapper: string | null; + /** Host-forced theme, e.g. matching the panel's dark mode (from `?theme=`). */ + themeOverride: 'light' | 'dark' | null; +} + +function ssGet(key: string): string | null { + if (!browser) return null; + try { + return sessionStorage.getItem(key); + } catch { + return null; + } +} + +function ssSet(key: string, val: string | null): void { + if (!browser) return; + try { + if (val === null) sessionStorage.removeItem(key); + else sessionStorage.setItem(key, val); + } catch { + /* sessionStorage may be unavailable (partitioning / privacy modes) */ + } +} + +function initialState(): EmbedState { + return { + embedded: ssGet(SS_EMBED) === '1', + wrapper: ssGet(SS_WRAPPER), + themeOverride: (ssGet(SS_THEME) as 'light' | 'dark' | null) || null + }; +} + +const embedState = writable(initialState()); + +/** + * Initialise/refresh embed state from a URL's query params. Safe to call on + * every navigation — once embedded, the session stays embedded even after the + * `?embed=1` param disappears. + */ +export function initEmbedFromUrl(url: URL): void { + const p = url.searchParams; + const isEmbed = p.get('embed') === '1' || p.get('embed') === 'true'; + const themeParam = p.get('theme'); + const wrapperParam = p.get('wrapper'); + const theme = themeParam === 'dark' || themeParam === 'light' ? themeParam : null; + + embedState.update((s) => { + const embedded = s.embedded || isEmbed; + const wrapper = wrapperParam ?? s.wrapper; + const themeOverride = theme ?? s.themeOverride; + + if (embedded) ssSet(SS_EMBED, '1'); + if (wrapper) ssSet(SS_WRAPPER, wrapper); + if (themeOverride) ssSet(SS_THEME, themeOverride); + + return { embedded, wrapper, themeOverride }; + }); +} + +/** Update the host-forced theme (e.g. from a live `ofd:setTheme` postMessage). */ +export function setEmbedTheme(t: 'light' | 'dark'): void { + ssSet(SS_THEME, t); + embedState.update((s) => ({ ...s, themeOverride: t })); +} + +export const embed = { subscribe: embedState.subscribe }; +export const isEmbedded = derived(embedState, ($s) => $s.embedded); +export const embedWrapper = derived(embedState, ($s) => $s.wrapper); +export const embedThemeOverride = derived(embedState, ($s) => $s.themeOverride); + +export function getEmbedState(): EmbedState { + return get(embedState); +} diff --git a/webui/src/lib/stores/submitted.ts b/webui/src/lib/stores/submitted.ts index d0c0a50c5b..5d35dfdc9e 100644 --- a/webui/src/lib/stores/submitted.ts +++ b/webui/src/lib/stores/submitted.ts @@ -121,6 +121,85 @@ function createSubmittedStore() { }); }, + /** + * Merge in-review submissions restored from an account-backed overlay (the + * SimplyPrint embed). Unlike {@link archive}, this preserves the original + * submission time, never overwrites an entry already tracked locally, and + * gives the entry a far-future expiry — an in-review PR can easily outlive + * the default 7-day TTL, and here the SimplyPrint account (plus the server + * reconciler) own pruning once the PR merges/closes. Without this rehydrate, + * a fresh browser/device or an expired local entry leaves the submitted set + * empty, so the next embed autosave would post an empty overlay and wipe the + * account draft. Returns true if anything new was added. + */ + importInReview( + entries: Array<{ + prNumber?: number; + prUrl?: string; + submittedAt?: string; + changes?: EntityChange[]; + }> + ): boolean { + if (!browser || !Array.isArray(entries)) return false; + let added = false; + const FAR_FUTURE_DAYS = 3650; + + update((buffer) => { + // PRs already tracked locally (possibly under a different uuid, e.g. the + // random one from the original submit on this device) — never duplicate. + const trackedPrNumbers = new Set( + Object.values(buffer.entries) + .map((x) => x.prNumber) + .filter((n) => n > 0) + ); + + for (const e of entries) { + const changes = Array.isArray(e?.changes) ? e.changes : []; + if (!changes.length) continue; + + const prNumber = Number(e?.prNumber) || 0; + if (prNumber > 0 && trackedPrNumbers.has(prNumber)) continue; + + // Stable id per PR so re-restoring the same draft is idempotent. + const uuid = + prNumber > 0 + ? `acct-pr-${prNumber}` + : `acct-${e?.submittedAt ?? ''}-${changes.length}`; + if (buffer.entries[uuid]) continue; // already tracked locally + + const submittedAt = e?.submittedAt || new Date().toISOString(); + const lightChanges = changes.map((c) => ({ + entity: c.entity, + operation: c.operation, + data: c.data, + timestamp: c.timestamp, + description: c.description + })); + + buffer.entries[uuid] = { + uuid, + prUrl: e?.prUrl || '', + prNumber, + submittedAt, + expiresAt: new Date( + Date.now() + FAR_FUTURE_DAYS * 24 * 60 * 60 * 1000 + ).toISOString(), + changes: lightChanges, + paths: lightChanges.map((c) => c.entity.path) + }; + if (prNumber > 0) trackedPrNumbers.add(prNumber); + added = true; + } + if (added) { + rebuildIndex(buffer); + persist(buffer); + } + return buffer; + }); + + return added; + }, + /** * Reconcile tracked submissions against GitHub's real PR state. * Asks the server for the current status of each tracked PR (which checks diff --git a/webui/src/routes/+layout.svelte b/webui/src/routes/+layout.svelte index dbd677f326..a17132841a 100644 --- a/webui/src/routes/+layout.svelte +++ b/webui/src/routes/+layout.svelte @@ -5,6 +5,10 @@ import { isCloudMode } from '$lib/stores/environment'; import { authStore } from '$lib/stores/auth'; import { theme } from '$lib/stores/theme'; + import { isSpAuthenticated, currentSpUser } from '$lib/stores/auth'; + import { isEmbedded, embedThemeOverride, initEmbedFromUrl } from '$lib/stores/embed'; + import { startEmbedBridge, applyEmbedTheme } from '$lib/services/embedBridge'; + import { initEmbedDraftSync } from '$lib/services/embedDraftSync'; import { db } from '$lib/services/database'; import { clearSearchCache } from '$lib/services/searchIndex'; import { clearLocalDataExceptSettings } from '$lib/services/localData'; @@ -17,9 +21,21 @@ let { children } = $props(); + // Detect + sustain embed mode from the URL (sticky across navigations). + initEmbedFromUrl($page.url); + $effect(() => { + initEmbedFromUrl($page.url); + }); + + // Apply the host-forced theme (e.g. the panel's dark mode) when embedded. + $effect(() => { + if ($isEmbedded) applyEmbedTheme($embedThemeOverride); + }); + let refreshing = $state(false); let themeMenuOpen = $state(false); let headerQuery = $state(''); + let signingIn = $state(false); // Reflect the active query when on the search page (so the box shows ?q=). $effect(() => { @@ -41,6 +57,18 @@ // Load schema-derived configs in parallel loadTraitConfig(); loadSlicerConfig(); + + // When embedded in a host (e.g. the SimplyPrint panel), open the + // postMessage bridge so the host can drive theme + receive lifecycle + // signals. Auto-detect an existing SimplyPrint session for the identity chip. + if (get(isEmbedded)) { + const teardown = startEmbedBridge(); + authStore.checkSpStatus(); + // Persist the layered overlay to the SimplyPrint account (not + // localStorage) so it follows the user across devices. + initEmbedDraftSync(); + return teardown; + } }); function handleRefresh() { @@ -86,7 +114,12 @@ } - { if (e.key === 'Escape' && themeMenuOpen) themeMenuOpen = false; }} /> + { + if (e.key === 'Escape' && themeMenuOpen) themeMenuOpen = false; + }} +/>
@@ -94,26 +127,43 @@
- - Filament Database - + {#if !$isEmbedded} + + Filament Database + + {/if}