Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a93e358cd | ||
|
|
1cb0b32898 | ||
|
|
c50bb78c32 | ||
|
|
2c93b4cf42 | ||
|
|
9169e4d662 | ||
|
|
4c7af51851 |
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("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
@@ -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 & stars — ArtCraft</title>");
|
||||
expect(head).toContain('property="og:title" content="Night sky & 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 & stars"');
|
||||
expect(head).toContain('name="twitter:card" content="summary_large_image"');
|
||||
expect(head).toContain('name="twitter:title" content="Night sky & 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;
|
||||
|
||||
Reference in new issue
Block a user