Skip to content
Open
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
283 changes: 133 additions & 150 deletions app/hooks/useMediaBin.ts

Large diffs are not rendered by default.

48 changes: 16 additions & 32 deletions app/lib/timeline.store.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
import fs from "fs";
import path from "path";
import type { MediaBinItem, TimelineState } from "~/components/timeline/types";
import { safeResolvePath, ensureDirectoryExists } from "~/utils/path-security";

const TIMELINE_DIR = process.env.TIMELINE_DIR || path.resolve("project_data");

function ensureDir(): void {
if (!fs.existsSync(TIMELINE_DIR))
fs.mkdirSync(TIMELINE_DIR, { recursive: true });
ensureDirectoryExists(TIMELINE_DIR);
}

function getFilePath(projectId: string): string {
ensureDir();

// Validate and sanitize projectId to prevent path traversal
if (!projectId || typeof projectId !== 'string') {
throw new Error('Invalid project ID');
}
// Remove any path traversal attempts and invalid characters
const sanitizedId = projectId.replace(/[^a-zA-Z0-9_-]/g, '');
if (sanitizedId !== projectId || sanitizedId.length === 0) {
throw new Error('Invalid project ID format');
if (!projectId || typeof projectId !== "string") {
throw new Error("Invalid project ID");
}
const filePath = path.resolve(TIMELINE_DIR, `${sanitizedId}.json`);
// Ensure the resolved path is still within TIMELINE_DIR
if (!filePath.startsWith(path.resolve(TIMELINE_DIR))) {
throw new Error('Invalid path');

// Use the utility function to safely resolve the path
const filePath = safeResolvePath(TIMELINE_DIR, `${projectId}.json`);
if (!filePath) {
throw new Error("Invalid project ID format");
}

return filePath;
}

Expand All @@ -44,23 +42,15 @@ function defaultTimeline(): TimelineState {
};
}

export async function loadProjectState(
projectId: string
): Promise<ProjectStateFile> {
export async function loadProjectState(projectId: string): Promise<ProjectStateFile> {
const file = getFilePath(projectId);
try {
const raw = await fs.promises.readFile(file, "utf8");
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
("timeline" in parsed || "textBinItems" in parsed)
) {
if (parsed && typeof parsed === "object" && ("timeline" in parsed || "textBinItems" in parsed)) {
return {
timeline: parsed.timeline ?? defaultTimeline(),
textBinItems: Array.isArray(parsed.textBinItems)
? parsed.textBinItems
: [],
textBinItems: Array.isArray(parsed.textBinItems) ? parsed.textBinItems : [],
};
}
// legacy file stored just the timeline
Expand All @@ -70,10 +60,7 @@ export async function loadProjectState(
}
}

export async function saveProjectState(
projectId: string,
state: ProjectStateFile
): Promise<void> {
export async function saveProjectState(projectId: string, state: ProjectStateFile): Promise<void> {
const file = getFilePath(projectId);
await fs.promises.writeFile(file, JSON.stringify(state), "utf8");
}
Expand All @@ -84,10 +71,7 @@ export async function loadTimeline(projectId: string): Promise<TimelineState> {
return state.timeline;
}

export async function saveTimeline(
projectId: string,
timeline: TimelineState
): Promise<void> {
export async function saveTimeline(projectId: string, timeline: TimelineState): Promise<void> {
const prev = await loadProjectState(projectId);
await saveProjectState(projectId, {
timeline,
Expand Down
116 changes: 33 additions & 83 deletions app/routes/api.assets.$.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { auth } from "~/lib/auth.server";
import {
insertAsset,
listAssetsByUser,
getAssetById,
softDeleteAsset,
} from "~/lib/assets.repo";
import { insertAsset, listAssetsByUser, getAssetById, softDeleteAsset } from "~/lib/assets.repo";
import fs from "fs";
import path from "path";

Expand All @@ -15,21 +10,15 @@ async function requireUserId(request: Request): Promise<string> {
try {
// @ts-ignore - runtime API may not be typed
const session = await auth.api?.getSession?.({ headers: request.headers });
const userId: string | undefined =
session?.user?.id ?? session?.session?.userId;
const userId: string | undefined = session?.user?.id ?? session?.session?.userId;
if (userId) return String(userId);
} catch {
console.error("Failed to get session");
}

// Fallback: call /api/auth/session with forwarded cookies
const host =
request.headers.get("x-forwarded-host") ||
request.headers.get("host") ||
"localhost:5173";
const proto =
request.headers.get("x-forwarded-proto") ||
(host.includes("localhost") ? "http" : "https");
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "localhost:5173";
const proto = request.headers.get("x-forwarded-proto") || (host.includes("localhost") ? "http" : "https");
const base = `${proto}://${host}`;
const cookie = request.headers.get("cookie") || "";
const res = await fetch(`${base}/api/auth/session`, {
Expand Down Expand Up @@ -62,15 +51,11 @@ async function requireUserId(request: Request): Promise<string> {
return String(uid);
}

function inferMediaTypeFromName(
name: string,
fallback: string = "application/octet-stream"
): string {
function inferMediaTypeFromName(name: string, fallback: string = "application/octet-stream"): string {
const ext = path.extname(name).toLowerCase();
if ([".mp4", ".mov", ".webm", ".mkv", ".avi"].includes(ext)) return "video/*";
if ([".mp3", ".wav", ".aac", ".ogg", ".flac"].includes(ext)) return "audio/*";
if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"].includes(ext))
return "image/*";
if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"].includes(ext)) return "image/*";
return fallback;
}

Expand All @@ -96,9 +81,7 @@ export async function loader({ request }: { request: Request }) {
durationInSeconds: r.duration_seconds, // camelCase for frontend
created_at: r.created_at,
mediaUrlRemote: `/api/assets/${r.id}/raw`,
fullUrl: `http://localhost:8000/media/${encodeURIComponent(
r.storage_key
)}`,
// Remove public fullUrl - all access must go through authenticated API
}));
return new Response(JSON.stringify({ assets: items }), {
status: 200,
Expand Down Expand Up @@ -130,19 +113,12 @@ export async function loader({ request }: { request: Request }) {
// Support range requests for video/audio
const stat = fs.statSync(filePath);
const range = request.headers.get("range");
const contentType =
asset.mime_type || inferMediaTypeFromName(asset.original_name);
const contentType = asset.mime_type || inferMediaTypeFromName(asset.original_name);
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
if (
isNaN(start) ||
isNaN(end) ||
start > end ||
start < 0 ||
end >= stat.size
) {
if (isNaN(start) || isNaN(end) || start > end || start < 0 || end >= stat.size) {
return new Response(undefined, { status: 416 });
}
const chunkSize = end - start + 1;
Expand Down Expand Up @@ -182,8 +158,7 @@ export async function action({ request }: { request: Request }) {
if (pathname.endsWith("/api/assets/upload") && method === "POST") {
const width = Number(request.headers.get("x-media-width") || "") || null;
const height = Number(request.headers.get("x-media-height") || "") || null;
const duration =
Number(request.headers.get("x-media-duration") || "") || null;
const duration = Number(request.headers.get("x-media-duration") || "") || null;
const originalNameHeader = request.headers.get("x-original-name") || "file";
const projectIdHeader = request.headers.get("x-project-id");

Expand All @@ -199,37 +174,30 @@ export async function action({ request }: { request: Request }) {

// Reconstruct a new FormData and forward to 8000 so boundary is correct; faster and streams
const form = new FormData();
const filenameFor8000 = (media as {name?: string})?.name || originalNameHeader || "upload.bin";
const filenameFor8000 = (media as { name?: string })?.name || originalNameHeader || "upload.bin";
form.append("media", media, filenameFor8000);

// Use HTTPS in production, HTTP only for local development
const uploadUrl = process.env.NODE_ENV === "production"
? process.env.UPLOAD_SERVICE_URL || "https://localhost:8000/upload"
: "http://localhost:8000/upload";

// Use internal Docker network URL for backend communication
const uploadUrl =
process.env.NODE_ENV === "production" ? "http://backend:8000/upload" : "http://localhost:8000/upload";

const forwardRes = await fetch(uploadUrl, {
method: "POST",
body: form,
});

if (!forwardRes.ok) {
const errText = await forwardRes.text().catch(() => "");
return new Response(
JSON.stringify({ error: "Upload failed", detail: errText }),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
return new Response(JSON.stringify({ error: "Upload failed", detail: errText }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}

const json = await forwardRes.json();
const filename: string = json.filename;
const size: number = json.size;
const mime = inferMediaTypeFromName(
filenameFor8000,
"application/octet-stream"
);
const mime = inferMediaTypeFromName(filenameFor8000, "application/octet-stream");

const record = await insertAsset({
userId,
Expand All @@ -250,16 +218,13 @@ export async function action({ request }: { request: Request }) {
id: record.id,
name: record.original_name,
mediaUrlRemote: `/api/assets/${record.id}/raw`,
fullUrl: `http://localhost:8000/media/${encodeURIComponent(
filename
)}`,
width: record.width,
height: record.height,
durationInSeconds: record.duration_seconds,
size: record.size_bytes,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}

Expand All @@ -269,21 +234,15 @@ export async function action({ request }: { request: Request }) {
const filename: string | undefined = body.filename;
const originalName: string | undefined = body.originalName;
const size: number | undefined = body.size;
const width: number | null =
typeof body.width === "number" ? body.width : null;
const height: number | null =
typeof body.height === "number" ? body.height : null;
const duration: number | null =
typeof body.duration === "number" ? body.duration : null;
const width: number | null = typeof body.width === "number" ? body.width : null;
const height: number | null = typeof body.height === "number" ? body.height : null;
const duration: number | null = typeof body.duration === "number" ? body.duration : null;

if (!filename || !originalName) {
return new Response(
JSON.stringify({ error: "filename and originalName are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
return new Response(JSON.stringify({ error: "filename and originalName are required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const filePath = path.resolve(OUT_DIR, decodeURIComponent(filename));
if (!filePath.startsWith(OUT_DIR) || !fs.existsSync(filePath)) {
Expand All @@ -293,10 +252,7 @@ export async function action({ request }: { request: Request }) {
});
}
const stat = fs.statSync(filePath);
const mime = inferMediaTypeFromName(
originalName,
"application/octet-stream"
);
const mime = inferMediaTypeFromName(originalName, "application/octet-stream");

const record = await insertAsset({
userId,
Expand All @@ -316,16 +272,13 @@ export async function action({ request }: { request: Request }) {
id: record.id,
name: record.original_name,
mediaUrlRemote: `/api/assets/${record.id}/raw`,
fullUrl: `http://localhost:8000/media/${encodeURIComponent(
record.storage_key
)}`,
width: record.width,
height: record.height,
durationInSeconds: record.duration_seconds,
size: record.size_bytes,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}

Expand Down Expand Up @@ -382,7 +335,7 @@ export async function action({ request }: { request: Request }) {
const ext = path.extname(sanitizedKey);
const base = path.basename(sanitizedKey, ext);
// Sanitize suffix to prevent path traversal in filename
const sanitizedSuffix = suffix.replace(/[^a-zA-Z0-9_-]/g, '');
const sanitizedSuffix = suffix.replace(/[^a-zA-Z0-9_-]/g, "");
const newFilename = `${base}_${sanitizedSuffix}_${timestamp}${ext}`;
const destPath = path.resolve(OUT_DIR, newFilename);
fs.copyFileSync(srcPath, destPath);
Expand All @@ -407,16 +360,13 @@ export async function action({ request }: { request: Request }) {
id: record.id,
name: record.original_name,
mediaUrlRemote: `/api/assets/${record.id}/raw`,
fullUrl: `http://localhost:8000/media/${encodeURIComponent(
newFilename
)}`,
width: record.width,
height: record.height,
durationInSeconds: record.duration_seconds,
size: record.size_bytes,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}

Expand Down
Loading