diff --git a/app/hooks/useMediaBin.ts b/app/hooks/useMediaBin.ts index e0d8798..8de013b 100644 --- a/app/hooks/useMediaBin.ts +++ b/app/hooks/useMediaBin.ts @@ -1,20 +1,17 @@ -import { useState, useCallback, useEffect } from "react" -import axios from "axios" -import { type MediaBinItem, type ScrubberState } from "~/components/timeline/types" -import { generateUUID } from "~/utils/uuid" -import { apiUrl } from "~/utils/api" +import { useState, useCallback, useEffect } from "react"; +import axios from "axios"; +import { type MediaBinItem, type ScrubberState } from "~/components/timeline/types"; +import { generateUUID } from "~/utils/uuid"; +import { apiUrl } from "~/utils/api"; // Delete media file from server export const deleteMediaFile = async ( - filename: string + filename: string, ): Promise<{ success: boolean; message?: string; error?: string }> => { try { - const response = await fetch( - apiUrl(`/media/${encodeURIComponent(filename)}`), - { - method: "DELETE", - } - ); + const response = await fetch(apiUrl(`/media/${encodeURIComponent(filename)}`), { + method: "DELETE", + }); if (!response.ok) { const errorData = await response.json(); @@ -35,7 +32,7 @@ export const deleteMediaFile = async ( export const cloneMediaFile = async ( filename: string, originalName: string, - suffix: string + suffix: string, ): Promise<{ success: boolean; filename?: string; @@ -76,7 +73,7 @@ export const cloneMediaFile = async ( // Helper function to get media metadata const getMediaMetadata = ( file: File, - mediaType: "video" | "image" | "audio" + mediaType: "video" | "image" | "audio", ): Promise<{ durationInSeconds?: number; width: number; @@ -96,9 +93,7 @@ const getMediaMetadata = ( URL.revokeObjectURL(url); resolve({ - durationInSeconds: isFinite(durationInSeconds) - ? durationInSeconds - : undefined, + durationInSeconds: isFinite(durationInSeconds) ? durationInSeconds : undefined, width, height, }); @@ -140,9 +135,7 @@ const getMediaMetadata = ( URL.revokeObjectURL(url); resolve({ - durationInSeconds: isFinite(durationInSeconds) - ? durationInSeconds - : undefined, + durationInSeconds: isFinite(durationInSeconds) ? durationInSeconds : undefined, width: 0, // Audio files don't have visual dimensions height: 0, }); @@ -158,9 +151,7 @@ const getMediaMetadata = ( }); }; -export const useMediaBin = ( - handleDeleteScrubbersByMediaBinId: (mediaBinId: string) => void -) => { +export const useMediaBin = (handleDeleteScrubbersByMediaBinId: (mediaBinId: string) => void) => { const [mediaBinItems, setMediaBinItems] = useState([]); const [isMediaLoading, setIsMediaLoading] = useState(true); const projectId = (() => { @@ -177,19 +168,18 @@ export const useMediaBin = ( item: MediaBinItem; } | null>(null); - // Hydrate existing assets for the logged-in user - // DISABLED: Loading assets feature temporarily commented out - /* + // Hydrate existing assets for the logged-in user and project useEffect(() => { const loadAssets = async () => { try { - const url = projectId - ? `/api/assets?projectId=${encodeURIComponent(projectId)}` - : "/api/assets"; + const url = projectId ? `/api/assets?projectId=${encodeURIComponent(projectId)}` : "/api/assets"; const res = await fetch(apiUrl(url, false, true), { credentials: "include", }); - if (!res.ok) return; + if (!res.ok) { + console.warn("Failed to load assets:", res.status); + return; + } const json = await res.json(); const assets = (json.assets || []) as Array<{ id: string; @@ -219,12 +209,14 @@ export const useMediaBin = ( uploadProgress: null, left_transition_id: null, right_transition_id: null, + groupped_scrubbers: null, })); // Merge: keep existing text items, replace non-text items with fetched assets setMediaBinItems((prev) => { const textItems = prev.filter((i) => i.mediaType === "text"); return [...textItems, ...items]; }); + console.log(`Loaded ${items.length} assets for project ${projectId || "default"}`); } catch (e) { console.error("Failed to load assets", e); } finally { @@ -233,12 +225,6 @@ export const useMediaBin = ( }; loadAssets(); }, [projectId]); - */ - - // Manually set loading to false since we're not loading assets - useEffect(() => { - setIsMediaLoading(false); - }, []); const handleAddMediaToBin = useCallback(async (file: File) => { const id = generateUUID(); @@ -278,92 +264,100 @@ export const useMediaBin = ( right_transition_id: null, groupped_scrubbers: null, }; - setMediaBinItems(prev => [...prev, newItem]); + setMediaBinItems((prev) => [...prev, newItem]); const formData = new FormData(); - formData.append('media', file); + formData.append("media", file); console.log("Uploading file to server..."); - const uploadResponse = await axios.post(apiUrl('/upload'), formData, { + // Use the new authenticated upload endpoint with project support + const uploadResponse = await axios.post(apiUrl("/api/assets/upload", false, true), formData, { + headers: { + "X-Media-Width": metadata.width.toString(), + "X-Media-Height": metadata.height.toString(), + "X-Media-Duration": (metadata.durationInSeconds || 0).toString(), + "X-Original-Name": file.name, + "X-Project-Id": projectId || "", + }, + withCredentials: true, // Include authentication cookies onUploadProgress: (progressEvent) => { if (progressEvent.total) { const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); console.log(`Upload progress: ${percentCompleted}%`); // Update upload progress in the media bin - setMediaBinItems(prev => - prev.map(item => - item.id === id - ? { ...item, uploadProgress: percentCompleted } - : item - ) + setMediaBinItems((prev) => + prev.map((item) => (item.id === id ? { ...item, uploadProgress: percentCompleted } : item)), ); } - } + }, }); const uploadResult = uploadResponse.data; console.log("Upload successful:", uploadResult); // Update item with successful upload result and remove progress tracking - setMediaBinItems(prev => - prev.map(item => + setMediaBinItems((prev) => + prev.map((item) => item.id === id ? { - ...item, - mediaUrlRemote: uploadResult.fullUrl, - isUploading: false, - uploadProgress: null - } - : item - ) + ...item, + id: uploadResult.asset.id, // Use the database-generated asset ID + mediaUrlRemote: uploadResult.asset.mediaUrlRemote, + isUploading: false, + uploadProgress: null, + } + : item, + ), ); - } catch (error) { console.error("Error adding media to bin:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error"; // Remove the failed item from media bin - setMediaBinItems(prev => prev.filter(item => item.id !== id)); + setMediaBinItems((prev) => prev.filter((item) => item.id !== id)); throw new Error(`Failed to add media: ${errorMessage}`); } }, []); - const handleAddTextToBin = useCallback(( - textContent: string, - fontSize: number, - fontFamily: string, - color: string, - textAlign: "left" | "center" | "right", - fontWeight: "normal" | "bold" - ) => { - const newItem: MediaBinItem = { - id: generateUUID(), - name: textContent, - mediaType: "text", - media_width: 0, - media_height: 0, - text: { - textContent, - fontSize, - fontFamily, - color, - textAlign, - fontWeight, - template: null, // for now, maybe we can also allow text to have a template (same ones from captions) - }, - mediaUrlLocal: null, - mediaUrlRemote: null, - durationInSeconds: 0, // interesting code. i wish i remembered why i did this. maybe there's a better way. - isUploading: false, - uploadProgress: null, - left_transition_id: null, - right_transition_id: null, - groupped_scrubbers: null, - }; - setMediaBinItems(prev => [...prev, newItem]); - }, []); + const handleAddTextToBin = useCallback( + ( + textContent: string, + fontSize: number, + fontFamily: string, + color: string, + textAlign: "left" | "center" | "right", + fontWeight: "normal" | "bold", + ) => { + const newItem: MediaBinItem = { + id: generateUUID(), + name: textContent, + mediaType: "text", + media_width: 0, + media_height: 0, + text: { + textContent, + fontSize, + fontFamily, + color, + textAlign, + fontWeight, + template: null, // for now, maybe we can also allow text to have a template (same ones from captions) + }, + mediaUrlLocal: null, + mediaUrlRemote: null, + durationInSeconds: 0, // interesting code. i wish i remembered why i did this. maybe there's a better way. + isUploading: false, + uploadProgress: null, + left_transition_id: null, + right_transition_id: null, + groupped_scrubbers: null, + }; + setMediaBinItems((prev) => [...prev, newItem]); + }, + [], + ); const getMediaBinItems = useCallback(() => mediaBinItems, [mediaBinItems]); @@ -380,50 +374,47 @@ export const useMediaBin = ( mediaUrlRemote: null, isUploading: false, uploadProgress: null, - }) + }), ), ]; }); }, []); - const handleDeleteMedia = useCallback(async (item: MediaBinItem) => { - try { - if (item.mediaType === "text" || item.mediaType === "groupped_scrubber") { - setMediaBinItems(prev => prev.filter(binItem => binItem.id !== item.id)); - - // Also remove any scrubbers from the timeline that use this media - if (handleDeleteScrubbersByMediaBinId) { - handleDeleteScrubbersByMediaBinId(item.id); + const handleDeleteMedia = useCallback( + async (item: MediaBinItem) => { + try { + // For text and grouped scrubbers, which are UI-only constructs, just remove them from the local state. + if (item.mediaType === "text" || item.mediaType === "groupped_scrubber") { + setMediaBinItems((prev) => prev.filter((binItem) => binItem.id !== item.id)); + if (handleDeleteScrubbersByMediaBinId) { + handleDeleteScrubbersByMediaBinId(item.id); + } + return; // Exit early as there's no backend asset to delete. } - if (!item.mediaUrlRemote) { - console.error("No remote URL found for media item"); - return; - } - } - // Call authenticated delete by asset id - const assetId = item.id; - const res = await fetch(apiUrl(`/api/assets/${assetId}`, false, true), { - method: "DELETE", - credentials: "include", - }); - if (res.ok) { - console.log(`Media deleted: ${item.name}`); - // Remove from media bin state - setMediaBinItems((prev) => - prev.filter((binItem) => binItem.id !== item.id) - ); - // Also remove any scrubbers from the timeline that use this media - if (handleDeleteScrubbersByMediaBinId) { - handleDeleteScrubbersByMediaBinId(item.id); + // For other media types, call the authenticated delete endpoint. + const assetId = item.id; + const res = await fetch(apiUrl(`/api/assets/${assetId}`, false, true), { + method: "DELETE", + credentials: "include", + }); + + if (res.ok) { + console.log(`Media deleted: ${item.name}`); + // On successful backend deletion, remove the item from the UI state. + setMediaBinItems((prev) => prev.filter((binItem) => binItem.id !== item.id)); + if (handleDeleteScrubbersByMediaBinId) { + handleDeleteScrubbersByMediaBinId(item.id); + } + } else { + console.error("Failed to delete media:", await res.text()); } - } else { - console.error("Failed to delete media:", await res.text()); + } catch (error) { + console.error("Error deleting media:", error); } - } catch (error) { - console.error("Error deleting media:", error); - } - }, [handleDeleteScrubbersByMediaBinId]); + }, + [handleDeleteScrubbersByMediaBinId], + ); const handleSplitAudio = useCallback(async (videoItem: MediaBinItem) => { if (videoItem.mediaType !== "video") { @@ -437,15 +428,12 @@ export const useMediaBin = ( } // Clone via authenticated API (server will copy within out/ and record) - const res = await fetch( - apiUrl(`/api/assets/${videoItem.id}/clone`, false, true), - { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ suffix: "(Audio)" }), - } - ); + const res = await fetch(apiUrl(`/api/assets/${videoItem.id}/clone`, false, true), { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ suffix: "(Audio)" }), + }); if (!res.ok) throw new Error("Failed to clone media file"); const cloneResult = await res.json(); @@ -471,9 +459,7 @@ export const useMediaBin = ( setMediaBinItems((prev) => [...prev, audioItem]); setContextMenu(null); // Close context menu after action - console.log( - `Audio split successful: ${videoItem.name} -> ${audioItem.name}` - ); + console.log(`Audio split successful: ${videoItem.name} -> ${audioItem.name}`); } catch (error) { console.error("Error splitting audio:", error); throw error; @@ -481,17 +467,14 @@ export const useMediaBin = ( }, []); // Handle right-click to show context menu - const handleContextMenu = useCallback( - (e: React.MouseEvent, item: MediaBinItem) => { - e.preventDefault(); - setContextMenu({ - x: e.clientX, - y: e.clientY, - item, - }); - }, - [] - ); + const handleContextMenu = useCallback((e: React.MouseEvent, item: MediaBinItem) => { + e.preventDefault(); + setContextMenu({ + x: e.clientX, + y: e.clientY, + item, + }); + }, []); // Handle context menu actions const handleDeleteFromContext = useCallback(async () => { @@ -534,7 +517,7 @@ export const useMediaBin = ( groupped_scrubbers: groupedScrubber.groupped_scrubbers, }; - setMediaBinItems(prev => [...prev, newItem]); + setMediaBinItems((prev) => [...prev, newItem]); console.log("Added grouped scrubber to media bin:", newItem.name); }, []); diff --git a/app/lib/timeline.store.ts b/app/lib/timeline.store.ts index 869ee5a..0a42d56 100644 --- a/app/lib/timeline.store.ts +++ b/app/lib/timeline.store.ts @@ -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; } @@ -44,23 +42,15 @@ function defaultTimeline(): TimelineState { }; } -export async function loadProjectState( - projectId: string -): Promise { +export async function loadProjectState(projectId: string): Promise { 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 @@ -70,10 +60,7 @@ export async function loadProjectState( } } -export async function saveProjectState( - projectId: string, - state: ProjectStateFile -): Promise { +export async function saveProjectState(projectId: string, state: ProjectStateFile): Promise { const file = getFilePath(projectId); await fs.promises.writeFile(file, JSON.stringify(state), "utf8"); } @@ -84,10 +71,7 @@ export async function loadTimeline(projectId: string): Promise { return state.timeline; } -export async function saveTimeline( - projectId: string, - timeline: TimelineState -): Promise { +export async function saveTimeline(projectId: string, timeline: TimelineState): Promise { const prev = await loadProjectState(projectId); await saveProjectState(projectId, { timeline, diff --git a/app/routes/api.assets.$.tsx b/app/routes/api.assets.$.tsx index 39ba3e4..f58f744 100644 --- a/app/routes/api.assets.$.tsx +++ b/app/routes/api.assets.$.tsx @@ -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"; @@ -15,21 +10,15 @@ async function requireUserId(request: Request): Promise { 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`, { @@ -62,15 +51,11 @@ async function requireUserId(request: Request): Promise { 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; } @@ -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, @@ -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; @@ -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"); @@ -199,14 +174,13 @@ 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, @@ -214,22 +188,16 @@ export async function action({ request }: { request: Request }) { 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, @@ -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" } }, ); } @@ -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)) { @@ -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, @@ -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" } }, ); } @@ -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); @@ -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" } }, ); } diff --git a/app/routes/privacy.tsx b/app/routes/privacy.tsx index 1b2a8cf..47973ce 100644 --- a/app/routes/privacy.tsx +++ b/app/routes/privacy.tsx @@ -18,6 +18,8 @@ import { KimuLogo } from "~/components/ui/KimuLogo"; import { GlowingEffect } from "~/components/ui/glowing-effect"; export default function Privacy() { + const lastUpdated = "30th August 2025"; + return (
{/* Hero / Masthead */} @@ -30,16 +32,13 @@ export default function Privacy() {
Privacy & Data Transparency
-

- Your data. Your rules. -

+

Your data. Your rules.

- Crystal-clear privacy with open-source transparency and explicit - data boundaries. + Crystal-clear privacy with open-source transparency and explicit data boundaries.

- Updated 30th August 2025 + Updated {lastUpdated} Version 2.0 @@ -50,31 +49,19 @@ export default function Privacy() { {/* Document Container */}
- + {/* Document Header */}
- - Kimu Privacy Policy - + Kimu Privacy Policy
-

- How we handle your data -

+

How we handle your data

- Kimu ("we," "our," or "us") is committed to protecting your - privacy. This Privacy Policy explains how we collect, use, - disclose, and safeguard your information when you use our video - editing application and related services. + Kimu ("we," "our," or "us") is committed to protecting your privacy. This Privacy Policy explains how we + collect, use, disclose, and safeguard your information when you use our video editing application and + related services.
@@ -83,40 +70,29 @@ export default function Privacy() {
{/* 1. Applicability */}
-

- 1. Applicability & Consent -

+

1. Applicability & Consent

- This Privacy Policy applies to our online services and is - valid for visitors and users of our website and web editor - with regards to information that they share with and/or - collect in Kimu. This policy does not apply to information - collected offline or via channels other than this website - and app. + This Privacy Policy applies to our online services and is valid for visitors and users of our + website and web editor with regards to information that they share with and/or collect in Kimu. This + policy does not apply to information collected offline or via channels other than this website and + app.

- By using our website or editor, you hereby consent to this - Privacy Policy and agree to its terms. If you have - additional questions or require more information, contact - us. + By using our website or editor, you hereby consent to this Privacy Policy and agree to its terms. If + you have additional questions or require more information, contact us.

{/* 2. Information Collection */}
-

- 2. Information We Collect -

+

2. Information We Collect

-

- 2.1 Personal Information -

+

2.1 Personal Information

- We collect the minimum required, and it will be clear at - the point of collection: + We collect the minimum required, and it will be clear at the point of collection:

  • Email address (for account access)
  • @@ -126,38 +102,23 @@ export default function Privacy() {
-

- 2.2 Video Content -

+

2.2 Video Content

- Projects can be local or{" "} - cloud: + Projects can be local or cloud:

    -
  • - Local projects keep media on your device (IndexedDB / - disk). -
  • -
  • - Cloud projects store media securely on our Hetzner VPS - for collaboration and access. -
  • -
  • - Project metadata (names, timelines, settings) is stored - in Supabase. -
  • +
  • Local projects keep media on your device (IndexedDB / disk).
  • +
  • Cloud projects store media securely on our Hetzner VPS for collaboration and access.
  • +
  • Project metadata (names, timelines, settings) is stored in Supabase.
-

- 2.3 Additional Details You May Provide -

+

2.3 Additional Details You May Provide

- If you contact us directly, we may receive your name, - email, phone number, and message contents. During account - registration or billing, we may request optional details - like company name or address. + If you contact us directly, we may receive your name, email, phone number, and message contents. + During account registration or billing, we may request optional details like company name or + address.

@@ -165,43 +126,31 @@ export default function Privacy() { {/* 3. Data Processing */}
-

- 3. How We Process Your Data -

+

3. How We Process Your Data

-

- 3.1 Local Processing -

+

3.1 Local Processing

- Editing and preview are real-time in your browser. For - local projects, media never leaves your device. + Editing and preview are real-time in your browser. For local projects, media never leaves your + device.

-

- 3.2 Account Management -

+

3.2 Account Management

- We use your email address solely for account - authentication, password recovery, and important service - notifications. + We use your email address solely for account authentication, password recovery, and important + service notifications.

-

- 3.3 Cloud Collaboration -

+

3.3 Cloud Collaboration

- Cloud projects are required for multiplayer. Assets are - stored securely and only accessible to members granted - access. + Cloud projects are required for multiplayer. Assets are stored securely and only accessible to + members granted access.

-

- 3.4 How We Use Information -

+

3.4 How We Use Information

  • Provide, operate, and maintain the service
  • Improve and expand features
  • @@ -216,24 +165,17 @@ export default function Privacy() { {/* 4. Data Storage */}
    -

    - 4. Data Storage and Security -

    +

    4. Data Storage and Security

    -

    - 4.1 Local Storage -

    +

    4.1 Local Storage

    - Project files, video assets, and editing history are - stored locally in your browser's IndexedDB. This data - remains under your control. + Project files, video assets, and editing history are stored locally in your browser's IndexedDB. + This data remains under your control.

    -

    - 4.2 Security Measures -

    +

    4.2 Security Measures

    • All server communications use HTTPS
    • Account data stored with industry best practices
    • @@ -241,12 +183,10 @@ export default function Privacy() {
    -

    - 4.3 Enforcement & Safety -

    +

    4.3 Enforcement & Safety

    - Uploads are private and secure. Assets violating Terms of - Service may be removed and accounts suspended. + Uploads are private and secure. Assets violating Terms of Service may be removed and accounts + suspended.

    @@ -254,113 +194,66 @@ export default function Privacy() { {/* 5. Third-Party Services */}
    -

    - 5. Third-Party Services -

    +

    5. Third-Party Services

    - +
    Hetzner VPS
    -

    - Secure hosting for services and media storage. -

    +

    Secure hosting for services and media storage.

    - +
    - - Google OAuth - + Google OAuth

    - Sign-in only. We receive your email and profile if you - consent. + Sign-in only. We receive your email and profile if you consent.

    - +
    Supabase
    -

    - Stores account preferences and project metadata. -

    +

    Stores account preferences and project metadata.

    - +
    - - Umami Analytics - + Umami Analytics
    -

    - Cookie-less, privacy-friendly usage analytics. -

    +

    Cookie-less, privacy-friendly usage analytics.

    {/* 6. Your Rights */}
    -

    - 6. Your Privacy Rights -

    +

    6. Your Privacy Rights

    You have complete control over your data. You can:

    • - - Delete your account - - : remove your account and all associated data anytime. + Delete your account: remove your account and all + associated data anytime.
    • - - Export your data - - : download your projects in a portable format. + Export your data: download your projects in a + portable format.
    • - - Clear local storage - - : remove all locally saved projects. + Clear local storage: remove all locally saved + projects.
    • - - Talk to a human - - : contact us anytime with privacy concerns. + Talk to a human: contact us anytime with privacy + concerns.
    @@ -368,13 +261,11 @@ export default function Privacy() { {/* Open Source */}
    -

    - 7. Open-Source Transparency -

    +

    7. Open-Source Transparency

    - Kimu is open-source. Inspect how data flows, audit changes, - or contribute. We practice transparent engineering: + Kimu is open-source. Inspect how data flows, audit changes, or contribute. We practice transparent + engineering:

    • Public repo, issues, and pull requests
    • @@ -385,8 +276,7 @@ export default function Privacy() { href="https://github.com/trykimu/videoeditor" target="_blank" rel="noreferrer" - className="inline-flex items-center gap-2 text-sm px-4 py-2 rounded-md border border-border/40 hover:bg-muted/10" - > + className="inline-flex items-center gap-2 text-sm px-4 py-2 rounded-md border border-border/40 hover:bg-muted/10"> View source on GitHub @@ -398,22 +288,19 @@ export default function Privacy() {

      8. Contact

      - Have questions or requests? Create a ticket in our Discord - or email us. + Have questions or requests? Create a ticket in our Discord or email us.

      @@ -422,18 +309,14 @@ export default function Privacy() { {/* Updates */}
      -

      - 9. Privacy Policy Changes -

      +

      9. Privacy Policy Changes

      - We may update this Privacy Policy from time to time. When we - do, we will publish an updated version and effective date at - the top of this page, unless another type of notice is - legally required. Your continued use of Kimu after any - change in this Privacy Policy will constitute your - acceptance of such change. + We may update this Privacy Policy from time to time. When we do, we will publish an updated version + and effective date at the top of this page, unless another type of notice is legally required. Your + continued use of Kimu after any change in this Privacy Policy will constitute your acceptance of + such change.

      @@ -443,16 +326,10 @@ export default function Privacy() { {/* Document Footer */}
      - - Last updated - {" "} - 30th August 2025 + Last updated{" "} + {lastUpdated}
      - + Return to Kimu
      diff --git a/app/utils/path-security.ts b/app/utils/path-security.ts new file mode 100644 index 0000000..bfdc239 --- /dev/null +++ b/app/utils/path-security.ts @@ -0,0 +1,144 @@ +import path from "path"; +import fs from "fs"; + +/** + * Utility functions for secure path operations + * Prevents path traversal attacks and ensures files stay within intended directories + */ + +/** + * Safely resolves a file path within a specified base directory + * @param baseDir - The base directory to confine files to (e.g., "out") + * @param filename - The filename to resolve + * @returns The resolved absolute path if safe, null if unsafe + */ +export function safeResolvePath(baseDir: string, filename: string): string | null { + try { + // Sanitize filename - remove any path traversal attempts + const sanitizedFilename = path.basename(filename); + + // Only allow alphanumeric, hyphens, underscores, dots, and timestamps + if (!/^[a-zA-Z0-9._-]+$/.test(sanitizedFilename)) { + return null; + } + + // Resolve the path and ensure it's within the base directory + const resolvedPath = path.resolve(baseDir, sanitizedFilename); + const baseDirResolved = path.resolve(baseDir); + + // Security check - ensure resolved path is within base directory + if (!resolvedPath.startsWith(baseDirResolved) || resolvedPath === baseDirResolved) { + return null; + } + + return resolvedPath; + } catch (error) { + return null; + } +} + +/** + * Safely resolves a file path within the "out" directory (common use case) + * @param filename - The filename to resolve + * @returns The resolved absolute path if safe, null if unsafe + */ +export function safeResolveOutPath(filename: string): string | null { + return safeResolvePath("out", filename); +} + +/** + * Validates if a filename is safe (no path traversal, valid characters) + * @param filename - The filename to validate + * @returns true if safe, false if unsafe + */ +export function isValidFilename(filename: string): boolean { + try { + const sanitizedFilename = path.basename(filename); + return /^[a-zA-Z0-9._-]+$/.test(sanitizedFilename); + } catch (error) { + return false; + } +} + +/** + * Sanitizes a filename by removing unsafe characters and path traversal attempts + * @param filename - The filename to sanitize + * @returns The sanitized filename, or null if too unsafe + */ +export function sanitizeFilename(filename: string): string | null { + try { + const sanitized = path.basename(filename); + + // Remove any remaining unsafe characters + const cleaned = sanitized.replace(/[^a-zA-Z0-9._-]/g, ""); + + // Return null if the filename becomes empty or too short + if (!cleaned || cleaned.length < 1) { + return null; + } + + return cleaned; + } catch (error) { + return null; + } +} + +/** + * Creates a safe filename with timestamp and optional suffix + * @param originalName - The original filename + * @param suffix - Optional suffix to add + * @returns A safe filename with timestamp + */ +export function createSafeFilename(originalName: string, suffix?: string): string { + const timestamp = Date.now(); + const extension = path.extname(originalName); + const nameWithoutExt = path.basename(originalName, extension); + + // Sanitize the base name + const sanitizedBase = sanitizeFilename(nameWithoutExt) || "file"; + + // Sanitize suffix if provided + const sanitizedSuffix = suffix ? sanitizeFilename(suffix) || "" : ""; + + // Combine parts + const parts = [sanitizedBase]; + if (sanitizedSuffix) { + parts.push(sanitizedSuffix); + } + parts.push(timestamp.toString()); + + return `${parts.join("_")}${extension}`; +} + +/** + * Ensures a directory exists, creating it if necessary + * @param dirPath - The directory path to ensure + * @returns true if successful, false if failed + */ +export function ensureDirectoryExists(dirPath: string): boolean { + try { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + return true; + } catch (error) { + return false; + } +} + +/** + * Checks if a file path is within a specified base directory + * @param filePath - The file path to check + * @param baseDir - The base directory to check against + * @returns true if the file is within the base directory, false otherwise + */ +export function isPathWithinDirectory(filePath: string, baseDir: string): boolean { + try { + const resolvedFilePath = path.resolve(filePath); + const resolvedBaseDir = path.resolve(baseDir); + + return resolvedFilePath.startsWith(resolvedBaseDir) && resolvedFilePath !== resolvedBaseDir; + } catch (error) { + return false; + } +} diff --git a/app/videorender/videorender.ts b/app/videorender/videorender.ts index 3d65fc4..e1efdad 100644 --- a/app/videorender/videorender.ts +++ b/app/videorender/videorender.ts @@ -1,18 +1,19 @@ -import { bundle } from '@remotion/bundler'; -import { renderMedia, selectComposition } from '@remotion/renderer'; -import path from 'path'; -import express, { type Request, type Response } from 'express'; -import cors from 'cors'; -import fs from 'fs'; -import multer from 'multer'; +import { bundle } from "@remotion/bundler"; +import { renderMedia, selectComposition } from "@remotion/renderer"; +import path from "path"; +import express, { type Request, type Response } from "express"; +import cors from "cors"; +import fs from "fs"; +import multer from "multer"; +import { safeResolveOutPath, createSafeFilename, ensureDirectoryExists, isValidFilename } from "~/utils/path-security"; // The composition you want to render -const compositionId = 'TimelineComposition'; +const compositionId = "TimelineComposition"; // You only have to create a bundle once, and you may reuse it // for multiple renders that you can parametrize using input props. const bundleLocation = await bundle({ - entryPoint: path.resolve('./app/videorender/index.ts'), + entryPoint: path.resolve("./app/videorender/index.ts"), // If you have a webpack override in remotion.config.ts, pass it here as well. webpackOverride: (config) => config, }); @@ -20,38 +21,31 @@ const bundleLocation = await bundle({ console.log(bundleLocation); // Ensure output directory exists -if (!fs.existsSync('out')) { - fs.mkdirSync('out', { recursive: true }); -} +ensureDirectoryExists("out"); const app = express(); app.use(express.json()); app.use(cors()); // Static file serving for the out/ directory -app.use('/media', express.static(path.resolve('out'), { - dotfiles: 'deny', - index: false -})); +// REMOVED: Direct media serving - all asset access must go through authenticated API +// app.use('/media', express.static(path.resolve('out'), { +// dotfiles: 'deny', +// index: false +// })); // Configure multer for file uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { // Ensure out directory exists - if (!fs.existsSync('out')) { - fs.mkdirSync('out', { recursive: true }); - } - cb(null, 'out/'); + ensureDirectoryExists("out"); + cb(null, "out/"); }, filename: (req, file, cb) => { - // Generate unique filename with timestamp - const timestamp = Date.now(); - const originalName = file.originalname; - const extension = path.extname(originalName); - const nameWithoutExt = path.basename(originalName, extension); - const uniqueName = `${nameWithoutExt}_${timestamp}${extension}`; + // Generate unique filename with timestamp using utility function + const uniqueName = createSafeFilename(file.originalname); cb(null, uniqueName); - } + }, }); const upload = multer({ @@ -65,49 +59,54 @@ const upload = multer({ if (allowedTypes.test(file.originalname)) { cb(null, true); } else { - cb(new Error('Invalid file type. Only media files are allowed.')); + cb(new Error("Invalid file type. Only media files are allowed.")); } - } + }, }); -// List files in out/ directory -app.get('/media', (req: Request, res: Response): void => { +// Internal media serving for Remotion composition only (Docker network access) +app.get("/media/:filename", (req: Request, res: Response): void => { try { - const outDir = path.resolve('out'); - if (!fs.existsSync(outDir)) { - res.json({ files: [] }); + const filename = req.params.filename; + const decodedFilename = decodeURIComponent(filename); + + // Safely resolve the file path + const filePath = safeResolveOutPath(decodedFilename); + if (!filePath) { + res.status(403).json({ error: "Invalid filename" }); return; } - const files = fs.readdirSync(outDir).map(filename => { - const filePath = path.join(outDir, filename); - const stats = fs.statSync(filePath); - return { - name: filename, - url: `/media/${encodeURIComponent(filename)}`, - size: stats.size, - modified: stats.mtime, - isDirectory: stats.isDirectory() - }; - }).filter(file => !file.isDirectory); // Only show files, not directories + if (!fs.existsSync(filePath)) { + res.status(404).json({ error: "File not found" }); + return; + } - res.json({ files }); + // Serve the file for internal use + res.sendFile(filePath); } catch (error) { - console.error('Error listing files:', error); - res.status(500).json({ error: 'Failed to list files' }); + console.error("Error serving media file:", error); + res.status(500).json({ error: "Failed to serve file" }); } }); // File upload endpoint -app.post('/upload', upload.single('media'), (req: Request, res: Response): void => { +app.post("/upload", upload.single("media"), (req: Request, res: Response): void => { try { if (!req.file) { - res.status(400).json({ error: 'No file uploaded' }); + res.status(400).json({ error: "No file uploaded" }); + return; + } + + // Validate the uploaded file path is safe + const safePath = safeResolveOutPath(req.file.filename); + if (!safePath) { + res.status(400).json({ error: "Invalid filename generated" }); return; } const fileUrl = `/media/${encodeURIComponent(req.file.filename)}`; - const fullUrl = `http://localhost:${port}${fileUrl}`; // Direct backend URL for Remotion + const fullUrl = `http://localhost:${port}${fileUrl}`; // For internal Remotion composition only console.log(`๐Ÿ“ File uploaded: ${req.file.originalname} -> ${req.file.filename}`); @@ -118,143 +117,152 @@ app.post('/upload', upload.single('media'), (req: Request, res: Response): void url: fileUrl, fullUrl: fullUrl, size: req.file.size, - path: req.file.path + path: req.file.path, }); } catch (error) { - console.error('Upload error:', error); - res.status(500).json({ error: 'File upload failed' }); + console.error("Upload error:", error); + res.status(500).json({ error: "File upload failed" }); } }); // Bulk file upload endpoint -app.post('/upload-multiple', upload.array('media', 10), (req: Request, res: Response): void => { +app.post("/upload-multiple", upload.array("media", 10), (req: Request, res: Response): void => { try { if (!req.files || req.files.length === 0) { - res.status(400).json({ error: 'No files uploaded' }); + res.status(400).json({ error: "No files uploaded" }); return; } - const uploadedFiles = (req.files as Express.Multer.File[]).map(file => ({ - filename: file.filename, - originalName: file.originalname, - url: `/media/${encodeURIComponent(file.filename)}`, - fullUrl: `http://localhost:${port}/media/${encodeURIComponent(file.filename)}`, // Direct backend URL for Remotion - size: file.size, - path: file.path - })); + const uploadedFiles = (req.files as Express.Multer.File[]).map((file) => { + // Validate each uploaded file path is safe + const safePath = safeResolveOutPath(file.filename); + if (!safePath) { + throw new Error(`Invalid filename generated: ${file.filename}`); + } + + return { + filename: file.filename, + originalName: file.originalname, + url: `/media/${encodeURIComponent(file.filename)}`, + fullUrl: `http://localhost:${port}/media/${encodeURIComponent(file.filename)}`, // For internal Remotion composition only + size: file.size, + path: file.path, + }; + }); console.log(`๐Ÿ“ ${uploadedFiles.length} files uploaded`); res.json({ success: true, - files: uploadedFiles + files: uploadedFiles, }); } catch (error) { - console.error('Bulk upload error:', error); - res.status(500).json({ error: 'Bulk file upload failed' }); + console.error("Bulk upload error:", error); + res.status(500).json({ error: "Bulk file upload failed" }); } }); // Clone/copy media file endpoint -app.post('/clone-media', (req: Request, res: Response): void => { +app.post("/clone-media", (req: Request, res: Response): void => { try { const { filename, originalName, suffix } = req.body; - + if (!filename) { - res.status(400).json({ error: 'Filename is required' }); + res.status(400).json({ error: "Filename is required" }); return; } - - const decodedFilename = decodeURIComponent(filename); - const sourcePath = path.resolve('out', decodedFilename); - - // Security check - ensure source file is in the out directory - if (!sourcePath.startsWith(path.resolve('out'))) { - res.status(403).json({ error: 'Access denied' }); + + // Safely resolve the source file path + const sourcePath = safeResolveOutPath(filename); + if (!sourcePath) { + res.status(403).json({ error: "Invalid filename" }); return; } - + if (!fs.existsSync(sourcePath)) { - res.status(404).json({ error: 'Source file not found' }); + res.status(404).json({ error: "Source file not found" }); return; } - + // Generate new filename with timestamp and suffix - const timestamp = Date.now(); - const sourceExtension = path.extname(decodedFilename); - const sourceNameWithoutExt = path.basename(decodedFilename, sourceExtension); - const newFilename = `${sourceNameWithoutExt}_${suffix}_${timestamp}${sourceExtension}`; - const destPath = path.resolve('out', newFilename); - + const newFilename = createSafeFilename(filename, suffix); + + // Safely resolve the destination path + const destPath = safeResolveOutPath(newFilename); + if (!destPath) { + res.status(400).json({ error: "Invalid destination filename generated" }); + return; + } + // Copy the file fs.copyFileSync(sourcePath, destPath); - + const fileStats = fs.statSync(destPath); const fileUrl = `/media/${encodeURIComponent(newFilename)}`; - const fullUrl = `http://localhost:${port}${fileUrl}`; - - console.log(`๐Ÿ“‹ File cloned: ${decodedFilename} -> ${newFilename}`); - + const fullUrl = `http://localhost:${port}${fileUrl}`; // For internal Remotion composition only + + console.log(`๐Ÿ“‹ File cloned: ${filename} -> ${newFilename}`); + res.json({ success: true, filename: newFilename, - originalName: originalName || decodedFilename, + originalName: originalName || filename, url: fileUrl, fullUrl: fullUrl, size: fileStats.size, - path: destPath + path: destPath, }); } catch (error) { - console.error('Clone error:', error); - res.status(500).json({ error: 'Failed to clone file' }); + console.error("Clone error:", error); + res.status(500).json({ error: "Failed to clone file" }); } }); // Delete file endpoint -app.delete('/media/:filename', (req: Request, res: Response): void => { +app.delete("/media/:filename", (req: Request, res: Response): void => { try { - const filename = decodeURIComponent(req.params.filename); - const filePath = path.resolve('out', filename); - - // Security check - ensure file is in the out directory - if (!filePath.startsWith(path.resolve('out'))) { - res.status(403).json({ error: 'Access denied' }); + const filename = req.params.filename; + + // Safely resolve the file path + const filePath = safeResolveOutPath(filename); + if (!filePath) { + res.status(403).json({ error: "Invalid filename" }); return; } - + if (!fs.existsSync(filePath)) { - res.status(404).json({ error: 'File not found' }); + res.status(404).json({ error: "File not found" }); return; } - + fs.unlinkSync(filePath); console.log(`๐Ÿ—‘๏ธ File deleted: ${filename}`); - - res.json({ - success: true, - message: `File ${filename} deleted successfully` + + res.json({ + success: true, + message: `File ${filename} deleted successfully`, }); } catch (error) { - console.error('Delete error:', error); - res.status(500).json({ error: 'Failed to delete file' }); + console.error("Delete error:", error); + res.status(500).json({ error: "Failed to delete file" }); } }); // Health check endpoint to monitor system resources -app.get('/health', (req, res) => { +app.get("/health", (req, res) => { const used = process.memoryUsage(); res.json({ - status: 'ok', + status: "ok", memory: { rss: `${Math.round(used.rss / 1024 / 1024)} MB`, heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`, heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`, }, - uptime: `${Math.round(process.uptime())} seconds` + uptime: `${Math.round(process.uptime())} seconds`, }); }); -app.post('/render', async (req, res) => { +app.post("/render", async (req, res) => { try { // Get input props from POST body const inputProps = { @@ -282,52 +290,60 @@ app.post('/render', async (req, res) => { await renderMedia({ composition, serveUrl: bundleLocation, - codec: 'h264', + codec: "h264", outputLocation: `out/${compositionId}.mp4`, inputProps, // Optimized settings for server hardware concurrency: 3, // Use 3 cores, leave 1 for system verbose: true, - logLevel: 'info', // More detailed logging for server monitoring + logLevel: "info", // More detailed logging for server monitoring // Balanced encoding settings for server performance ffmpegOverride: ({ args }) => { return [ ...args, - '-preset', 'fast', // Good balance of speed and quality - '-crf', '28', // Better quality than ultrafast setting - '-threads', '3', // Use 3 threads for encoding - '-tune', 'film', // Better quality for general content - '-x264-params', 'ref=3:me=hex:subme=6:trellis=1', // Better quality settings - '-g', '30', // Standard keyframe interval - '-bf', '2', // Allow some B-frames for better compression - '-maxrate', '5M', // Limit bitrate to prevent memory issues - '-bufsize', '10M', // Buffer size for rate control + "-preset", + "fast", // Good balance of speed and quality + "-crf", + "28", // Better quality than ultrafast setting + "-threads", + "3", // Use 3 threads for encoding + "-tune", + "film", // Better quality for general content + "-x264-params", + "ref=3:me=hex:subme=6:trellis=1", // Better quality settings + "-g", + "30", // Standard keyframe interval + "-bf", + "2", // Allow some B-frames for better compression + "-maxrate", + "5M", // Limit bitrate to prevent memory issues + "-bufsize", + "10M", // Buffer size for rate control ]; }, timeoutInMilliseconds: 900000, // 15 minute timeout for longer videos }); - console.log('โœ… Render completed successfully'); + console.log("โœ… Render completed successfully"); res.sendFile(path.resolve(`out/${compositionId}.mp4`)); - } catch (err) { - console.error('โŒ Render failed:', err); + console.error("โŒ Render failed:", err); // Clean up failed renders try { const outputPath = `out/${compositionId}.mp4`; if (fs.existsSync(outputPath)) { fs.unlinkSync(outputPath); - console.log('๐Ÿงน Cleaned up partial file'); + console.log("๐Ÿงน Cleaned up partial file"); } } catch (cleanupErr) { - console.warn('โš ๏ธ Could not clean up:', cleanupErr); + console.warn("โš ๏ธ Could not clean up:", cleanupErr); } res.status(500).json({ - error: 'Video rendering failed', - message: 'Your laptop might be under heavy load. Try closing other apps and rendering again.', - tip: 'Videos are limited to 5 seconds at half resolution for performance.' + error: "Video rendering failed", + message: "Your laptop might be under heavy load. Try closing other apps and rendering again.", + tip: "Videos are limited to 5 seconds at half resolution for performance.", }); } }); @@ -347,7 +363,5 @@ app.listen(port, () => { console.log(` - Balanced quality/speed encoding`); console.log(` - Full resolution rendering`); console.log(` - 15-minute timeout for longer videos`); - console.log(`๐Ÿ“‚ Media files are served from: ${path.resolve('out')}`); + console.log(`๐Ÿ“‚ Media files are served from: ${path.resolve("out")}`); }); - - diff --git a/docker-compose.yml b/docker-compose.yml index f0c0cb9..7bf784d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: - "443:443" volumes: - ./nginx.conf:/etc/nginx/nginx.conf - - /etc/letsencrypt:/etc/letsencrypt:ro # Mount certs read-only + - /etc/letsencrypt:/etc/letsencrypt:ro # Mount certs read-only depends_on: - frontend - backend @@ -29,9 +29,9 @@ services: AUTH_COOKIE_DOMAIN: trykimu.com NODE_ENV: production HOST: 0.0.0.0 - PORT: 3000 + PORT: 5173 # ports: - # - "3000:3000" + # - "5173:5173" depends_on: - backend @@ -61,4 +61,4 @@ services: build: context: ./backend dockerfile: Dockerfile - container_name: videoeditor-fastapi \ No newline at end of file + container_name: videoeditor-fastapi diff --git a/nginx.conf b/nginx.conf index 5548909..ba8bec9 100644 --- a/nginx.conf +++ b/nginx.conf @@ -61,6 +61,24 @@ http { proxy_request_buffering off; } + # trykimu.com/api/assets โ†’ http://frontend/api/assets (authenticated asset access) + location /api/assets { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 900s; + proxy_send_timeout 900s; + proxy_request_buffering off; + client_max_body_size 500M; + } + + # Block direct access to /media/* - all asset access must go through authenticated API + location /media { + return 403; + } + # trykimu.com/learn โ†’ http://frontend/learn location / { proxy_pass http://frontend;