Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/api/account/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,30 @@ function sanitizeConfig(body: any): Record<string, any> {
// (escape-first) at display time, so no HTML sanitization is needed here.
const blocks = cleanBlocks(body?.blocks);
if (blocks.length) c.blocks = blocks;

// Uploaded videos ({name, url, poster?}) — pass through so a config save
// doesn't wipe uploads (they're written to the tenant config by /api/upload).
const videos = cleanVideos(body?.videos);
if (videos.length) c.videos = videos;
return c;
}

/** Keeps uploaded-video entries: same-origin /api/media/ url + optional poster. */
function cleanVideos(arr: unknown): { name: string; url: string; poster?: string }[] {
if (!Array.isArray(arr)) return [];
const okUrl = (u: string) => /^\/api\/media\/[A-Za-z0-9._\-/]+$/.test(u) || normalizeUrl(u) === u;
const out: { name: string; url: string; poster?: string }[] = [];
for (const v of arr.slice(0, 24)) {
const url = String((v as any)?.url || "").trim();
if (!okUrl(url)) continue;
const entry: { name: string; url: string; poster?: string } = { name: String((v as any)?.name || "video").slice(0, 120), url };
const poster = String((v as any)?.poster || "").trim();
if (poster && okUrl(poster)) entry.poster = poster;
out.push(entry);
}
return out;
}

/** Sanitizes the content-blocks array: bounded count + size, known types only. */
function cleanBlocks(arr: unknown): { id: string; type: string; content: string; enabled: boolean }[] {
if (!Array.isArray(arr)) return [];
Expand Down
9 changes: 8 additions & 1 deletion app/api/media/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ path: strin
try { stat = await fsp.stat(full); if (!stat.isFile()) throw 0; } catch { return new Response("not found", { status: 404 }); }

const size = stat.size;
const type = "video/mp4";
const ext = path.extname(full).toLowerCase();
const type =
ext === ".png" ? "image/png"
: ext === ".jpg" || ext === ".jpeg" ? "image/jpeg"
: ext === ".webp" ? "image/webp"
: ext === ".webm" ? "video/webm"
: ext === ".mov" ? "video/quicktime"
: "video/mp4";
const range = req.headers.get("range");

if (range) {
Expand Down
22 changes: 18 additions & 4 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "node:path";
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
import { findOrCreateAccountByEmail, ownsParkedDomain, getTenantConfig, upsertTenant } from "@/lib/db";
import { safeDomain } from "@/lib/config";
import { ffmpegPoster } from "@/lib/media";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -43,16 +44,28 @@ export async function POST(req: NextRequest) {
await fs.mkdir(dir, { recursive: true });
const safeBase = (file.name || "video.mp4").replace(/[^A-Za-z0-9._-]/g, "_").replace(/_{2,}/g, "_").slice(0, 80);
const fname = `${Date.now().toString(36)}-${safeBase.endsWith(".mp4") ? safeBase : safeBase + ".mp4"}`;
await fs.writeFile(path.join(dir, fname), Buffer.from(await file.arrayBuffer()));
const mp4Path = path.join(dir, fname);
await fs.writeFile(mp4Path, Buffer.from(await file.arrayBuffer()));

// Generate a poster thumbnail (best-effort — never fail the upload on it).
const thumbName = fname.replace(/\.mp4$/i, "_thumb.png");
let poster: string | undefined;
try {
if (await ffmpegPoster(mp4Path, path.join(dir, thumbName))) {
poster = `/api/media/${domSlug(dn)}/${thumbName}`;
}
} catch { /* no poster, no problem */ }

const url = `/api/media/${domSlug(dn)}/${fname}`;
const entry: { name: string; url: string; poster?: string } = { name: (file.name || fname).slice(0, 120), url };
if (poster) entry.poster = poster;
const config: any = (await getTenantConfig(dn)) || {};
const videos = Array.isArray(config.videos) ? config.videos : [];
videos.unshift({ name: (file.name || fname).slice(0, 120), url });
videos.unshift(entry);
config.videos = videos.slice(0, 24);
await upsertTenant(dn, id, config);

return NextResponse.json({ ok: true, video: { name: (file.name || fname).slice(0, 120), url }, videos: config.videos });
return NextResponse.json({ ok: true, video: entry, videos: config.videos });
}

// DELETE /api/upload?dn=<domain>&url=/api/media/... — remove a video.
Expand All @@ -67,10 +80,11 @@ export async function DELETE(req: NextRequest) {
const videos = Array.isArray(config.videos) ? config.videos : [];
config.videos = videos.filter((v: any) => v?.url !== url);
await upsertTenant(dn, id, config);
// Best-effort delete the file (path is validated to be under this domain's dir).
// Best-effort delete the file + its poster (path validated to this domain's dir).
const m = /^\/api\/media\/([a-z0-9.-]+)\/([A-Za-z0-9._-]+)$/.exec(url);
if (m && m[1] === domSlug(dn)) {
fs.unlink(path.join(VIDEO_DIR, m[1], m[2])).catch(() => {});
fs.unlink(path.join(VIDEO_DIR, m[1], m[2].replace(/\.mp4$/i, "_thumb.png"))).catch(() => {});
}
return NextResponse.json({ ok: true, videos: config.videos });
}
2 changes: 1 addition & 1 deletion components/Tenant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export default function Tenant({ cfg }: { cfg: TenantConfig }) {
{cfg.videos.length > 0 && (
<div id="videos" className="t-videos" aria-label="Videos">
{cfg.videos.map((v, i) => (
<video key={i} className="t-video" controls preload="metadata" playsInline src={v.url} />
<video key={i} className="t-video" controls preload="metadata" playsInline src={v.url} poster={v.poster || undefined} />
))}
</div>
)}
Expand Down
12 changes: 8 additions & 4 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export type TenantConfig = {
hashtags: string[];
/** Image assets pulled from a connected GitHub repo, rendered as a gallery. */
assets: TenantLink[];
/** Uploaded MP4 videos ({name, url}), rendered in the #videos section. */
videos: { name: string; url: string }[];
/** Uploaded MP4 videos ({name, url, poster?}), rendered in the #videos section. */
videos: { name: string; url: string; poster?: string }[];
/** Genres from ?style=metal,punk — drives the AI hero-image generation. */
styles: string[];
/** Optional background accent (rgba) from ?bg_rgba=; null = use the theme default. */
Expand Down Expand Up @@ -398,9 +398,13 @@ export function configFor(dn: string, opts: TenantOverrides = {}): TenantConfig
const assets = Array.isArray(ov.assets)
? ov.assets.filter((a: any) => a && a.url).map((a: any) => ({ label: String(a.name || a.label || ""), url: String(a.url), kind: "image" }))
: [];
// Uploaded MP4 videos ({name, url}).
// Uploaded MP4 videos ({name, url, poster?}).
const videos = Array.isArray(ov.videos)
? ov.videos.filter((v: any) => v && v.url).map((v: any) => ({ name: String(v.name || "video"), url: String(v.url) })).slice(0, 24)
? ov.videos.filter((v: any) => v && v.url).map((v: any) => ({
name: String(v.name || "video"),
url: String(v.url),
...(v.poster ? { poster: String(v.poster) } : {}),
})).slice(0, 24)
: [];
// Hashtags: ?hashtags= query, else saved config, else the domain slug.
const parsedTags = parseHashtags(opts.hashtags);
Expand Down
25 changes: 14 additions & 11 deletions lib/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,21 @@ function runFfmpeg(args: string[]): Promise<boolean> {
}

/**
* Extracts a poster frame from an uploaded video into `<name>_thumb.png`
* (640px wide, aspect preserved) using ffmpeg. Best-effort: returns false if
* ffmpeg is missing or the clip can't be decoded — the caller must not fail the
* upload on a false. Grabs a frame ~1s in, falling back to the first frame for
* very short clips.
* Extracts a 640px poster frame from `inputPath` into `outputPath` (PNG) with
* ffmpeg. Best-effort: returns false if ffmpeg is missing or the clip can't be
* decoded — callers must not fail an upload on a false. Grabs a frame ~1s in,
* falling back to the first frame for very short clips. Works on any absolute
* paths, so it's shared by both video stores (media/ and videos/).
*/
export async function ffmpegPoster(inputPath: string, outputPath: string): Promise<boolean> {
const frameAt = (t: string) =>
["-y", "-ss", t, "-i", inputPath, "-frames:v", "1", "-vf", "scale=640:-2", outputPath];
if ((await runFfmpeg(frameAt("1"))) && fs.existsSync(outputPath)) return true;
return (await runFfmpeg(frameAt("0"))) && fs.existsSync(outputPath);
}

/** Poster for an uploaded media-table reel: `<name>.mp4` → `<name>_thumb.png`. */
export async function generateThumbnail(videoFilename: string): Promise<boolean> {
ensureDir();
const input = mediaPath(videoFilename);
const output = mediaPath(thumbFilename(videoFilename));
const frameAt = (t: string) =>
["-y", "-ss", t, "-i", input, "-frames:v", "1", "-vf", "scale=640:-2", output];
if ((await runFfmpeg(frameAt("1"))) && fs.existsSync(output)) return true;
return (await runFfmpeg(frameAt("0"))) && fs.existsSync(output);
return ffmpegPoster(mediaPath(videoFilename), mediaPath(thumbFilename(videoFilename)));
}
8 changes: 6 additions & 2 deletions lib/tenant-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,16 @@ export function sanitizeTenantConfig(body: any): Record<string, any> {
// Uploaded MP4 videos ({name, url}). url is a same-origin /api/media/ path
// (or an http(s) URL); passed through so a config save doesn't wipe uploads.
if (Array.isArray(body?.videos)) {
const okUrl = (u: string) => /^\/api\/media\/[A-Za-z0-9._\-\/]+$/.test(u) || normalizeUrl(u) === u;
const videos = body.videos
.slice(0, 24)
.map((v: any) => {
const url = String(v?.url || "").trim();
const ok = /^\/api\/media\/[A-Za-z0-9._\-\/]+$/.test(url) || (normalizeUrl(url) === url);
return ok ? { name: String(v?.name || "video").slice(0, 120), url } : null;
if (!okUrl(url)) return null;
const entry: { name: string; url: string; poster?: string } = { name: String(v?.name || "video").slice(0, 120), url };
const poster = String(v?.poster || "").trim();
if (poster && okUrl(poster)) entry.poster = poster;
return entry;
})
.filter(Boolean);
if (videos.length) c.videos = videos;
Expand Down
Loading