mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: enable S3 video uploads and update related configurations
- Added support for self-hosted S3 video uploads with new environment variables: OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_MAX_VIDEO_UPLOAD_BYTES. - Updated .env.example and .env.docker.example to reflect new configuration options. - Enhanced Content Security Policy to include origins for S3-compatible storage. - Updated dependencies for AWS SDK to support new features. - Refactored upload logic to accommodate both Bunny and S3 upload providers. - Updated documentation to clarify the usage of direct uploads and S3 configurations. - Closes #11
This commit is contained in:
@@ -37,6 +37,9 @@ const MIME_ALIASES: Record<string, string> = {
|
||||
'audio/x-pn-wav': 'audio/wav',
|
||||
'audio/mp3': 'audio/mpeg',
|
||||
'audio/x-mpeg': 'audio/mpeg',
|
||||
// Some browsers report MediaRecorder audio-only blobs as video/* containers.
|
||||
'video/webm': 'audio/webm',
|
||||
'video/mp4': 'audio/mp4',
|
||||
};
|
||||
|
||||
// Map canonical MIME to fallback file extension
|
||||
@@ -231,7 +234,8 @@ export async function POST(request: NextRequest) {
|
||||
await releaseStorageReservation(reservationId);
|
||||
return apiErrors.badRequest('File content does not match an audio format');
|
||||
}
|
||||
if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) {
|
||||
const hasValidMagicBytes = hasValidAudioMagicBytes(buffer.slice(0, 16), contentType);
|
||||
if (!hasValidMagicBytes) {
|
||||
await releaseStorageReservation(reservationId);
|
||||
return apiErrors.badRequest('File content does not match the declared audio format');
|
||||
}
|
||||
|
||||
@@ -48,23 +48,43 @@ export async function GET(
|
||||
projectId: true,
|
||||
project: { select: projectSelect },
|
||||
} as const;
|
||||
const [comment, videoAsset, session] = await Promise.all([
|
||||
db.comment.findFirst({
|
||||
const [comments, videoAssets, videoVersions, session] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: { imageUrl },
|
||||
take: 2,
|
||||
select: {
|
||||
version: {
|
||||
select: { video: { select: videoSelect } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.videoAsset.findFirst({
|
||||
db.videoAsset.findMany({
|
||||
where: { sourceUrl: imageUrl },
|
||||
take: 2,
|
||||
select: { video: { select: videoSelect } },
|
||||
}),
|
||||
db.videoVersion.findMany({
|
||||
where: { thumbnailUrl: imageUrl },
|
||||
take: 2,
|
||||
select: { video: { select: videoSelect } },
|
||||
}),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
const video = comment?.version?.video ?? videoAsset?.video ?? null;
|
||||
const uniqueVideos = new Map<string, (typeof videoAssets)[number]['video']>();
|
||||
comments.forEach((comment) => {
|
||||
if (comment.version?.video) uniqueVideos.set(comment.version.video.id, comment.version.video);
|
||||
});
|
||||
videoAssets.forEach((videoAsset) => uniqueVideos.set(videoAsset.video.id, videoAsset.video));
|
||||
videoVersions.forEach((videoVersion) =>
|
||||
uniqueVideos.set(videoVersion.video.id, videoVersion.video)
|
||||
);
|
||||
|
||||
if (uniqueVideos.size > 1) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const video = uniqueVideos.values().next().value ?? null;
|
||||
if (!video) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors } from '@/lib/api-response';
|
||||
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
|
||||
import { buildVideoObjectKey, SAFE_VIDEO_BASENAME } from '@/lib/video-upload-validation';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const VIDEO_CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
ogg: 'video/ogg',
|
||||
mov: 'video/quicktime',
|
||||
m4v: 'video/mp4',
|
||||
mkv: 'video/x-matroska',
|
||||
avi: 'video/x-msvideo',
|
||||
};
|
||||
|
||||
function getVideoContentType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
return VIDEO_CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> }
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
|
||||
if (!SAFE_VIDEO_BASENAME.test(filename)) {
|
||||
return apiErrors.badRequest('Invalid filename');
|
||||
}
|
||||
|
||||
const originalUrl = `/api/upload/video/${filename}`;
|
||||
const projectSelect = {
|
||||
id: true,
|
||||
ownerId: true,
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
} as const;
|
||||
const videoSelect = {
|
||||
id: true,
|
||||
projectId: true,
|
||||
project: { select: projectSelect },
|
||||
} as const;
|
||||
|
||||
const [versions, session] = await Promise.all([
|
||||
db.videoVersion.findMany({
|
||||
where: { originalUrl },
|
||||
take: 2,
|
||||
select: {
|
||||
id: true,
|
||||
video: { select: videoSelect },
|
||||
},
|
||||
}),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
const uniqueVideos = new Map<string, (typeof versions)[number]['video']>();
|
||||
for (const version of versions) {
|
||||
uniqueVideos.set(version.video.id, version.video);
|
||||
}
|
||||
if (uniqueVideos.size > 1) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const video = uniqueVideos.values().next().value ?? null;
|
||||
if (!video) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
|
||||
if (!access.hasAccess) {
|
||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: video.projectId,
|
||||
videoId: video.id,
|
||||
requiredPermission: 'VIEW',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: {
|
||||
hasAccess: false,
|
||||
canComment: false,
|
||||
canDownload: false,
|
||||
allowGuests: false,
|
||||
requiresPassword: false,
|
||||
};
|
||||
|
||||
if (!shareAccess.hasAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
const key = buildVideoObjectKey(filename);
|
||||
return proxyR2MediaObject({
|
||||
request,
|
||||
key,
|
||||
fallbackContentType: getVideoContentType(filename),
|
||||
cacheControl: 'private, max-age=3600',
|
||||
internalErrorMessage: 'Failed to load video',
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Error serving video upload:', error);
|
||||
return apiErrors.internalError('Failed to load video');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user