Compare commits

...
Author SHA1 Message Date
Brandon Thomas 3a93e358cd tests 2026-09-27 17:27:40 -04:00
Brandon Thomas 1cb0b32898 move desktop configs 2026-09-27 17:25:20 -04:00
Brandon Thomas c50bb78c32 tauri cors for new domain 2026-09-27 17:12:27 -04:00
Brandon Thomas 2c93b4cf42 Merge pull request #36 from storytold/bt-opengraph-metadata
add opengraph metadata (social media tags)
2026-09-26 16:32:39 -07:00
Brandon Thomas 9169e4d662 add opengraph metadata (social media tags) 2026-09-26 16:30:57 -07:00
Brandon Thomas 4c7af51851 Merge pull request #35 from storytold/bt-media-previews
fix media previews in new design
2026-09-26 10:30:44 -07:00
17 changed files with 600 additions and 59 deletions

No files matched your search

@@ -0,0 +1,66 @@
use actix_cors::Cors;
pub fn add_artcraft_desktop(cors: Cors, _is_production: bool) -> Cors {
cors
// Stable identity sent by the Artcraft native HTTP bridge (dev and packaged apps).
.allowed_origin("https://desktop.getartcraft.com")
}
#[cfg(test)]
mod tests {
use actix_web::dev::Transform;
use actix_web::http::{header, Method, StatusCode};
use actix_web::test::{self, TestRequest};
use server_environment::ServerEnvironment;
use crate::cors::build_cors_config;
use crate::testing::{assert_origin_invalid, assert_origin_ok};
const DESKTOP_ORIGIN: &str = "https://desktop.getartcraft.com";
#[actix_rt::test]
async fn desktop_requests_and_preflights_allow_credentials() {
for environment in [ServerEnvironment::Production, ServerEnvironment::Development] {
let service = build_cors_config(environment)
.new_transform(test::ok_service())
.await
.unwrap();
for method in [Method::GET, Method::POST, Method::OPTIONS] {
let mut request = TestRequest::default()
.uri("/v1/session")
.insert_header((header::ORIGIN, DESKTOP_ORIGIN));
if method == Method::OPTIONS {
request = request
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type,session,x-artcraft-version"));
}
let response = test::call_service(&service, request.method(method).to_srv_request()).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), DESKTOP_ORIGIN);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
}
}
}
#[actix_rt::test]
async fn desktop_origin_is_an_exact_https_allowlist_entry() {
let cors = build_cors_config(ServerEnvironment::Production);
for origin in [
"http://desktop.getartcraft.com",
"https://desktop.getartcraft.com:8443",
"https://desktop.getartcraft.com.example.com",
"https://sub.desktop.getartcraft.com",
] {
assert_origin_invalid(&cors, origin).await;
}
}
#[actix_rt::test]
async fn existing_desktop_clients_remain_allowed() {
let cors = build_cors_config(ServerEnvironment::Production);
for origin in ["https://studio.storyteller.ai", "http://tauri.localhost", "tauri://localhost"] {
assert_origin_ok(&cors, origin).await;
}
}
}
@@ -1,6 +1,7 @@
pub (crate) mod artcraft_admin_dashboard;
pub (crate) mod artcraft_2d;
pub (crate) mod artcraft_3d;
pub (crate) mod artcraft_desktop;
pub (crate) mod artcraft_webapp;
pub (crate) mod artcraft_website;
pub (crate) mod development_only;
@@ -11,4 +12,4 @@ pub (crate) mod storyteller;
pub (crate) mod storyteller_board;
pub (crate) mod storyteller_render;
pub (crate) mod storyteller_studio;
pub (crate) mod tauri;
pub (crate) mod tauri;
@@ -7,3 +7,86 @@ pub fn add_tauri(cors: Cors, _is_production: bool) -> Cors {
// Tauri Mac
.allowed_origin("tauri://localhost")
}
#[cfg(test)]
mod tests {
use actix_web::dev::Transform;
use actix_web::http::{header, Method, StatusCode};
use actix_web::test::{self, TestRequest};
use server_environment::ServerEnvironment;
use crate::cors::build_cors_config;
use crate::testing::assert_origin_invalid;
const WINDOWS_ORIGIN: &str = "http://tauri.localhost";
const MAC_ORIGIN: &str = "tauri://localhost";
mod windows {
use super::*;
#[actix_rt::test]
async fn requests_and_preflights_allow_credentials() {
assert_requests_and_preflights_allow_credentials(WINDOWS_ORIGIN).await;
}
#[actix_rt::test]
async fn production_rejects_origin_variants() {
let cors = build_cors_config(ServerEnvironment::Production);
for origin in [
"https://tauri.localhost",
"http://tauri.localhost:8080",
"http://tauri.localhost.example.com",
"http://sub.tauri.localhost",
] {
assert_origin_invalid(&cors, origin).await;
}
}
}
mod mac {
use super::*;
#[actix_rt::test]
async fn requests_and_preflights_allow_credentials() {
assert_requests_and_preflights_allow_credentials(MAC_ORIGIN).await;
}
#[actix_rt::test]
async fn production_rejects_origin_variants() {
let cors = build_cors_config(ServerEnvironment::Production);
for origin in [
"http://localhost",
"https://localhost",
"tauri://localhost:8080",
"tauri://localhost.example.com",
"tauri://sub.localhost",
] {
assert_origin_invalid(&cors, origin).await;
}
}
}
async fn assert_requests_and_preflights_allow_credentials(origin: &str) {
for environment in [ServerEnvironment::Production, ServerEnvironment::Development] {
let service = build_cors_config(environment)
.new_transform(test::ok_service())
.await
.unwrap();
for method in [Method::GET, Method::POST, Method::OPTIONS] {
let mut request = TestRequest::default()
.uri("/v1/session")
.insert_header((header::ORIGIN, origin));
if method == Method::OPTIONS {
request = request
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type,session,x-artcraft-version"));
}
let response = test::call_service(&service, request.method(method).to_srv_request()).await;
assert_eq!(response.status(), StatusCode::OK, "Origin: {}", origin);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), origin);
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
}
}
}
}
@@ -4,6 +4,7 @@ use log::info;
use crate::configs::artcraft_2d::add_artcraft_2d;
use crate::configs::artcraft_3d::add_artcraft_3d;
use crate::configs::artcraft_admin_dashboard::add_artcraft_admin_dashboard;
use crate::configs::artcraft_desktop::add_artcraft_desktop;
use crate::configs::artcraft_webapp::add_artcraft_webapp;
use crate::configs::artcraft_website::add_artcraft_website;
use crate::configs::development_only::add_development_only;
@@ -48,6 +49,7 @@ fn do_build_cors_config(is_production: bool) -> Cors {
// Artcraft
cors = add_tauri(cors, is_production);
cors = add_artcraft_desktop(cors, is_production);
cors = add_artcraft_2d(cors, is_production);
cors = add_artcraft_3d(cors, is_production);
cors = add_artcraft_webapp(cors, is_production);
@@ -2,6 +2,11 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
// Share previews must work from the raw <head>, even for an unknown crawler
// or curl. Wait for generateMetadata instead of streaming it into the body.
// Media lookups have a five-second timeout; static marketing pages do not
// perform that lookup. https://nextjs.org/docs/app/api-reference/config/next-config-js/htmlLimitedBots
htmlLimitedBots: /.*/,
// Pin the project root: the repo has lockfiles both here and in the Nx
// workspace above, and letting Next guess picks the wrong one (breaks
// output file tracing on serverless deploys).
@@ -10,7 +10,7 @@
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"test:media": "playwright test tests/media.spec.ts"
"test:media": "playwright test tests/media.spec.ts tests/media-metadata.spec.ts"
},
"dependencies": {
"@radix-ui/react-navigation-menu": "^1.2.22",
@@ -0,0 +1,39 @@
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
import { mediaTitle } from "@/lib/media";
import { mediaEmbed, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
const HEADERS = { "Cache-Control": "private, no-store", "X-Robots-Tag": "noindex, nofollow" };
export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const query = new URL(request.url).searchParams;
if (query.has("format") && query.get("format") !== "json") {
return Response.json({ error: "Only JSON is supported" }, { status: 501, headers: HEADERS });
}
if (query.get("url") !== `${MEDIA_SHARE_ORIGIN}/media/${encodeURIComponent(token)}`) {
return Response.json({ error: "Media unavailable" }, { status: 404, headers: HEADERS });
}
const media = await fetchPublicMediaFile(token);
const embed = media && mediaEmbed(media);
if (!media || !embed) return Response.json({ error: "Media unavailable" }, { status: 404, headers: HEADERS });
const playerUrl = `${MEDIA_SHARE_ORIGIN}/media/${encodeURIComponent(token)}/player`;
const scale = Math.min(1, maximum(query.get("maxwidth")) / embed.width, maximum(query.get("maxheight")) / embed.height);
const width = Math.max(1, Math.floor(embed.width * scale));
const height = Math.max(1, Math.floor(embed.height * scale));
return Response.json({
version: "1.0",
type: embed.kind === "video" ? "video" : "rich",
provider_name: "ArtCraft",
provider_url: MEDIA_SHARE_ORIGIN,
title: mediaTitle(media),
author_name: media.maybe_creator_user?.display_name || media.maybe_creator_user?.username,
width,
height,
html: `<iframe src="${playerUrl}" width="${width}" height="${height}" title="ArtCraft media player" frameborder="0" allow="fullscreen" allowfullscreen></iframe>`,
}, { headers: HEADERS });
}
function maximum(value: string | null): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : Infinity;
}
@@ -1,40 +1,78 @@
import type { Metadata } from "next";
import MediaPage from "@/components/media/media-page";
import { API_HOST } from "@/lib/api";
import { mediaFilePath, mediaKind, mediaThumbnail, safeMediaUrl, type SharedMedia } from "@/lib/media";
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
import { mediaEmbed, mediaShareImage, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
import { mediaTitle } from "@/lib/media";
const OG_IMAGE_WIDTH = 1200;
const FALLBACK_IMAGE = { url: "/images/og-image.png", width: 1200, height: 630, alt: "ArtCraft" };
type Props = { params: Promise<{ token: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { token } = await params;
const url = `/media/${encodeURIComponent(token)}`;
const fallbackTitle = "Shared media";
const fallbackDescription = "View images, video, music, and interactive 3D creations shared with ArtCraft.";
const metadata: Metadata = {
title: "Shared media",
description: "View images, video, music, and interactive 3D creations shared with ArtCraft.",
alternates: { canonical: `/media/${encodeURIComponent(token)}` },
title: fallbackTitle,
description: fallbackDescription,
alternates: { canonical: url },
// A shared link is not a request to index a user's creation.
robots: { index: false, follow: true },
openGraph: {
type: "website", siteName: "ArtCraft", url,
title: `${fallbackTitle} — ArtCraft`, description: fallbackDescription,
images: [FALLBACK_IMAGE],
},
twitter: {
card: "summary_large_image", title: `${fallbackTitle} — ArtCraft`,
description: fallbackDescription, images: [FALLBACK_IMAGE],
},
};
const media = await fetchPublicMediaFile(token);
if (!media) return metadata;
const image = mediaKind(media) === "image"
? safeMediaUrl(media.media_links.cdn_url)
: mediaThumbnail(media.media_links, OG_IMAGE_WIDTH);
const title = mediaTitle(media);
const image = mediaShareImage(media);
const embed = mediaEmbed(media);
const images = image ? [{ url: image, alt: title }] : [FALLBACK_IMAGE];
const creator = media.maybe_creator_user;
const description = creator
? `Made with ArtCraft by ${creator.display_name || creator.username}.`
: "Made with ArtCraft.";
return {
...metadata,
title,
description,
openGraph: {
title: "Shared Media — ArtCraft",
type: embed?.kind === "video" ? "video.other" : "website",
...(embed?.kind === "video" && { videos: [{ url: embed.url, secureUrl: embed.url, type: embed.contentType }] }),
...(embed?.kind === "audio" && { audio: [{ url: embed.url, secureUrl: embed.url, type: embed.contentType }] }),
siteName: "ArtCraft",
title: `${title} — ArtCraft`,
description,
url: `/media/${encodeURIComponent(token)}`,
...(image && { images: [{ url: image }] }),
url,
images,
},
twitter: { card: image ? "summary_large_image" : "summary" },
twitter: {
...(embed ? { card: "player" as const, players: [] } : { card: "summary_large_image" as const }),
title: `${title} — ArtCraft`,
description,
images,
},
...(embed && {
alternates: { canonical: url, types: { "application/json+oembed": `${MEDIA_SHARE_ORIGIN}${url}/oembed?url=${encodeURIComponent(`${MEDIA_SHARE_ORIGIN}${url}`)}&format=json` } },
// Next's player descriptor requires a stream URL, while X's optional
// native stream must be MP4. Audio/WebM use the HTML player instead.
other: {
"twitter:player": `${MEDIA_SHARE_ORIGIN}${url}/player`,
"twitter:player:width": embed.width,
"twitter:player:height": embed.height,
...(embed.contentType === "video/mp4" && {
"twitter:player:stream": embed.url,
"twitter:player:stream:content_type": embed.contentType,
}),
},
}),
};
}
@@ -44,22 +82,3 @@ export default async function Media({ params }: Props) {
// viewer. Never cache one visitor's private media response in server HTML.
return <MediaPage key={token} token={token} />;
}
// Social cards use only the public API response. Never forward a visitor's
// cookies or signed session into server metadata; private media stays client-side.
async function fetchPublicMediaFile(token: string): Promise<SharedMedia | null> {
try {
const response = await fetch(`${API_HOST}${mediaFilePath(token)}`, {
headers: { Accept: "application/json" },
credentials: "omit",
cache: "no-store",
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return null;
const payload = await response.json() as { success?: boolean; media_file?: SharedMedia };
const media = payload.media_file;
return payload.success && media?.token && safeMediaUrl(media.media_links?.cdn_url) ? media : null;
} catch {
return null;
}
}
@@ -0,0 +1,35 @@
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
import { mediaTitle } from "@/lib/media";
import { mediaEmbed, mediaShareImage, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
const HEADERS = {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"X-Robots-Tag": "noindex, nofollow",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src https:; media-src https:; base-uri 'none'; form-action 'none'",
};
// A standalone HTML document bypasses the site layout and needs no scripts.
// Platforms that support iframe Player Cards can use native media controls.
export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const media = await fetchPublicMediaFile(token);
const embed = media && mediaEmbed(media);
if (!media || !embed) return new Response("Media unavailable", { status: 404, headers: HEADERS });
const title = escapeHtml(mediaTitle(media));
const poster = escapeHtml(mediaShareImage(media) ?? `${MEDIA_SHARE_ORIGIN}/images/og-image.png`);
const source = `<source src="${escapeHtml(embed.url)}" type="${embed.contentType}">`;
const player = embed.kind === "video"
? `<video controls playsinline preload="metadata" poster="${poster}" aria-label="${title}">${source}</video>`
: `<div class="audio"><img src="${poster}" alt=""><div><p>${title}</p><audio controls preload="metadata" aria-label="${title}">${source}</audio></div></div>`;
return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title} — ArtCraft</title><style>
:root{color-scheme:light dark}*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;font:14px system-ui;background:light-dark(#f2f1ee,#121316);color:light-dark(#222,#eee)}
video{display:block;width:100%;height:100%;object-fit:contain;background:#000}.audio{display:flex;align-items:center;gap:16px;padding:16px;height:100%}.audio img{width:80px;height:80px;object-fit:cover}.audio div{min-width:0;flex:1}p{margin:0 0 12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}audio{width:100%}@media(max-width:360px){.audio img{display:none}}
</style></head><body>${player}</body></html>`, { headers: HEADERS });
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
@@ -0,0 +1,24 @@
import { API_HOST } from "./api";
import { mediaFilePath, safeMediaUrl, type SharedMedia } from "./media";
// Social cards use only the public API response. Never forward a visitor's
// cookies or signed session into server metadata; private media stays client-side.
export async function fetchPublicMediaFile(token: string): Promise<SharedMedia | null> {
try {
const response = await fetch(`${API_HOST}${mediaFilePath(token)}`, {
headers: { Accept: "application/json" },
credentials: "omit",
cache: "no-store",
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return null;
const payload = await response.json() as { success?: boolean; media_file?: SharedMedia };
const media = payload.media_file;
if (!payload.success || media?.token !== token || !safeMediaUrl(media.media_links?.cdn_url)) return null;
// Hidden means unlisted but shareable by URL. Private files must never
// appear in social cards, even if an API response includes their fields.
return media.creator_set_visibility === "public" || media.creator_set_visibility === "hidden" ? media : null;
} catch {
return null;
}
}
@@ -0,0 +1,35 @@
import { mediaFormat, mediaKind, mediaThumbnail, safeMediaUrl, type SharedMedia } from "./media";
export const MEDIA_SHARE_ORIGIN = "https://getartcraft.com";
const PREVIEW_WIDTH = 1200;
const VIDEO_TYPES: Record<string, string> = { mp4: "video/mp4", webm: "video/webm" };
const AUDIO_TYPES: Record<string, string> = {
mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", aac: "audio/aac",
ogg: "audio/ogg", opus: "audio/ogg", flac: "audio/flac",
};
type MediaEmbed = {
kind: "video" | "audio";
url: string;
contentType: string;
// These are iframe viewport dimensions, not invented source dimensions.
width: number;
height: number;
};
export function mediaEmbed(media: SharedMedia): MediaEmbed | null {
const kind = mediaKind(media);
if (kind !== "video" && kind !== "audio") return null;
const contentType = (kind === "video" ? VIDEO_TYPES : AUDIO_TYPES)[mediaFormat(media)];
const url = safeMediaUrl(media.media_links.cdn_url);
if (!contentType || !url?.startsWith("https://")) return null;
return { kind, contentType, url, width: 640, height: kind === "video" ? 360 : 180 };
}
export function mediaShareImage(media: SharedMedia): string | undefined {
const cover = media.cover_image?.maybe_links;
return mediaThumbnail(media.media_links, PREVIEW_WIDTH)
?? (mediaKind(media) === "image" ? safeMediaUrl(media.media_links.cdn_url) : undefined)
?? safeMediaUrl(cover?.thumbnail_template?.replace("{WIDTH}", String(PREVIEW_WIDTH)))
?? safeMediaUrl(cover?.cdn_url);
}
@@ -8,6 +8,7 @@ export type SharedMedia = {
media_type?: string | null;
maybe_engine_category?: string | null;
maybe_title?: string | null;
creator_set_visibility?: string | null;
maybe_original_filename?: string | null;
maybe_prompt_token?: string | null;
maybe_text_transcript?: string | null;
@@ -18,6 +19,9 @@ export type SharedMedia = {
} | null;
maybe_model_weight_info?: { title: string } | null;
created_at?: string;
cover_image?: {
maybe_links?: { cdn_url: string; thumbnail_template?: string | null } | null;
} | null;
media_links: {
cdn_url: string;
maybe_thumbnail_template?: string | null;
@@ -13,3 +13,32 @@ on `127.0.0.1:4203`, which has no upstream. No production API or database is use
Run `npm run test:media` with Google Chrome installed and ports 4202 and 4203 free.
The suite starts its own Next server with the fixture API host, so it refuses to
reuse a running development server. Playwright uses installed Chrome.
`media-metadata.spec.ts` inspects raw HTTP response heads for Discord, Twitter,
Meta, Slack, an unknown crawler, curl, and a browser user agent. It executes no
page JavaScript. The fixtures cover image titles, video stills, 3D cover images,
unlisted links, audio without artwork, private media, API errors/timeouts, and
updates to an existing creation. The fixture API rejects forwarded session
credentials. Keep these tests independent of the browser's intercepted requests.
The route's `generateMetadata` performs the anonymous server-side lookup.
`htmlLimitedBots: /.*/` keeps metadata in the initial `<head>` for every user
agent. Keep the five-second API timeout and generic fallback so an API outage
does not leave crawlers waiting indefinitely. `cache: "no-store"` avoids stale
server metadata; social platforms can still cache cards independently.
Public audio/video links also advertise a standalone `/media/<token>/player`
document and `/media/<token>/oembed` discovery endpoint. The player uses native
HTML controls without scripts or the marketing layout. Tests play its fixture
video and audio in Chrome with page JavaScript disabled, check oEmbed sizing,
and verify that private media cannot be embedded. Twitter's optional native
stream URL is emitted only for MP4; audio and WebM use the iframe player.
These tests verify our responses and native player, not another platform's
acceptance of an embed. Validate a deployed URL in each platform. Discord's
audio-tag support is still an open request:
https://github.com/discord/discord-api-docs/discussions/3262
Discord-playable audio needs a generated video derivative (for example, H.264
cover art plus AAC audio in an MP4). Merely declaring an MP3 to be a video does
not create that derivative. Instagram feed/Reel publishing is a separate media
upload workflow; website metadata does not publish an Instagram post.
@@ -6,20 +6,43 @@ const CDN = "https://media-fixture.invalid";
const publicMedia = {
m_fixture_image: {
token: "m_fixture_image", media_class: "image",
creator_set_visibility: "public", maybe_title: "Night sky & stars",
maybe_creator_user: { username: "fixture_artist", display_name: "Fixture Artist" },
media_links: { cdn_url: `${CDN}/asset.png?fixture=1` },
},
m_fixture_video: {
token: "m_fixture_video", media_class: "video",
creator_set_visibility: "public", maybe_title: "A short film",
media_links: { cdn_url: `${CDN}/asset.mp4`, maybe_video_previews: {
still: `${CDN}/still.jpg`, still_thumbnail_template: `${CDN}/still-{WIDTH}.jpg`,
} },
},
m_fixture_audio: {
token: "m_fixture_audio", media_class: "audio",
creator_set_visibility: "public", maybe_title: "A song",
media_links: { cdn_url: `${CDN}/asset.wav` },
},
m_fixture_mesh: {
token: "m_fixture_mesh", media_class: "mesh", creator_set_visibility: "public",
media_links: { cdn_url: `${CDN}/asset.glb` },
cover_image: { maybe_links: { cdn_url: `${CDN}/cover.jpg`, thumbnail_template: `${CDN}/cover-{WIDTH}.jpg` } },
},
m_fixture_splat: {
token: "m_fixture_splat", media_class: "splat", creator_set_visibility: "public",
media_links: { cdn_url: `${CDN}/asset.spz` },
cover_image: { maybe_links: { cdn_url: `${CDN}/splat-cover.jpg` } },
},
m_fixture_private: {
token: "m_fixture_private", media_class: "image", creator_set_visibility: "private",
maybe_title: "Secret creation", maybe_creator_user: { username: "secret_artist" },
media_links: { cdn_url: `${CDN}/secret-image.png` },
},
m_fixture_hidden: {
token: "m_fixture_hidden", media_class: "image", creator_set_visibility: "hidden",
media_links: { cdn_url: `${CDN}/unlisted.png`, maybe_thumbnail_template: `${CDN}/unlisted-{WIDTH}.png` },
},
};
let revision = 0;
createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
@@ -33,6 +56,25 @@ createServer((request, response) => {
return;
}
const token = request.url?.match(/^\/v1\/media_files\/file\/([^/?]+)$/)?.[1];
if (token === "m_fixture_changing") {
response.end(JSON.stringify({ success: true, media_file: {
...publicMedia.m_fixture_image, token, maybe_title: `Revision ${++revision}`,
} }));
return;
}
if (token === "m_fixture_malformed") {
response.end("not json");
return;
}
if (token === "m_fixture_timeout") {
setTimeout(() => response.end(JSON.stringify({ success: false })), 6000);
return;
}
const errorStatus = { m_fixture_unauthorized: 401, m_fixture_forbidden: 403, m_fixture_failure: 500 }[token];
if (errorStatus) {
response.writeHead(errorStatus).end(JSON.stringify({ success: false }));
return;
}
const media = publicMedia[token];
response.writeHead(media ? 200 : 404).end(JSON.stringify(media
? { success: true, media_file: media }
@@ -0,0 +1,10 @@
export function wav() {
const buffer = Buffer.alloc(44 + 80000);
buffer.write("RIFF", 0); buffer.writeUInt32LE(buffer.length - 8, 4); buffer.write("WAVEfmt ", 8);
buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(1, 20); buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(8000, 24); buffer.writeUInt32LE(16000, 28); buffer.writeUInt16LE(2, 32); buffer.writeUInt16LE(16, 34);
buffer.write("data", 36); buffer.writeUInt32LE(80000, 40);
for (let i = 0; i < 40000; i++) buffer.writeInt16LE(Math.round(Math.sin(i * 2 * Math.PI * 440 / 8000) * 12000), 44 + i * 2);
return buffer;
}
@@ -0,0 +1,171 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { wav } from "./fixtures/audio";
const CDN = "https://media-fixture.invalid";
const SITE = "https://getartcraft.com";
const CRAWLERS = [
["Discord", "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"],
["Twitter", "Twitterbot/1.0"],
["Meta", "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"],
["Slack", "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"],
["unknown crawler", "ArtCraftPreviewTest/1.0"],
["curl", "curl/8.7.1"],
["browser", "Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36"],
] as const;
// Raw HTTP only: no browser, hydration, JavaScript, or DOM repair can make
// these assertions pass. Tags must be inside the server response's <head>.
for (const [name, userAgent] of CRAWLERS) {
test(`${name} gets complete media social tags in the raw HTML head`, async ({ request }) => {
const head = await requestHead(request, "m_fixture_image", userAgent);
expect(head).toContain("<title>Night sky &amp; stars — ArtCraft</title>");
expect(head).toContain('property="og:title" content="Night sky &amp; stars — ArtCraft"');
expect(head).toContain('property="og:description" content="Made with ArtCraft by Fixture Artist."');
expect(head).toContain('property="og:type" content="website"');
expect(head).toContain('property="og:site_name" content="ArtCraft"');
expect(head).toContain(`property="og:url" content="${SITE}/media/m_fixture_image"`);
expect(head).toContain(`property="og:image" content="${CDN}/asset.png?fixture=1"`);
expect(head).toContain('property="og:image:alt" content="Night sky &amp; stars"');
expect(head).toContain('name="twitter:card" content="summary_large_image"');
expect(head).toContain('name="twitter:title" content="Night sky &amp; stars — ArtCraft"');
expect(head).toContain('name="twitter:description" content="Made with ArtCraft by Fixture Artist."');
expect(head).toContain(`name="twitter:image" content="${CDN}/asset.png?fixture=1"`);
expect(head).toContain(`rel="canonical" href="${SITE}/media/m_fixture_image"`);
expect(head).toContain('name="robots" content="noindex, follow"');
});
}
test("video, mesh, splat, and unlisted cards use image previews", async ({ request }) => {
for (const [token, preview] of [
["m_fixture_video", "still-1200.jpg"],
["m_fixture_mesh", "cover-1200.jpg"],
["m_fixture_splat", "splat-cover.jpg"],
["m_fixture_hidden", "unlisted-1200.png"],
]) {
const head = await requestHead(request, token);
expect(head).toContain(`property="og:image" content="${CDN}/${preview}"`);
expect(head).toContain(`name="twitter:image" content="${CDN}/${preview}"`);
}
});
test("audio without cover art uses the site image and its own title", async ({ request }) => {
const head = await requestHead(request, "m_fixture_audio");
expect(head).toContain('property="og:title" content="A song — ArtCraft"');
expect(head).toContain(`property="og:image" content="${SITE}/images/og-image.png"`);
expect(head).toContain(`name="twitter:image" content="${SITE}/images/og-image.png"`);
expect(head).not.toContain(`property="og:image" content="${CDN}/asset.wav"`);
});
test("video and audio advertise direct media and iframe players", async ({ request }) => {
for (const [, userAgent] of CRAWLERS) {
const video = await requestHead(request, "m_fixture_video", userAgent);
expect(video).toContain('property="og:type" content="video.other"');
expect(video).toContain(`property="og:video" content="${CDN}/asset.mp4"`);
expect(video).toContain(`property="og:video:secure_url" content="${CDN}/asset.mp4"`);
expect(video).toContain('property="og:video:type" content="video/mp4"');
expect(video).toContain('name="twitter:card" content="player"');
expect(video).toContain(`name="twitter:player" content="${SITE}/media/m_fixture_video/player"`);
expect(video).toContain(`name="twitter:player:stream" content="${CDN}/asset.mp4"`);
expect(video).toContain('name="twitter:player:stream:content_type" content="video/mp4"');
expect(video).toContain('name="twitter:player:width" content="640"');
expect(video).toContain('name="twitter:player:height" content="360"');
expect(video).toContain('type="application/json+oembed"');
const audio = await requestHead(request, "m_fixture_audio", userAgent);
expect(audio).toContain(`property="og:audio" content="${CDN}/asset.wav"`);
expect(audio).toContain('property="og:audio:type" content="audio/wav"');
expect(audio).toContain('name="twitter:card" content="player"');
expect(audio).toContain(`name="twitter:player" content="${SITE}/media/m_fixture_audio/player"`);
expect(audio).not.toContain('name="twitter:player:stream"');
expect(audio).not.toContain('property="og:video"');
}
});
test("oEmbed returns a sized iframe and rejects unrelated URLs and unsupported formats", async ({ request }) => {
for (const [token, type] of [["m_fixture_video", "video"], ["m_fixture_audio", "rich"]]) {
const response = await request.get(`/media/${token}/oembed`, { params: { url: `${SITE}/media/${token}`, format: "json", maxwidth: 320, maxheight: 90 } });
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toMatchObject({ version: "1.0", type, provider_name: "ArtCraft", provider_url: SITE });
expect(data.width).toBeLessThanOrEqual(320);
expect(data.height).toBeLessThanOrEqual(90);
expect(data.html).toContain(`src="${SITE}/media/${token}/player"`);
expect(data.html).toContain(`width="${data.width}" height="${data.height}"`);
}
const unrelated = await request.get("/media/m_fixture_video/oembed", { params: { url: "https://unrelated.invalid/" } });
expect(unrelated.status()).toBe(404);
const xml = await request.get("/media/m_fixture_video/oembed?format=xml");
expect(xml.status()).toBe(501);
});
test("private and non-playable media cannot be embedded", async ({ request }) => {
for (const token of ["m_fixture_private", "m_missing", "m_fixture_image", "m_fixture_mesh"]) {
for (const route of ["player", "oembed"]) {
const response = await request.get(`/media/${token}/${route}`, { params: { url: `${SITE}/media/${token}` } });
expect(response.status()).toBe(404);
const body = await response.text();
expect(body).not.toContain(CDN);
expect(body).not.toContain("Secret creation");
}
}
});
test("standalone video and audio players load and play with JavaScript disabled", async ({ browser }) => {
const context = await browser.newContext({ javaScriptEnabled: false });
try {
const page = await context.newPage();
const png = readFileSync(join(__dirname, "../public/images/og-image.png"));
await page.route(`${CDN}/**`, (route) => route.fulfill({ body: png, contentType: "image/png" }));
await page.route(`${SITE}/images/og-image.png`, (route) => route.fulfill({ body: png, contentType: "image/png" }));
await page.route(`${CDN}/asset.mp4`, (route) => route.fulfill({ body: readFileSync(join(__dirname, "fixtures/clip.mp4")), contentType: "video/mp4" }));
await page.route(`${CDN}/asset.wav`, (route) => route.fulfill({ body: wav(), contentType: "audio/wav" }));
for (const kind of ["video", "audio"]) {
const response = await page.goto(`http://localhost:4202/media/m_fixture_${kind}/player`);
expect(response?.headers()["content-security-policy"]).toContain("default-src 'none'");
expect(await response!.text()).not.toContain("<script");
const player = page.locator(kind);
await expect(player).toHaveAttribute("controls", "");
await expect.poll(() => player.evaluate((element: HTMLMediaElement) => element.readyState)).toBeGreaterThanOrEqual(1);
// Invoke the native media API; the document itself contains no JavaScript.
await player.evaluate((element: HTMLMediaElement) => { element.muted = true; return element.play(); });
await expect.poll(() => player.evaluate((element: HTMLMediaElement) => element.currentTime)).toBeGreaterThan(0);
}
} finally {
await context.close();
}
});
test("private files, missing files, and API failures get a generic card", async ({ request }) => {
for (const token of ["m_fixture_private", "m_missing", "m_fixture_unauthorized", "m_fixture_forbidden", "m_fixture_failure", "m_fixture_malformed", "m_fixture_timeout"]) {
const head = await requestHead(request, token);
expect(head).toContain('property="og:title" content="Shared media — ArtCraft"');
expect(head).toContain(`property="og:url" content="${SITE}/media/${token}"`);
expect(head).toContain(`property="og:image" content="${SITE}/images/og-image.png"`);
expect(head).toContain(`name="twitter:image" content="${SITE}/images/og-image.png"`);
expect(head).not.toContain(CDN);
expect(head).not.toContain("Secret creation");
expect(head).not.toContain("secret_artist");
}
});
test("metadata is looked up again when a public creation changes", async ({ request }) => {
const first = await requestHead(request, "m_fixture_changing");
const second = await requestHead(request, "m_fixture_changing");
const revision = (head: string) => Number(head.match(/property="og:title" content="Revision (\d+)/)?.[1]);
expect(revision(first)).toBeGreaterThan(0);
expect(revision(second)).toBeGreaterThan(revision(first));
});
async function requestHead(request: APIRequestContext, token: string, userAgent = "Discordbot/2.0") {
const response = await request.get(`/media/${token}`, {
// The fixture API rejects these headers if the website forwards them.
headers: { "User-Agent": userAgent, Cookie: "session=private-fixture", session: "private-fixture" },
});
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("text/html");
const html = await response.text();
const head = html.match(/<head\b[^>]*>([\s\S]*?)<\/head>/i)?.[1];
expect(head).toBeDefined();
return head!;
}
@@ -2,6 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { wav } from "./fixtures/audio";
import { corsMediaUrl, mediaKind, safeMediaUrl, type SharedMedia } from "../src/lib/media";
const API = "http://127.0.0.1:4203";
@@ -35,21 +36,6 @@ test("legacy query share links redirect to the canonical media page", async ({ p
await expect(page.getByAltText("Fixture image")).toBeVisible();
});
test("social cards use public images and video stills without forwarding visitor credentials", async ({ request }) => {
const headers = { "User-Agent": "Twitterbot", Cookie: "session=private-fixture", session: "private-fixture" };
const image = await request.get("/media/m_fixture_image", { headers });
const imageHtml = await image.text();
expect(imageHtml).toContain('property="og:image" content="https://media-fixture.invalid/asset.png?fixture=1"');
expect(imageHtml).toContain("Made with ArtCraft by Fixture Artist.");
expect(imageHtml).toContain('name="robots" content="noindex, follow"');
const video = await request.get("/media/m_fixture_video", { headers });
expect(await video.text()).toContain('property="og:image" content="https://media-fixture.invalid/still-1200.jpg"');
for (const token of ["m_fixture_audio", "m_private"]) {
const response = await request.get(`/media/${token}`, { headers });
expect(await response.text()).not.toContain('property="og:image" content="https://media-fixture.invalid/');
}
});
test("prompt and reference media are restored without blocking the preview", async ({ page }) => {
await mockMedia(page, { ...fixture("image", "png"), maybe_prompt_token: "p_fixture" });
await page.route(`${API}/v1/prompts/p_fixture`, (route) => route.fulfill({ json: {
@@ -254,16 +240,6 @@ async function mockMedia(page: Page, media: SharedMedia) {
await page.route(`${API}/v1/media_files/file/**`, (route) => route.fulfill({ json: { success: true, media_file: media } }));
}
function wav() {
const buffer = Buffer.alloc(44 + 80000);
buffer.write("RIFF", 0); buffer.writeUInt32LE(buffer.length - 8, 4); buffer.write("WAVEfmt ", 8);
buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(1, 20); buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(8000, 24); buffer.writeUInt32LE(16000, 28); buffer.writeUInt16LE(2, 32); buffer.writeUInt16LE(16, 34);
buffer.write("data", 36); buffer.writeUInt32LE(80000, 40);
for (let i = 0; i < 40000; i++) buffer.writeInt16LE(Math.round(Math.sin(i * 2 * Math.PI * 440 / 8000) * 12000), 44 + i * 2);
return buffer;
}
async function serveBytes(route: Route, bytes: Buffer, contentType: string) {
const range = route.request().headers().range?.match(/bytes=(\d+)-(\d*)/);
const start = range ? Number(range[1]) : 0;