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
24 changes: 15 additions & 9 deletions app/routes/dashboard/-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,21 @@ import { prewarmTeam } from "./-team.data";
import { useVideoUploadManager } from "./-useVideoUploadManager";
import { DashboardUploadProvider } from "@/lib/dashboardUploadContext";

const VIDEO_FILE_EXTENSIONS = /\.(mp4|mov|m4v|webm|avi|mkv)$/i;
const SUPPORTED_FILE_EXTENSIONS = /\.(mp4|mov|m4v|webm|avi|mkv|docx|wav|aup3)$/i;

function isVideoFile(file: File) {
return file.type.startsWith("video/") || VIDEO_FILE_EXTENSIONS.test(file.name);
function isSupportedFile(file: File) {
return (
file.type.startsWith("video/") ||
file.type.startsWith("audio/") ||
file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
file.type === "application/octet-stream" ||
SUPPORTED_FILE_EXTENSIONS.test(file.name)
);
}
Comment on lines +35 to 45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mismatch between client-side and server-side allowed file types.

The client-side regex includes .avi and .m4v extensions, but their MIME types (video/x-msvideo and video/x-m4v) are not in ALLOWED_UPLOAD_CONTENT_TYPES in convex/videoActions.ts. Additionally, isSupportedFile accepts any type.startsWith("video/"), which is broader than the server-side validation.

This allows users to select files that will be rejected server-side with "Unsupported file format" errors after upload completes.

Proposed fix - align client-side filtering with server-side
-const SUPPORTED_FILE_EXTENSIONS = /\.(mp4|mov|m4v|webm|avi|mkv|docx|wav|aup3)$/i;
+const SUPPORTED_FILE_EXTENSIONS = /\.(mp4|mov|webm|mkv|docx|wav|aup3)$/i;
+
+const SUPPORTED_VIDEO_TYPES = new Set([
+  "video/mp4",
+  "video/quicktime",
+  "video/webm",
+  "video/x-matroska",
+]);
 
 function isSupportedFile(file: File) {
   return (
-    file.type.startsWith("video/") ||
+    SUPPORTED_VIDEO_TYPES.has(file.type) ||
     file.type.startsWith("audio/") ||
     file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
     file.type === "application/octet-stream" ||
     SUPPORTED_FILE_EXTENSIONS.test(file.name)
   );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const SUPPORTED_FILE_EXTENSIONS = /\.(mp4|mov|m4v|webm|avi|mkv|docx|wav|aup3)$/i;
function isVideoFile(file: File) {
return file.type.startsWith("video/") || VIDEO_FILE_EXTENSIONS.test(file.name);
function isSupportedFile(file: File) {
return (
file.type.startsWith("video/") ||
file.type.startsWith("audio/") ||
file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
file.type === "application/octet-stream" ||
SUPPORTED_FILE_EXTENSIONS.test(file.name)
);
}
const SUPPORTED_FILE_EXTENSIONS = /\.(mp4|mov|webm|mkv|docx|wav|aup3)$/i;
const SUPPORTED_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-matroska",
]);
function isSupportedFile(file: File) {
return (
SUPPORTED_VIDEO_TYPES.has(file.type) ||
file.type.startsWith("audio/") ||
file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
file.type === "application/octet-stream" ||
SUPPORTED_FILE_EXTENSIONS.test(file.name)
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/dashboard/-layout.tsx` around lines 35 - 45, The client-side file
filter (isSupportedFile and SUPPORTED_FILE_EXTENSIONS) is broader than the
server's ALLOWED_UPLOAD_CONTENT_TYPES in convex/videoActions.ts, causing
selectable files to be rejected server-side; update isSupportedFile to mirror
server validation by either: 1) replacing the loose
file.type.startsWith("video/") check with an explicit whitelist of MIME types
that matches ALLOWED_UPLOAD_CONTENT_TYPES (include video/x-msvideo and
video/x-m4v if you want .avi/.m4v allowed), or 2) remove .avi/.m4v from
SUPPORTED_FILE_EXTENSIONS and restrict accepted file.type values to only those
present in ALLOWED_UPLOAD_CONTENT_TYPES so client-side selection and server-side
validation are consistent; reference isSupportedFile, SUPPORTED_FILE_EXTENSIONS,
and ALLOWED_UPLOAD_CONTENT_TYPES when making the change.


function getVideoFiles(files: FileList | null) {
function getSupportedFiles(files: FileList | null) {
if (!files) return [];
return Array.from(files).filter(isVideoFile);
return Array.from(files).filter(isSupportedFile);
}

function dragEventHasFiles(event: DragEvent) {
Expand Down Expand Up @@ -90,7 +96,7 @@ export default function DashboardLayout() {

const requestUpload = useCallback(
(inputFiles: File[], preferredProjectId?: Id<"projects">) => {
const files = inputFiles.filter(isVideoFile);
const files = inputFiles.filter(isSupportedFile);
if (files.length === 0) return;

if (preferredProjectId) {
Expand Down Expand Up @@ -170,7 +176,7 @@ export default function DashboardLayout() {
dragDepthRef.current = 0;
setIsGlobalDragActive(false);

const files = getVideoFiles(event.dataTransfer?.files ?? null);
const files = getSupportedFiles(event.dataTransfer?.files ?? null);
if (files.length === 0) return;
requestUpload(files);
};
Expand Down Expand Up @@ -249,7 +255,7 @@ export default function DashboardLayout() {
<div className="absolute inset-0 bg-[#1a1a1a]/20" />
<div className="absolute inset-4 border-4 border-dashed border-[#2d5a2d] bg-[#2d5a2d]/10 flex items-center justify-center">
<p className="border-2 border-[#1a1a1a] bg-[#f0f0e8] px-4 py-2 text-sm font-bold text-[#1a1a1a]">
Drop videos to upload
Drop files to upload
</p>
</div>
</div>
Expand Down Expand Up @@ -278,7 +284,7 @@ export default function DashboardLayout() {
<DialogHeader>
<DialogTitle>Choose a project</DialogTitle>
<DialogDescription>
{pendingFiles?.length ? `Upload ${pendingFiles.length} video${pendingFiles.length > 1 ? "s" : ""} to:` : "Pick a project to start uploading."}
{pendingFiles?.length ? `Upload ${pendingFiles.length} file${pendingFiles.length > 1 ? "s" : ""} to:` : "Pick a project to start uploading."}
</DialogDescription>
</DialogHeader>
{uploadTargets === undefined ? (
Expand Down
18 changes: 9 additions & 9 deletions app/routes/dashboard/-useVideoUploadManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function useVideoUploadManager() {
projectId,
title,
fileSize: file.size,
contentType: file.type || "video/mp4",
contentType: file.type || "application/octet-stream",
});

setUploads((prev) =>
Expand All @@ -72,7 +72,7 @@ export function useVideoUploadManager() {
videoId: createdVideoId,
filename: file.name,
fileSize: file.size,
contentType: file.type || "video/mp4",
contentType: file.type || "application/octet-stream",
});

await new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -100,7 +100,7 @@ export function useVideoUploadManager() {
const avgSpeed =
recentSpeeds.length > 0
? recentSpeeds.reduce((sum, speed) => sum + speed, 0) /
recentSpeeds.length
recentSpeeds.length
: 0;
const remaining = event.total - event.loaded;
const eta = avgSpeed > 0 ? Math.ceil(remaining / avgSpeed) : null;
Expand All @@ -109,11 +109,11 @@ export function useVideoUploadManager() {
prev.map((upload) =>
upload.id === uploadId
? {
...upload,
progress: percentage,
bytesPerSecond: avgSpeed,
estimatedSecondsRemaining: eta,
}
...upload,
progress: percentage,
bytesPerSecond: avgSpeed,
estimatedSecondsRemaining: eta,
}
: upload,
),
);
Expand All @@ -140,7 +140,7 @@ export function useVideoUploadManager() {
});

xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", file.type || "video/mp4");
xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");
xhr.send(file);
});

Expand Down
64 changes: 44 additions & 20 deletions convex/videoActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,18 @@ import { BUCKET_NAME, getS3Client } from "./s3";
const GIBIBYTE = 1024 ** 3;
const MAX_PRESIGNED_PUT_FILE_SIZE_BYTES = 5 * GIBIBYTE;
const ALLOWED_UPLOAD_CONTENT_TYPES = new Set([
// Video
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-matroska",
// Documents
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
// Audio
"audio/wav",
"audio/x-wav",
// Binary / generic (covers .aup3 — Audacity project files)
"application/octet-stream",
]);

function getExtensionFromKey(key: string, fallback = "mp4") {
Expand Down Expand Up @@ -115,18 +123,24 @@ function isAllowedUploadContentType(contentType: string): boolean {
return ALLOWED_UPLOAD_CONTENT_TYPES.has(contentType);
}

function isVideoContentType(contentType: string): boolean {
return contentType.startsWith("video/");
}

function validateUploadRequestOrThrow(args: { fileSize: number; contentType: string }) {
if (!Number.isFinite(args.fileSize) || args.fileSize <= 0) {
throw new Error("Video file size must be greater than zero.");
throw new Error("File size must be greater than zero.");
}

if (args.fileSize > MAX_PRESIGNED_PUT_FILE_SIZE_BYTES) {
throw new Error("Video file is too large for direct upload.");
throw new Error("File is too large for direct upload.");
}

const normalizedContentType = normalizeContentType(args.contentType);
if (!isAllowedUploadContentType(normalizedContentType)) {
throw new Error("Unsupported video format. Allowed: mp4, mov, webm, mkv.");
throw new Error(
"Unsupported file format. Allowed: mp4, mov, webm, mkv, docx, wav, aup3.",
);
}

return normalizedContentType;
Expand All @@ -138,9 +152,9 @@ function shouldDeleteUploadedObjectOnFailure(error: unknown): boolean {
}

return (
error.message.includes("Unsupported video format") ||
error.message.includes("Video file is too large") ||
error.message.includes("Uploaded video file not found") ||
error.message.includes("Unsupported file format") ||
error.message.includes("File is too large") ||
error.message.includes("Uploaded file not found") ||
error.message.includes("Storage limit reached")
);
}
Expand Down Expand Up @@ -272,18 +286,20 @@ export const markUploadComplete = action({
!Number.isFinite(contentLengthRaw) ||
contentLengthRaw <= 0
) {
throw new Error("Uploaded video file not found or empty.");
throw new Error("Uploaded file not found or empty.");
}
const contentLength = contentLengthRaw;
if (contentLength > MAX_PRESIGNED_PUT_FILE_SIZE_BYTES) {
throw new Error("Video file is too large for direct upload.");
throw new Error("File is too large for direct upload.");
}

const normalizedContentType = normalizeContentType(
head.ContentType ?? video.contentType,
);
if (!isAllowedUploadContentType(normalizedContentType)) {
throw new Error("Unsupported video format. Allowed: mp4, mov, webm, mkv.");
throw new Error(
"Unsupported file format. Allowed: mp4, mov, webm, mkv, docx, wav, aup3.",
);
}

await ctx.runMutation(internal.videos.reconcileUploadedObjectMetadata, {
Expand All @@ -292,18 +308,26 @@ export const markUploadComplete = action({
contentType: normalizedContentType,
});

await ctx.runMutation(internal.videos.markAsProcessing, {
videoId: args.videoId,
});
if (isVideoContentType(normalizedContentType)) {
// ── Video path: send to Mux for transcoding ──
await ctx.runMutation(internal.videos.markAsProcessing, {
videoId: args.videoId,
});

const ingestUrl = await buildSignedBucketObjectUrl(video.s3Key, {
expiresIn: 60 * 60 * 24,
});
const asset = await createMuxAssetFromInputUrl(args.videoId, ingestUrl);
if (asset.id) {
await ctx.runMutation(internal.videos.setMuxAssetReference, {
const ingestUrl = await buildSignedBucketObjectUrl(video.s3Key, {
expiresIn: 60 * 60 * 24,
});
const asset = await createMuxAssetFromInputUrl(args.videoId, ingestUrl);
if (asset.id) {
await ctx.runMutation(internal.videos.setMuxAssetReference, {
videoId: args.videoId,
muxAssetId: asset.id,
});
}
} else {
// ── Non-video path: skip Mux, mark ready immediately ──
await ctx.runMutation(internal.videos.markFileAsReady, {
videoId: args.videoId,
muxAssetId: asset.id,
});
}
} catch (error) {
Expand All @@ -325,7 +349,7 @@ export const markUploadComplete = action({
const uploadError =
shouldDeleteObject && error instanceof Error
? error.message
: "Mux ingest failed after upload.";
: "Upload processing failed.";
await ctx.runMutation(internal.videos.markAsFailed, {
videoId: args.videoId,
uploadError,
Expand Down
13 changes: 13 additions & 0 deletions convex/videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,19 @@ export const markAsReady = internalMutation({
},
});

/** Mark a non-video file as ready (no Mux processing required). */
export const markFileAsReady = internalMutation({
args: {
videoId: v.id("videos"),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.videoId, {
uploadError: undefined,
status: "ready",
});
},
});

export const markAsFailed = internalMutation({
args: {
videoId: v.id("videos"),
Expand Down
10 changes: 4 additions & 6 deletions src/components/upload/DropZone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ export function DropZone({ onFilesSelected, disabled, className }: DropZoneProps

if (disabled) return;

const files = Array.from(e.dataTransfer.files).filter((file) =>
file.type.startsWith("video/")
);
const files = Array.from(e.dataTransfer.files);

if (files.length > 0) {
onFilesSelected(files);
Expand Down Expand Up @@ -71,7 +69,7 @@ export function DropZone({ onFilesSelected, disabled, className }: DropZoneProps
>
<input
type="file"
accept="video/*"
accept="video/*,.docx,.wav,.aup3"
multiple
onChange={handleChange}
disabled={disabled}
Expand All @@ -90,10 +88,10 @@ export function DropZone({ onFilesSelected, disabled, className }: DropZoneProps
</div>
<div>
<p className="font-bold text-[#1a1a1a]">
{isDragActive ? "Drop to upload" : "Drop videos or click to upload"}
{isDragActive ? "Drop to upload" : "Drop files or click to upload"}
</p>
<p className="text-sm text-[#888] mt-1">
MP4, MOV, WebM supported
Video, audio, and documents supported
</p>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/upload/UploadButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function UploadButton({
<input
ref={inputRef}
type="file"
accept="video/*"
accept="video/*,.docx,.wav,.aup3"
multiple
onChange={handleChange}
className="hidden"
Expand Down