mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +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:
@@ -18,6 +18,8 @@ NODE_ENV="production"
|
||||
# Self-host defaults
|
||||
OPENFRAME_ENABLE_STRIPE="false"
|
||||
OPENFRAME_ENABLE_BUNNY_UPLOADS="false"
|
||||
OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="true"
|
||||
OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120"
|
||||
OPENFRAME_REQUIRE_INVITE_CODE="false"
|
||||
SELF_HOSTED_AUTO_CREATE_BUCKET="true"
|
||||
|
||||
@@ -32,6 +34,8 @@ TRUSTED_PROXY_MODE="nginx"
|
||||
MINIO_ROOT_USER="replace-with-minio-root-user"
|
||||
MINIO_ROOT_PASSWORD="replace-with-strong-minio-password"
|
||||
R2_ENDPOINT="http://minio:9000"
|
||||
# Browser-facing endpoint used for presigned upload URLs (must be reachable from the browser).
|
||||
R2_PRESIGN_ENDPOINT="http://localhost:9000"
|
||||
R2_PUBLIC_BASE_URL="http://localhost:9000/openframe"
|
||||
R2_ACCESS_KEY_ID="replace-with-minio-root-user"
|
||||
R2_SECRET_ACCESS_KEY="replace-with-strong-minio-password"
|
||||
|
||||
+13
-1
@@ -19,6 +19,14 @@ NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
|
||||
# ============================================================================
|
||||
OPENFRAME_ENABLE_STRIPE="true"
|
||||
OPENFRAME_ENABLE_BUNNY_UPLOADS="true"
|
||||
# Self-hosted direct video uploads to your S3-compatible storage (R2_* vars below).
|
||||
# Mutually exclusive with Bunny: set OPENFRAME_ENABLE_BUNNY_UPLOADS=false when enabling this.
|
||||
OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="false"
|
||||
# Max size per uploaded video file in bytes (default 5GB if unset)
|
||||
OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120"
|
||||
# Direct browser uploads require bucket CORS allowing PUT from your app origin(s).
|
||||
# Run once after creating the bucket: bun run r2:configure-cors
|
||||
# Or set CORS manually in Cloudflare R2 -> bucket -> Settings -> CORS policy.
|
||||
OPENFRAME_REQUIRE_INVITE_CODE="true"
|
||||
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
|
||||
|
||||
@@ -39,9 +47,13 @@ GITHUB_CLIENT_SECRET="your-github-client-secret"
|
||||
# FILE STORAGE
|
||||
# ============================================================================
|
||||
# Cloudflare R2 (S3-compatible)
|
||||
# For self-hosted S3-compatible storage such as MinIO, set R2_ENDPOINT and R2_PUBLIC_BASE_URL.
|
||||
# For self-hosted S3-compatible storage such as MinIO, set:
|
||||
# - R2_ENDPOINT (server/container endpoint)
|
||||
# - R2_PRESIGN_ENDPOINT (browser-reachable endpoint for presigned upload URLs)
|
||||
# - R2_PUBLIC_BASE_URL (public base URL for served files)
|
||||
R2_ACCOUNT_ID="your-account-id"
|
||||
R2_ENDPOINT=""
|
||||
R2_PRESIGN_ENDPOINT=""
|
||||
R2_PUBLIC_BASE_URL=""
|
||||
R2_ACCESS_KEY_ID="your-access-key"
|
||||
R2_SECRET_ACCESS_KEY="your-secret-key"
|
||||
|
||||
@@ -21,7 +21,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
|
||||
- Comment tags, resolved states, and CSV/PDF exports
|
||||
- Video-linked assets for supporting media and references
|
||||
- Email and Telegram notifications
|
||||
- URL-based YouTube video intake plus optional Bunny direct uploads
|
||||
- URL-based YouTube video intake plus optional direct uploads (Bunny Stream or self-hosted S3)
|
||||
|
||||
## Core Workflow
|
||||
|
||||
@@ -75,8 +75,8 @@ OpenFrame is built with:
|
||||
- PostgreSQL
|
||||
- NextAuth.js
|
||||
- Tailwind CSS
|
||||
- MinIO or other S3-compatible object storage for self-hosted media
|
||||
- Bunny Stream for optional direct video uploads
|
||||
- MinIO or other S3-compatible object storage for self-hosted media and direct video uploads
|
||||
- Bunny Stream for optional hosted direct video uploads (mutually exclusive with S3 video uploads)
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
@@ -177,13 +177,14 @@ OPENFRAME_REQUIRE_INVITE_CODE=false
|
||||
Behavior when disabled:
|
||||
|
||||
- `OPENFRAME_ENABLE_STRIPE=false` disables Stripe checkout and customer portal flows and removes billing-based workspace restrictions.
|
||||
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides direct-upload entry points. URL-based providers such as YouTube remain available.
|
||||
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available.
|
||||
- `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true` (with `R2_*` configured) enables presigned uploads to your own S3-compatible storage. Set `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` — only one direct-upload backend can be active. The bucket must allow CORS `PUT` from your app origin (for example `http://localhost:3000` in dev and your production URL). For Docker + MinIO, keep `R2_ENDPOINT=http://minio:9000` (app-internal) and set `R2_PRESIGN_ENDPOINT=http://localhost:9000` (browser-reachable host).
|
||||
- `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact.
|
||||
|
||||
These integrations remain optional for self-hosted deployments and can be enabled later by setting the related environment variables:
|
||||
|
||||
- Stripe billing
|
||||
- Bunny direct uploads
|
||||
- Bunny direct uploads (hosted) or S3 video uploads via `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS` (self-hosted)
|
||||
- SMTP for invitation and notification delivery
|
||||
- Telegram notifications
|
||||
- External S3-compatible storage such as Cloudflare R2 or another compatible provider instead of bundled MinIO
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ProjectFilter } from './project-filter';
|
||||
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
|
||||
interface SerializedProject {
|
||||
id: string;
|
||||
@@ -21,7 +22,8 @@ interface DashboardClientProps {
|
||||
totalPages: number;
|
||||
canCreateProjects: boolean;
|
||||
canUploadVideos: boolean;
|
||||
bunnyUploadsEnabled: boolean;
|
||||
directUploadsEnabled: boolean;
|
||||
directUploadProvider: DirectUploadProvider;
|
||||
}
|
||||
|
||||
export function DashboardClient({
|
||||
@@ -30,11 +32,15 @@ export function DashboardClient({
|
||||
totalPages,
|
||||
canCreateProjects,
|
||||
canUploadVideos,
|
||||
bunnyUploadsEnabled,
|
||||
directUploadsEnabled,
|
||||
directUploadProvider,
|
||||
}: DashboardClientProps) {
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-8 w-full">
|
||||
<VideoDragDropUploader canUpload={canUploadVideos && bunnyUploadsEnabled} />
|
||||
<VideoDragDropUploader
|
||||
canUpload={canUploadVideos && directUploadsEnabled}
|
||||
directUploadProvider={directUploadProvider}
|
||||
/>
|
||||
<ProjectFilter
|
||||
projects={serializedProjects}
|
||||
workspaces={workspaces}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/lib/route-access';
|
||||
import { DashboardClient } from './dashboard-client';
|
||||
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
@@ -157,7 +157,8 @@ export default async function DashboardPage({
|
||||
totalPages={totalPages}
|
||||
canCreateProjects={canCreateProjects}
|
||||
canUploadVideos={canUploadVideos}
|
||||
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
|
||||
directUploadsEnabled={isDirectFileUploadEnabled()}
|
||||
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { GuestGate } from '@/components/guest-gate';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { ProjectContentClient } from './project-content-client';
|
||||
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (!seconds) return '0:00';
|
||||
@@ -141,6 +142,9 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
};
|
||||
});
|
||||
|
||||
const directUploadsEnabled = isDirectFileUploadEnabled();
|
||||
const directUploadProvider = isS3VideoUploadsEnabled() ? 'r2' : 'bunny';
|
||||
|
||||
const canEdit =
|
||||
access.canEdit &&
|
||||
(isOwner ||
|
||||
@@ -181,6 +185,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
workspaceRole={null}
|
||||
totalPages={totalPages}
|
||||
currentPage={page}
|
||||
directUploadsEnabled={directUploadsEnabled}
|
||||
directUploadProvider={directUploadProvider}
|
||||
/>
|
||||
</div>
|
||||
</GuestGate>
|
||||
@@ -208,6 +214,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
workspaceRole={workspaceRole}
|
||||
totalPages={totalPages}
|
||||
currentPage={page}
|
||||
directUploadsEnabled={directUploadsEnabled}
|
||||
directUploadProvider={directUploadProvider}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { VideoCard } from '@/components/video-card';
|
||||
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
|
||||
interface SerializedVideo {
|
||||
id: string;
|
||||
@@ -48,6 +49,8 @@ interface ProjectContentClientProps {
|
||||
workspaceRole: string | null;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
directUploadsEnabled: boolean;
|
||||
directUploadProvider: DirectUploadProvider;
|
||||
}
|
||||
|
||||
export function ProjectContentClient({
|
||||
@@ -58,6 +61,8 @@ export function ProjectContentClient({
|
||||
isOwner,
|
||||
totalPages,
|
||||
currentPage,
|
||||
directUploadsEnabled,
|
||||
directUploadProvider,
|
||||
}: ProjectContentClientProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -91,7 +96,8 @@ export function ProjectContentClient({
|
||||
<VideoDragDropUploader
|
||||
fixedProjectId={projectId}
|
||||
fixedProjectName={project.name}
|
||||
canUpload={canEdit}
|
||||
canUpload={canEdit && directUploadsEnabled}
|
||||
directUploadProvider={directUploadProvider}
|
||||
/>
|
||||
|
||||
{/* Project Header */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { VideoPageContent } from '@/components/video-page-content';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { requireVideoProjectAccessOrRedirect } from '@/lib/route-access';
|
||||
|
||||
interface VideoPageProps {
|
||||
@@ -24,7 +24,8 @@ export default async function VideoPage({ params }: VideoPageProps) {
|
||||
mode="dashboard"
|
||||
videoId={videoId}
|
||||
projectId={projectId}
|
||||
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
|
||||
directUploadsEnabled={isDirectFileUploadEnabled()}
|
||||
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
type VideoSource,
|
||||
} from '@/lib/video-providers';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
import * as tus from 'tus-js-client';
|
||||
|
||||
const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
||||
@@ -38,10 +40,12 @@ function isVideoFile(file: File): boolean {
|
||||
|
||||
export default function NewVideoPageClient({
|
||||
projectId,
|
||||
bunnyUploadsEnabled,
|
||||
directUploadsEnabled,
|
||||
directUploadProvider,
|
||||
}: {
|
||||
projectId: string;
|
||||
bunnyUploadsEnabled: boolean;
|
||||
directUploadsEnabled: boolean;
|
||||
directUploadProvider: DirectUploadProvider;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||
@@ -64,6 +68,9 @@ export default function NewVideoPageClient({
|
||||
const [pendingBunnyUploadToken, setPendingBunnyUploadToken] = useState<string | null>(null);
|
||||
const pendingBunnyVideoIdRef = useRef<string | null>(null);
|
||||
const pendingBunnyUploadTokenRef = useRef<string | null>(null);
|
||||
const pendingR2ObjectKeyRef = useRef<string | null>(null);
|
||||
const pendingR2UploadTokenRef = useRef<string | null>(null);
|
||||
const pendingR2ReservationIdRef = useRef<string | null>(null);
|
||||
const activeTusUploadRef = useRef<tus.Upload | null>(null);
|
||||
const fileDragDepthRef = useRef(0);
|
||||
|
||||
@@ -394,7 +401,7 @@ export default function NewVideoPageClient({
|
||||
finalThumbnailUrl = getThumbnailUrl(videoSource, 'large');
|
||||
finalDuration = videoSource.metadata?.duration || null;
|
||||
} else {
|
||||
if (!bunnyUploadsEnabled) {
|
||||
if (!directUploadsEnabled) {
|
||||
throw new Error('Direct uploads are disabled by this host');
|
||||
}
|
||||
|
||||
@@ -405,22 +412,37 @@ export default function NewVideoPageClient({
|
||||
}
|
||||
finalTitle = finalTitle || selectedFile.name;
|
||||
|
||||
// Handle TUS Upload
|
||||
const bunnyData = await uploadToBunny(selectedFile);
|
||||
uploadedBunnyVideoId = bunnyData.videoId;
|
||||
uploadedBunnyUploadToken = bunnyData.uploadToken;
|
||||
if (directUploadProvider === 'r2') {
|
||||
const r2Data = await uploadVideoToR2(projectId, selectedFile, {
|
||||
onProgress: (progress) => {
|
||||
setUploadProgress(progress);
|
||||
setUploadStatus(`Uploading... ${progress}%`);
|
||||
},
|
||||
});
|
||||
pendingR2ObjectKeyRef.current = r2Data.objectKey;
|
||||
pendingR2UploadTokenRef.current = r2Data.uploadToken;
|
||||
pendingR2ReservationIdRef.current = r2Data.reservationId;
|
||||
|
||||
finalVideoUrl = bunnyData.url;
|
||||
finalProviderId = bunnyData.providerId;
|
||||
finalVideoId = bunnyData.videoId;
|
||||
// Bunny will generate thumbnails automatically after processing.
|
||||
// We'll just provide the standard CDN thumbnail URL format as fallback.
|
||||
finalThumbnailUrl = bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyData.videoId}/thumbnail.jpg`
|
||||
: null;
|
||||
finalVideoUrl = r2Data.proxyUrl;
|
||||
finalProviderId = 'r2';
|
||||
finalVideoId = r2Data.objectKey;
|
||||
finalThumbnailUrl = r2Data.thumbnailUrl || '/placeholder-video-thumbnail.png';
|
||||
finalDuration = r2Data.duration;
|
||||
uploadedBunnyUploadToken = r2Data.uploadToken;
|
||||
} else {
|
||||
const bunnyData = await uploadToBunny(selectedFile);
|
||||
uploadedBunnyVideoId = bunnyData.videoId;
|
||||
uploadedBunnyUploadToken = bunnyData.uploadToken;
|
||||
|
||||
finalVideoUrl = bunnyData.url;
|
||||
finalProviderId = bunnyData.providerId;
|
||||
finalVideoId = bunnyData.videoId;
|
||||
finalThumbnailUrl = bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyData.videoId}/thumbnail.jpg`
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
// Final POST to our database
|
||||
const response = await fetch(`/api/projects/${projectId}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -433,6 +455,8 @@ export default function NewVideoPageClient({
|
||||
thumbnailUrl: finalThumbnailUrl,
|
||||
duration: finalDuration,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
objectKey: pendingR2ObjectKeyRef.current,
|
||||
reservationId: pendingR2ReservationIdRef.current,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -441,12 +465,21 @@ export default function NewVideoPageClient({
|
||||
setSubmitError(data.error || 'Failed to add video');
|
||||
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
|
||||
await cleanupPendingBunnyVideo(uploadedBunnyVideoId, uploadedBunnyUploadToken);
|
||||
} else if (pendingR2ObjectKeyRef.current && pendingR2UploadTokenRef.current) {
|
||||
await cleanupPendingR2VideoUpload(projectId, {
|
||||
objectKey: pendingR2ObjectKeyRef.current,
|
||||
uploadToken: pendingR2UploadTokenRef.current,
|
||||
reservationId: pendingR2ReservationIdRef.current,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pendingBunnyVideoIdRef.current = null;
|
||||
pendingBunnyUploadTokenRef.current = null;
|
||||
pendingR2ObjectKeyRef.current = null;
|
||||
pendingR2UploadTokenRef.current = null;
|
||||
pendingR2ReservationIdRef.current = null;
|
||||
setPendingBunnyVideoId(null);
|
||||
setPendingBunnyUploadToken(null);
|
||||
router.push(`/projects/${projectId}`);
|
||||
@@ -458,6 +491,12 @@ export default function NewVideoPageClient({
|
||||
pendingBunnyVideoIdRef.current,
|
||||
pendingBunnyUploadTokenRef.current
|
||||
);
|
||||
} else if (pendingR2ObjectKeyRef.current && pendingR2UploadTokenRef.current) {
|
||||
await cleanupPendingR2VideoUpload(projectId, {
|
||||
objectKey: pendingR2ObjectKeyRef.current,
|
||||
uploadToken: pendingR2UploadTokenRef.current,
|
||||
reservationId: pendingR2ReservationIdRef.current,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
activeTusUploadRef.current = null;
|
||||
@@ -492,7 +531,7 @@ export default function NewVideoPageClient({
|
||||
<CardHeader>
|
||||
<CardTitle>Add Video</CardTitle>
|
||||
<CardDescription>
|
||||
{bunnyUploadsEnabled
|
||||
{directUploadsEnabled
|
||||
? 'Paste a video link or upload a file directly to add it to your project. Currently supports YouTube.'
|
||||
: 'Paste a video link to add it to your project. Direct uploads are disabled on this host.'}
|
||||
</CardDescription>
|
||||
@@ -504,12 +543,12 @@ export default function NewVideoPageClient({
|
||||
className="mb-6"
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
className={`grid w-full ${directUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
<TabsTrigger value="url" disabled={isLoading}>
|
||||
Paste URL
|
||||
</TabsTrigger>
|
||||
{bunnyUploadsEnabled ? (
|
||||
{directUploadsEnabled ? (
|
||||
<TabsTrigger value="file" disabled={isLoading}>
|
||||
Direct Upload
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requireProjectAccessOrRedirect } from '@/lib/route-access';
|
||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
import NewVideoPageClient from './new-video-page-client';
|
||||
|
||||
interface NewVideoPageProps {
|
||||
@@ -14,5 +14,11 @@ export default async function NewVideoPage({ params }: NewVideoPageProps) {
|
||||
intent: 'manage',
|
||||
});
|
||||
|
||||
return <NewVideoPageClient projectId={projectId} bunnyUploadsEnabled={isBunnyUploadsEnabled()} />;
|
||||
return (
|
||||
<NewVideoPageClient
|
||||
projectId={projectId}
|
||||
directUploadsEnabled={isDirectFileUploadEnabled()}
|
||||
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { auth, checkWorkspaceAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
|
||||
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
function VisibilityIcon({ visibility }: { visibility: string }) {
|
||||
switch (visibility) {
|
||||
@@ -108,7 +109,8 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
|
||||
<div className="px-6 lg:px-8 py-8 w-full">
|
||||
<VideoDragDropUploader
|
||||
workspaceId={workspaceId}
|
||||
canUpload={isAdmin && workspace._count.projects > 0}
|
||||
canUpload={isAdmin && workspace._count.projects > 0 && isDirectFileUploadEnabled()}
|
||||
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
|
||||
/>
|
||||
{/* Back & Header */}
|
||||
<div className="mb-6">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { logError } from '@/lib/logger';
|
||||
@@ -148,8 +149,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
});
|
||||
|
||||
const bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([bunnyRef]);
|
||||
const cleanupInput = { bunny: bunnyCleanupResult };
|
||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||
cleanupBunnyStreamVideosBestEffort([bunnyRef]),
|
||||
result.version.providerId === 'r2'
|
||||
? deleteMediaFilesBestEffort(
|
||||
[result.version.originalUrl, result.version.thumbnailUrl].filter((url): url is string =>
|
||||
Boolean(url)
|
||||
)
|
||||
)
|
||||
: Promise.resolve({ attempted: 0, failed: 0, failedKeys: [] }),
|
||||
]);
|
||||
const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult };
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'video-version', entityId: versionId }, cleanupInput);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||
import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation';
|
||||
import { toJsonSafe } from '@/lib/json-serialize';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
@@ -88,6 +90,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
duration,
|
||||
setActive,
|
||||
uploadToken,
|
||||
objectKey,
|
||||
} = body;
|
||||
|
||||
if (!videoUrl) {
|
||||
@@ -103,13 +106,23 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate URLs use safe schemes (http/https only)
|
||||
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
||||
if (videoUrlError) {
|
||||
return apiErrors.badRequest(videoUrlError);
|
||||
const normalizedProviderIdEarly =
|
||||
typeof providerId === 'string' && providerId.trim()
|
||||
? providerId.trim().toLowerCase()
|
||||
: 'youtube';
|
||||
|
||||
if (normalizedProviderIdEarly === 'r2') {
|
||||
if (!videoUrl.startsWith('/api/upload/video/')) {
|
||||
return apiErrors.badRequest('Video URL must be a valid upload path');
|
||||
}
|
||||
} else {
|
||||
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
||||
if (videoUrlError) {
|
||||
return apiErrors.badRequest(videoUrlError);
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
|
||||
const thumbnailUrlError = validateOptionalUrlOrAppPath(thumbnailUrl, 'Thumbnail URL');
|
||||
if (thumbnailUrlError) {
|
||||
return apiErrors.badRequest(thumbnailUrlError);
|
||||
}
|
||||
@@ -122,6 +135,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
|
||||
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||
|
||||
let versionSizeBytes = BigInt(0);
|
||||
let persistedProviderVideoId = normalizedProviderVideoId;
|
||||
let finalizedR2Session: {
|
||||
sessionId: string;
|
||||
reservationId: string | null;
|
||||
billedUserId: string;
|
||||
thumbnailProxyUrl: string;
|
||||
} | null = null;
|
||||
|
||||
if (normalizedProviderId === 'bunny') {
|
||||
if (!normalizedProviderVideoId || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
|
||||
@@ -135,6 +157,34 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
} else if (normalizedProviderId === 'r2') {
|
||||
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
|
||||
if (!normalizedObjectKey || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('R2 uploads must include objectKey and uploadToken');
|
||||
}
|
||||
|
||||
const finalizeResult = await finalizeR2VideoUpload({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoUrl,
|
||||
objectKey: normalizedObjectKey,
|
||||
uploadToken: normalizedUploadToken,
|
||||
});
|
||||
if (!finalizeResult.ok) {
|
||||
if (finalizeResult.status === 403) {
|
||||
return apiErrors.forbidden(finalizeResult.error);
|
||||
}
|
||||
return apiErrors.badRequest(finalizeResult.error);
|
||||
}
|
||||
|
||||
versionSizeBytes = finalizeResult.sizeBytes;
|
||||
persistedProviderVideoId = normalizedObjectKey;
|
||||
finalizedR2Session = {
|
||||
sessionId: finalizeResult.sessionId,
|
||||
reservationId: finalizeResult.reservationId,
|
||||
billedUserId: finalizeResult.billedUserId,
|
||||
thumbnailProxyUrl: finalizeResult.thumbnailProxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
|
||||
@@ -150,16 +200,47 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
}
|
||||
|
||||
if (finalizedR2Session) {
|
||||
const consumed = await tx.videoUploadSession.updateMany({
|
||||
where: {
|
||||
id: finalizedR2Session.sessionId,
|
||||
status: 'INITIATED',
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey: persistedProviderVideoId,
|
||||
},
|
||||
data: {
|
||||
status: 'FINALIZED',
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (consumed.count !== 1) {
|
||||
throw new Error('Upload session already consumed');
|
||||
}
|
||||
if (finalizedR2Session.reservationId) {
|
||||
await tx.uploadReservation.deleteMany({
|
||||
where: {
|
||||
id: finalizedR2Session.reservationId,
|
||||
billedUserId: finalizedR2Session.billedUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.videoVersion.create({
|
||||
data: {
|
||||
versionNumber: nextVersionNumber,
|
||||
versionLabel: versionLabel?.trim() || null,
|
||||
providerId: normalizedProviderId,
|
||||
videoId: normalizedProviderVideoId,
|
||||
videoId: persistedProviderVideoId,
|
||||
originalUrl: videoUrl,
|
||||
title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
|
||||
thumbnailUrl: thumbnailUrl || null,
|
||||
thumbnailUrl:
|
||||
normalizedProviderId === 'r2'
|
||||
? (finalizedR2Session?.thumbnailProxyUrl ?? '/placeholder-video-thumbnail.png')
|
||||
: thumbnailUrl || null,
|
||||
duration: duration || null,
|
||||
sizeBytes: versionSizeBytes,
|
||||
isActive: setActive ?? false,
|
||||
videoParentId: videoId,
|
||||
},
|
||||
@@ -183,7 +264,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}).catch((err) => logError('Notification failed:', err));
|
||||
}
|
||||
|
||||
const response = successResponse(version, 201);
|
||||
const response = successResponse(toJsonSafe(version), 201);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error creating version:', error);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { rateLimit } from '@/lib/rate-limit';
|
||||
import crypto from 'crypto';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { enforceStorageQuota } from '@/lib/storage-quota';
|
||||
|
||||
@@ -60,8 +60,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.badRequest('Title is required');
|
||||
}
|
||||
|
||||
if (!isBunnyUploadsFeatureEnabled()) {
|
||||
return apiErrors.badRequest('Direct uploads are disabled by this host');
|
||||
if (!isBunnyUploadsEnabled()) {
|
||||
return apiErrors.badRequest('Bunny direct uploads are disabled by this host');
|
||||
}
|
||||
|
||||
const quotaError = await enforceStorageQuota(project.workspace.ownerId, BigInt(0));
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import {
|
||||
createR2UploadToken,
|
||||
parseR2UploadToken,
|
||||
verifyR2UploadToken,
|
||||
} from '@/lib/r2-upload-token';
|
||||
import {
|
||||
createPresignedImagePutUrl,
|
||||
createPresignedVideoPutUrl,
|
||||
deleteR2Object,
|
||||
deleteVideoObject,
|
||||
} from '@/lib/r2';
|
||||
import { getMaxVideoUploadBytes, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
import {
|
||||
buildVideoObjectKey,
|
||||
getVideoExtensionFromMime,
|
||||
resolveVideoContentType,
|
||||
videoProxyPathFromFilename,
|
||||
} from '@/lib/video-upload-validation';
|
||||
import { logError } from '@/lib/logger';
|
||||
import {
|
||||
enforceStorageQuota,
|
||||
releaseStorageReservation,
|
||||
reserveStorageQuota,
|
||||
} from '@/lib/storage-quota';
|
||||
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
const VIDEO_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
const THUMBNAIL_RESERVE_BYTES = BigInt(512 * 1024);
|
||||
|
||||
async function getProjectWithEditAccess(projectId: string, userId: string) {
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ownerId: true,
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
workspace: { select: { ownerId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
|
||||
if (!access.canEdit) return null;
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
// POST /api/projects/[projectId]/videos/r2-init
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
if (!isS3VideoUploadsEnabled()) {
|
||||
return apiErrors.badRequest('S3 video uploads are disabled by this host');
|
||||
}
|
||||
|
||||
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
||||
if (!project) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const fileName = typeof body?.fileName === 'string' ? body.fileName.trim() : '';
|
||||
const contentTypeInput = typeof body?.contentType === 'string' ? body.contentType.trim() : '';
|
||||
const sizeBytesRaw = body?.sizeBytes;
|
||||
|
||||
if (!fileName) {
|
||||
return apiErrors.badRequest('fileName is required');
|
||||
}
|
||||
|
||||
let sizeBytes: bigint;
|
||||
try {
|
||||
sizeBytes = BigInt(sizeBytesRaw);
|
||||
if (sizeBytes <= BigInt(0)) {
|
||||
return apiErrors.badRequest('sizeBytes must be a positive integer');
|
||||
}
|
||||
} catch {
|
||||
return apiErrors.badRequest('sizeBytes must be a positive integer');
|
||||
}
|
||||
|
||||
const maxBytes = getMaxVideoUploadBytes();
|
||||
if (sizeBytes > maxBytes) {
|
||||
return apiErrors.badRequest('Video file exceeds the maximum allowed upload size');
|
||||
}
|
||||
|
||||
const contentType = resolveVideoContentType(fileName, contentTypeInput);
|
||||
if (!contentType) {
|
||||
return apiErrors.badRequest('Unsupported video format');
|
||||
}
|
||||
|
||||
const ext = getVideoExtensionFromMime(contentType);
|
||||
if (!ext) {
|
||||
return apiErrors.badRequest('Unsupported video format');
|
||||
}
|
||||
|
||||
const quotaError = await enforceStorageQuota(
|
||||
project.workspace.ownerId,
|
||||
sizeBytes + THUMBNAIL_RESERVE_BYTES
|
||||
);
|
||||
if (quotaError) return quotaError;
|
||||
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
project.workspace.ownerId,
|
||||
sizeBytes + THUMBNAIL_RESERVE_BYTES,
|
||||
VIDEO_RESERVATION_TTL_MS
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
|
||||
const fileId = randomUUID();
|
||||
const filename = `${fileId}.${ext}`;
|
||||
const objectKey = buildVideoObjectKey(filename);
|
||||
const proxyUrl = videoProxyPathFromFilename(filename);
|
||||
const thumbnailFilename = `${fileId}.jpg`;
|
||||
const thumbnailObjectKey = `images/${thumbnailFilename}`;
|
||||
const thumbnailProxyUrl = `/api/upload/image/${thumbnailFilename}`;
|
||||
|
||||
let presignedPutUrl: string;
|
||||
let thumbnailPresignedPutUrl: string;
|
||||
try {
|
||||
[presignedPutUrl, thumbnailPresignedPutUrl] = await Promise.all([
|
||||
createPresignedVideoPutUrl(objectKey, contentType, sizeBytes),
|
||||
createPresignedImagePutUrl(thumbnailObjectKey, 'image/jpeg'),
|
||||
]);
|
||||
} catch (error) {
|
||||
await releaseStorageReservation(reserveResult.reservationId, project.workspace.ownerId);
|
||||
logError('Failed to create presigned video upload URL:', error);
|
||||
return apiErrors.internalError('Failed to initialize video upload');
|
||||
}
|
||||
|
||||
const uploadJti = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + VIDEO_RESERVATION_TTL_MS);
|
||||
const uploadSession = await createR2UploadSession({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
billedUserId: project.workspace.ownerId,
|
||||
objectKey,
|
||||
thumbnailObjectKey,
|
||||
declaredSizeBytes: sizeBytes,
|
||||
contentType,
|
||||
reservationId: reserveResult.reservationId,
|
||||
uploadJti,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const uploadToken = createR2UploadToken({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey,
|
||||
sessionId: uploadSession.id,
|
||||
tokenId: uploadJti,
|
||||
thumbnailObjectKey,
|
||||
});
|
||||
|
||||
const response = successResponse({
|
||||
presignedPutUrl,
|
||||
objectKey,
|
||||
proxyUrl,
|
||||
uploadToken,
|
||||
reservationId: reserveResult.reservationId,
|
||||
contentType,
|
||||
thumbnailPresignedPutUrl,
|
||||
thumbnailObjectKey,
|
||||
thumbnailProxyUrl,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error initializing R2 video upload:', error);
|
||||
return apiErrors.internalError('Failed to initialize upload');
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/projects/[projectId]/videos/r2-init
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
if (!isS3VideoUploadsEnabled()) {
|
||||
return apiErrors.badRequest('S3 video uploads are disabled by this host');
|
||||
}
|
||||
|
||||
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
||||
if (!project) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const objectKey = typeof body?.objectKey === 'string' ? body.objectKey.trim() : '';
|
||||
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
||||
const thumbnailObjectKey =
|
||||
typeof body?.thumbnailObjectKey === 'string' ? body.thumbnailObjectKey.trim() : '';
|
||||
|
||||
if (!objectKey || !uploadToken) {
|
||||
return apiErrors.badRequest('objectKey and uploadToken are required');
|
||||
}
|
||||
|
||||
const tokenPayload = parseR2UploadToken(uploadToken);
|
||||
if (!tokenPayload) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyR2UploadToken(uploadToken, {
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey,
|
||||
sessionId: tokenPayload.sid,
|
||||
tokenId: tokenPayload.jti,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const uploadSession = await db.videoUploadSession.findFirst({
|
||||
where: {
|
||||
id: tokenPayload.sid,
|
||||
status: 'INITIATED',
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey,
|
||||
uploadJti: tokenPayload.jti,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
reservationId: true,
|
||||
billedUserId: true,
|
||||
thumbnailObjectKey: true,
|
||||
},
|
||||
});
|
||||
if (!uploadSession) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
if (thumbnailObjectKey && thumbnailObjectKey !== uploadSession.thumbnailObjectKey) {
|
||||
return apiErrors.badRequest('Invalid thumbnail object key');
|
||||
}
|
||||
|
||||
const cancelled = await db.videoUploadSession.updateMany({
|
||||
where: {
|
||||
id: uploadSession.id,
|
||||
status: 'INITIATED',
|
||||
},
|
||||
data: {
|
||||
status: 'CANCELLED',
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (cancelled.count !== 1) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
deleteVideoObject(objectKey),
|
||||
uploadSession.thumbnailObjectKey.startsWith('images/')
|
||||
? deleteR2Object(uploadSession.thumbnailObjectKey)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
} catch (error) {
|
||||
logError('Failed to delete pending R2 video object:', error);
|
||||
}
|
||||
|
||||
await releaseStorageReservation(uploadSession.reservationId, uploadSession.billedUserId);
|
||||
|
||||
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error cleaning up pending R2 video upload:', error);
|
||||
return apiErrors.internalError('Failed to cleanup pending upload');
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||
import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation';
|
||||
import { toJsonSafe } from '@/lib/json-serialize';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
@@ -97,19 +99,30 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
thumbnailUrl,
|
||||
duration,
|
||||
uploadToken,
|
||||
objectKey,
|
||||
} = body;
|
||||
|
||||
if (!title || !videoUrl) {
|
||||
return apiErrors.badRequest('Title and video URL are required');
|
||||
}
|
||||
|
||||
// Validate URLs use safe schemes (http/https only)
|
||||
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
||||
if (videoUrlError) {
|
||||
return apiErrors.badRequest(videoUrlError);
|
||||
const normalizedProviderIdEarly =
|
||||
typeof providerId === 'string' && providerId.trim()
|
||||
? providerId.trim().toLowerCase()
|
||||
: 'youtube';
|
||||
|
||||
if (normalizedProviderIdEarly === 'r2') {
|
||||
if (!videoUrl.startsWith('/api/upload/video/')) {
|
||||
return apiErrors.badRequest('Video URL must be a valid upload path');
|
||||
}
|
||||
} else {
|
||||
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
||||
if (videoUrlError) {
|
||||
return apiErrors.badRequest(videoUrlError);
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
|
||||
const thumbnailUrlError = validateOptionalUrlOrAppPath(thumbnailUrl, 'Thumbnail URL');
|
||||
if (thumbnailUrlError) {
|
||||
return apiErrors.badRequest(thumbnailUrlError);
|
||||
}
|
||||
@@ -121,6 +134,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
|
||||
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||
|
||||
let versionSizeBytes = BigInt(0);
|
||||
let finalizedR2Session: {
|
||||
sessionId: string;
|
||||
reservationId: string | null;
|
||||
billedUserId: string;
|
||||
thumbnailProxyUrl: string;
|
||||
} | null = null;
|
||||
|
||||
if (normalizedProviderId === 'bunny') {
|
||||
if (!normalizedVideoId || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
|
||||
@@ -134,8 +155,42 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
} else if (normalizedProviderId === 'r2') {
|
||||
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
|
||||
if (!normalizedObjectKey || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('R2 uploads must include objectKey and uploadToken');
|
||||
}
|
||||
|
||||
const finalizeResult = await finalizeR2VideoUpload({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoUrl,
|
||||
objectKey: normalizedObjectKey,
|
||||
uploadToken: normalizedUploadToken,
|
||||
});
|
||||
if (!finalizeResult.ok) {
|
||||
if (finalizeResult.status === 403) {
|
||||
return apiErrors.forbidden(finalizeResult.error);
|
||||
}
|
||||
return apiErrors.badRequest(finalizeResult.error);
|
||||
}
|
||||
|
||||
versionSizeBytes = finalizeResult.sizeBytes;
|
||||
finalizedR2Session = {
|
||||
sessionId: finalizeResult.sessionId,
|
||||
reservationId: finalizeResult.reservationId,
|
||||
billedUserId: finalizeResult.billedUserId,
|
||||
thumbnailProxyUrl: finalizeResult.thumbnailProxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const persistedVideoId =
|
||||
normalizedProviderId === 'r2'
|
||||
? typeof objectKey === 'string'
|
||||
? objectKey.trim()
|
||||
: ''
|
||||
: normalizedVideoId;
|
||||
|
||||
// Get the next position
|
||||
const lastVideo = await db.video.findFirst({
|
||||
where: { projectId },
|
||||
@@ -144,29 +199,62 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const nextPosition = (lastVideo?.position ?? -1) + 1;
|
||||
|
||||
// Create video with initial version
|
||||
const video = await db.video.create({
|
||||
data: {
|
||||
title: title.trim(),
|
||||
description: description?.trim() || null,
|
||||
position: nextPosition,
|
||||
projectId,
|
||||
versions: {
|
||||
create: {
|
||||
versionNumber: 1,
|
||||
providerId: normalizedProviderId,
|
||||
videoId: normalizedVideoId,
|
||||
originalUrl: videoUrl,
|
||||
title: title.trim(),
|
||||
thumbnailUrl: thumbnailUrl || null,
|
||||
duration: duration || null,
|
||||
isActive: true,
|
||||
const video = await db.$transaction(async (tx) => {
|
||||
if (finalizedR2Session) {
|
||||
const consumed = await tx.videoUploadSession.updateMany({
|
||||
where: {
|
||||
id: finalizedR2Session.sessionId,
|
||||
status: 'INITIATED',
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey: persistedVideoId,
|
||||
},
|
||||
data: {
|
||||
status: 'FINALIZED',
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (consumed.count !== 1) {
|
||||
throw new Error('Upload session already consumed');
|
||||
}
|
||||
if (finalizedR2Session.reservationId) {
|
||||
await tx.uploadReservation.deleteMany({
|
||||
where: {
|
||||
id: finalizedR2Session.reservationId,
|
||||
billedUserId: finalizedR2Session.billedUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.video.create({
|
||||
data: {
|
||||
title: title.trim(),
|
||||
description: description?.trim() || null,
|
||||
position: nextPosition,
|
||||
projectId,
|
||||
versions: {
|
||||
create: {
|
||||
versionNumber: 1,
|
||||
providerId: normalizedProviderId,
|
||||
videoId: persistedVideoId,
|
||||
originalUrl: videoUrl,
|
||||
title: title.trim(),
|
||||
thumbnailUrl:
|
||||
normalizedProviderId === 'r2'
|
||||
? (finalizedR2Session?.thumbnailProxyUrl ?? '/placeholder-video-thumbnail.png')
|
||||
: thumbnailUrl || null,
|
||||
duration: duration || null,
|
||||
sizeBytes: versionSizeBytes,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
versions: true,
|
||||
_count: { select: { versions: true } },
|
||||
},
|
||||
include: {
|
||||
versions: true,
|
||||
_count: { select: { versions: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Notify project owner (fire-and-forget, skip if they added it themselves)
|
||||
@@ -181,7 +269,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}).catch((err) => logError('Notification failed:', err));
|
||||
}
|
||||
|
||||
const response = successResponse(video, 201);
|
||||
const response = successResponse(toJsonSafe(video), 201);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error creating video:', error);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
"name": "openframe",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@aws-sdk/client-s3": "^3.1029.0",
|
||||
"@aws-sdk/client-s3": "3.1054.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1054.0",
|
||||
"@prisma/adapter-pg": "^7.3.0",
|
||||
"@prisma/client": "^7.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -16,7 +17,7 @@
|
||||
"gsap": "^3.14.2",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.2.3",
|
||||
"next": "16.2.6",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.5",
|
||||
@@ -82,69 +83,53 @@
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-s3": ["@aws-sdk/[email protected]29.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/credential-provider-node": "^3.972.30", "@aws-sdk/middleware-bucket-endpoint": "^3.972.9", "@aws-sdk/middleware-expect-continue": "^3.972.9", "@aws-sdk/middleware-flexible-checksums": "^3.974.7", "@aws-sdk/middleware-host-header": "^3.972.9", "@aws-sdk/middleware-location-constraint": "^3.972.9", "@aws-sdk/middleware-logger": "^3.972.9", "@aws-sdk/middleware-recursion-detection": "^3.972.10", "@aws-sdk/middleware-sdk-s3": "^3.972.28", "@aws-sdk/middleware-ssec": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/region-config-resolver": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.16", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@aws-sdk/util-user-agent-browser": "^3.972.9", "@aws-sdk/util-user-agent-node": "^3.973.15", "@smithy/config-resolver": "^4.4.14", "@smithy/core": "^3.23.14", "@smithy/eventstream-serde-browser": "^4.2.13", "@smithy/eventstream-serde-config-resolver": "^4.3.13", "@smithy/eventstream-serde-node": "^4.2.13", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/hash-blob-browser": "^4.2.14", "@smithy/hash-node": "^4.2.13", "@smithy/hash-stream-node": "^4.2.13", "@smithy/invalid-dependency": "^4.2.13", "@smithy/md5-js": "^4.2.13", "@smithy/middleware-content-length": "^4.2.13", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-retry": "^4.5.0", "@smithy/middleware-serde": "^4.2.17", "@smithy/middleware-stack": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/node-http-handler": "^4.5.2", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.45", "@smithy/util-defaults-mode-node": "^4.2.49", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.15", "tslib": "^2.6.2" } }, "sha512-OuA8RZTxsAaHDcI25j2NGLMaYFI2WpJdDzK3uLmVBmaHwjQKQZOUDVVBcln8pNo3IgkY+HRSJhRR4/xlM//UyQ=="],
|
||||
"@aws-sdk/client-s3": ["@aws-sdk/[email protected]54.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/credential-provider-node": "^3.972.45", "@aws-sdk/middleware-bucket-endpoint": "^3.972.16", "@aws-sdk/middleware-expect-continue": "^3.972.13", "@aws-sdk/middleware-flexible-checksums": "^3.974.22", "@aws-sdk/middleware-location-constraint": "^3.972.11", "@aws-sdk/middleware-sdk-s3": "^3.972.43", "@aws-sdk/middleware-ssec": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2ue7uVqaHYX4rytkcrLySYU/m/ZlRbL8KojWefbR24B0/TcFkqN2IovpBFrnmla/dtZAn9eVSlhHeEddOghZ5w=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/[email protected]3.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/xml-builder": "^3.972.17", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A=="],
|
||||
"@aws-sdk/core": ["@aws-sdk/[email protected]4.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw=="],
|
||||
|
||||
"@aws-sdk/crc64-nvme": ["@aws-sdk/[email protected].6", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g=="],
|
||||
"@aws-sdk/crc64-nvme": ["@aws-sdk/[email protected].9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/[email protected].25", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A=="],
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/[email protected].40", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/[email protected]7", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/node-http-handler": "^4.5.2", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" } }, "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ=="],
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/[email protected].42", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/[email protected].29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-login": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw=="],
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/[email protected].44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/credential-provider-env": "^3.972.40", "@aws-sdk/credential-provider-http": "^3.972.42", "@aws-sdk/credential-provider-login": "^3.972.44", "@aws-sdk/credential-provider-process": "^3.972.40", "@aws-sdk/credential-provider-sso": "^3.972.44", "@aws-sdk/credential-provider-web-identity": "^3.972.44", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/[email protected].29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ=="],
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/[email protected].44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/[email protected].30", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-ini": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw=="],
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/[email protected].45", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.40", "@aws-sdk/credential-provider-http": "^3.972.42", "@aws-sdk/credential-provider-ini": "^3.972.44", "@aws-sdk/credential-provider-process": "^3.972.40", "@aws-sdk/credential-provider-sso": "^3.972.44", "@aws-sdk/credential-provider-web-identity": "^3.972.44", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/[email protected].25", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ=="],
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/[email protected].40", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/[email protected].29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/token-providers": "3.1026.0", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig=="],
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/[email protected].44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/token-providers": "3.1054.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/[email protected].29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew=="],
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/[email protected].44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg=="],
|
||||
|
||||
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/[email protected].9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-COToYKgquDyligbcAep7ygs48RK+mwe/IYprq4+TSrVFzNOYmzWvHf6werpnKV5VYpRiwdn+Wa5ZXkPqLVwcTg=="],
|
||||
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/[email protected].16", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-FhasMTBDBmMN7EEa1hUeHwo5p5Mv3Dm8w0VEbdXX/6ola/uyhRuJt8zGkH09mLTmab20USTzEpPqyqEoe1MqNg=="],
|
||||
|
||||
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/[email protected].9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA=="],
|
||||
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/[email protected].13", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-sHiqIFg8o2ipT7t40B89Vj0ubSUtY6OSt/+Ee/OXhHch5K4+81zP2+QX8Lkc/nJ2QSmCySxOke7TEbmX69fe2g=="],
|
||||
|
||||
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/[email protected].7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/crc64-nvme": "^3.972.6", "@aws-sdk/types": "^3.973.7", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uU4/ch2CLHB8Phu1oTKnnQ4e8Ujqi49zEnQYBhWYT53zfFvtJCdGsaOoypBr8Fm/pmCBssRmGoIQ4sixgdLP9w=="],
|
||||
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/[email protected].22", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/crc64-nvme": "^3.972.9", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-ot1kZ1JGHUxcXPOARhej/n/+Odfx9VPt60pNrUq8Lf/U2blIF3+uj5v56gw76VD70dZvrfeLNo9jKz6pQJfOlA=="],
|
||||
|
||||
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ=="],
|
||||
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A=="],
|
||||
|
||||
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-TyfOi2XNdOZpNKeTJwRUsVAGa+14nkyMb2VVGG+eDgcWG/ed6+NUo72N3hT6QJioxym80NSinErD+LBRF0Ir1w=="],
|
||||
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CBmixMY36JdAdt9ALgm7yVlvOXGUCHt9Z2kn5p9XVO5StO6HCH+cayV7YYV1CDLsXvVyebaXgBmif9wHoxCeNA=="],
|
||||
|
||||
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog=="],
|
||||
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ=="],
|
||||
|
||||
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ=="],
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.12", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q=="],
|
||||
|
||||
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-qJHcJQH9UNPUrnPlRtCozKjtqAaypQ5IgQxTNoPsVYIQeuwNIA8Rwt3NvGij1vCDYDfCmZaPLpnJEHlZXeFqmg=="],
|
||||
"@aws-sdk/s3-request-presigner": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rgSDc0LwzM1yUL/UouFLR72HV5lhgdQRQlp8WWWot+q3nhBYrj+mklreWh28q9bGkgrNGWrcpMRp4Y3rC4sxVw=="],
|
||||
|
||||
"@aws-sdk/middleware-ssec": ["@aws-sdk/[email protected].9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-wSA2BR7L0CyBNDJeSrleIIzC+DzL93YNTdfU0KPGLiocK6YsRv1nPAzPF+BFSdcs0Qa5ku5Kcf4KvQcWwKGenQ=="],
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/[email protected]9", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw=="],
|
||||
|
||||
"@aws-sdk/middleware-user-agent": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@smithy/core": "^3.23.14", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-retry": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ=="],
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/middleware-host-header": "^3.972.9", "@aws-sdk/middleware-logger": "^3.972.9", "@aws-sdk/middleware-recursion-detection": "^3.972.10", "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/region-config-resolver": "^3.972.11", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@aws-sdk/util-user-agent-browser": "^3.972.9", "@aws-sdk/util-user-agent-node": "^3.973.15", "@smithy/config-resolver": "^4.4.14", "@smithy/core": "^3.23.14", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/hash-node": "^4.2.13", "@smithy/invalid-dependency": "^4.2.13", "@smithy/middleware-content-length": "^4.2.13", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-retry": "^4.5.0", "@smithy/middleware-serde": "^4.2.17", "@smithy/middleware-stack": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/node-http-handler": "^4.5.2", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.45", "@smithy/util-defaults-mode-node": "^4.2.49", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q=="],
|
||||
|
||||
"@aws-sdk/region-config-resolver": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/config-resolver": "^4.4.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.28", "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ=="],
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg=="],
|
||||
|
||||
"@aws-sdk/util-arn-parser": ["@aws-sdk/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
|
||||
|
||||
"@aws-sdk/util-endpoints": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-endpoints": "^3.3.4", "tslib": "^2.6.2" } }, "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg=="],
|
||||
"@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="],
|
||||
|
||||
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-brows[email protected].9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw=="],
|
||||
|
||||
"@aws-sdk/util-user-agent-node": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg=="],
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-build[email protected].26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/[email protected]", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
|
||||
|
||||
@@ -380,25 +365,25 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@next/env": ["@next/[email protected].3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
|
||||
"@next/env": ["@next/[email protected].6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
|
||||
|
||||
"@next/eslint-plugin-next": ["@next/[email protected]", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/[email protected].3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
|
||||
"@next/swc-darwin-arm64": ["@next/[email protected].6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/[email protected].3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
|
||||
"@next/swc-darwin-x64": ["@next/[email protected].6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/[email protected].3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/[email protected].6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/[email protected].3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/[email protected].6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/[email protected].3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/[email protected].6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/[email protected].3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/[email protected].6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/[email protected].3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/[email protected].6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/[email protected].3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/[email protected].6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/[email protected]", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
@@ -406,6 +391,8 @@
|
||||
|
||||
"@noble/hashes": ["@noble/[email protected]", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/[email protected]", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -574,105 +561,23 @@
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/[email protected]", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@smithy/chunked-blob-reader": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
|
||||
"@smithy/core": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw=="],
|
||||
|
||||
"@smithy/chunked-blob-reader-native": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
|
||||
"@smithy/credential-provider-imds": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA=="],
|
||||
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.14", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ=="],
|
||||
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.4", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ=="],
|
||||
|
||||
"@smithy/core": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg=="],
|
||||
"@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ=="],
|
||||
"@smithy/node-http-handler": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ=="],
|
||||
|
||||
"@smithy/eventstream-codec": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
|
||||
"@smithy/signature-v4": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q=="],
|
||||
|
||||
"@smithy/eventstream-serde-browser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg=="],
|
||||
"@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="],
|
||||
|
||||
"@smithy/eventstream-serde-config-resolver": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA=="],
|
||||
"@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@smithy/eventstream-serde-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A=="],
|
||||
|
||||
"@smithy/eventstream-serde-universal": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA=="],
|
||||
|
||||
"@smithy/fetch-http-handler": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ=="],
|
||||
|
||||
"@smithy/hash-blob-browser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-rtQ5es8r/5v4rav7q5QTsfx9CtCyzrz/g7ZZZBH2xtMmd6G/KQrLOWfSHTvFOUPlVy59RQvxeBYJaLRoybMEyA=="],
|
||||
|
||||
"@smithy/hash-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA=="],
|
||||
|
||||
"@smithy/hash-stream-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg=="],
|
||||
|
||||
"@smithy/invalid-dependency": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg=="],
|
||||
|
||||
"@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
|
||||
|
||||
"@smithy/md5-js": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ=="],
|
||||
|
||||
"@smithy/middleware-content-length": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig=="],
|
||||
|
||||
"@smithy/middleware-endpoint": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/middleware-serde": "^4.2.17", "@smithy/node-config-provider": "^4.3.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw=="],
|
||||
|
||||
"@smithy/middleware-retry": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/service-error-classification": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.1", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA=="],
|
||||
|
||||
"@smithy/middleware-serde": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ=="],
|
||||
|
||||
"@smithy/middleware-stack": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw=="],
|
||||
|
||||
"@smithy/node-config-provider": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw=="],
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA=="],
|
||||
|
||||
"@smithy/property-provider": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ=="],
|
||||
|
||||
"@smithy/protocol-http": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg=="],
|
||||
|
||||
"@smithy/querystring-builder": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ=="],
|
||||
|
||||
"@smithy/querystring-parser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA=="],
|
||||
|
||||
"@smithy/service-error-classification": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0" } }, "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw=="],
|
||||
|
||||
"@smithy/shared-ini-file-loader": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw=="],
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg=="],
|
||||
|
||||
"@smithy/smithy-client": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-stack": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" } }, "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ=="],
|
||||
|
||||
"@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ=="],
|
||||
|
||||
"@smithy/url-parser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw=="],
|
||||
|
||||
"@smithy/util-base64": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="],
|
||||
|
||||
"@smithy/util-body-length-browser": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="],
|
||||
|
||||
"@smithy/util-body-length-node": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="],
|
||||
|
||||
"@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
||||
|
||||
"@smithy/util-config-provider": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
|
||||
|
||||
"@smithy/util-defaults-mode-browser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/property-provider": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw=="],
|
||||
|
||||
"@smithy/util-defaults-mode-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/config-resolver": "^4.4.14", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ=="],
|
||||
|
||||
"@smithy/util-endpoints": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog=="],
|
||||
|
||||
"@smithy/util-hex-encoding": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="],
|
||||
|
||||
"@smithy/util-middleware": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow=="],
|
||||
|
||||
"@smithy/util-retry": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ=="],
|
||||
|
||||
"@smithy/util-stream": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.16", "@smithy/node-http-handler": "^4.5.2", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew=="],
|
||||
|
||||
"@smithy/util-uri-escape": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="],
|
||||
|
||||
"@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@smithy/util-waiter": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg=="],
|
||||
|
||||
"@smithy/uuid": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
|
||||
"@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
@@ -1126,9 +1031,9 @@
|
||||
|
||||
"fast-uri": ["[email protected]", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
"fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
|
||||
|
||||
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
@@ -1536,7 +1441,7 @@
|
||||
|
||||
"negotiator": ["[email protected]", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"next": ["[email protected].3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
|
||||
"next": ["[email protected].6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
|
||||
|
||||
"next-auth": ["[email protected]", "", { "dependencies": { "@auth/core": "0.41.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg=="],
|
||||
|
||||
@@ -1978,6 +1883,8 @@
|
||||
|
||||
"wsl-utils": ["[email protected]", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
"xml-naming": ["[email protected]", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||
|
||||
"xtend": ["[email protected]", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"y18n": ["[email protected]", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
@@ -2010,18 +1917,12 @@
|
||||
|
||||
"@aws-crypto/sha1-browser/@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
|
||||
|
||||
"@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@babel/core/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -2178,18 +2079,12 @@
|
||||
|
||||
"@aws-crypto/sha1-browser/@aws-sdk/types/@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/sha256-js/@aws-sdk/types/@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
|
||||
|
||||
"@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@commitlint/config-validator/ajv/json-schema-traverse": ["[email protected]", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@commitlint/top-level/find-up/locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="],
|
||||
@@ -2248,12 +2143,6 @@
|
||||
|
||||
"wrap-ansi/string-width/emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@commitlint/top-level/find-up/locate-path/p-locate": ["[email protected]", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa/onetime/mimic-fn": ["[email protected]", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
|
||||
type ProjectOption = {
|
||||
id: string;
|
||||
@@ -35,6 +37,7 @@ interface VideoDragDropUploaderProps {
|
||||
workspaceId?: string;
|
||||
projectOptions?: ProjectOption[];
|
||||
canUpload?: boolean;
|
||||
directUploadProvider?: DirectUploadProvider;
|
||||
}
|
||||
|
||||
const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
||||
@@ -68,6 +71,7 @@ export function VideoDragDropUploader({
|
||||
workspaceId,
|
||||
projectOptions,
|
||||
canUpload = false,
|
||||
directUploadProvider = 'bunny',
|
||||
}: VideoDragDropUploaderProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -86,11 +90,23 @@ export function VideoDragDropUploader({
|
||||
);
|
||||
|
||||
const activeTusUploadRef = useRef<ActiveTusUpload | null>(null);
|
||||
const pendingUploadRef = useRef<{
|
||||
projectId: string;
|
||||
videoId: string;
|
||||
uploadToken: string;
|
||||
} | null>(null);
|
||||
const pendingUploadRef = useRef<
|
||||
| {
|
||||
type: 'bunny';
|
||||
projectId: string;
|
||||
videoId: string;
|
||||
uploadToken: string;
|
||||
}
|
||||
| {
|
||||
type: 'r2';
|
||||
projectId: string;
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
thumbnailObjectKey?: string;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
const cancelRequestedRef = useRef(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
const hasLoadedProjectsRef = useRef(false);
|
||||
@@ -205,11 +221,20 @@ export function VideoDragDropUploader({
|
||||
|
||||
if (pending) {
|
||||
try {
|
||||
await fetch(`/api/projects/${pending.projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }),
|
||||
});
|
||||
if (pending.type === 'bunny') {
|
||||
await fetch(`/api/projects/${pending.projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }),
|
||||
});
|
||||
} else {
|
||||
await cleanupPendingR2VideoUpload(pending.projectId, {
|
||||
objectKey: pending.objectKey,
|
||||
uploadToken: pending.uploadToken,
|
||||
reservationId: pending.reservationId,
|
||||
thumbnailObjectKey: pending.thumbnailObjectKey,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup cancelled upload:', error);
|
||||
}
|
||||
@@ -232,12 +257,80 @@ export function VideoDragDropUploader({
|
||||
setSelectedProjectId(projectId);
|
||||
setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null);
|
||||
|
||||
let createdVideoId: string | null = null;
|
||||
let uploadToken: string | null = null;
|
||||
let pendingCleanup:
|
||||
| { type: 'bunny'; videoId: string; uploadToken: string }
|
||||
| {
|
||||
type: 'r2';
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
thumbnailObjectKey?: string;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
try {
|
||||
const title = getDefaultTitleFromFile(file);
|
||||
|
||||
if (directUploadProvider === 'r2') {
|
||||
const uploaded = await uploadVideoToR2(projectId, file, {
|
||||
onProgress: (progress) => {
|
||||
setUploadProgress(progress);
|
||||
setUploadStatus(`Uploading... ${progress}%`);
|
||||
},
|
||||
});
|
||||
pendingCleanup = {
|
||||
type: 'r2',
|
||||
objectKey: uploaded.objectKey,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
reservationId: uploaded.reservationId,
|
||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
||||
};
|
||||
pendingUploadRef.current = {
|
||||
type: 'r2',
|
||||
projectId,
|
||||
objectKey: uploaded.objectKey,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
reservationId: uploaded.reservationId,
|
||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
||||
};
|
||||
|
||||
setUploadStatus('Saving video...');
|
||||
const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description: null,
|
||||
videoUrl: uploaded.proxyUrl,
|
||||
providerId: 'r2',
|
||||
videoId: uploaded.objectKey,
|
||||
thumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
|
||||
duration: uploaded.duration,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
objectKey: uploaded.objectKey,
|
||||
reservationId: uploaded.reservationId,
|
||||
}),
|
||||
});
|
||||
|
||||
const createPayload = (await createResponse.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(createPayload?.error || 'Failed to create video');
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
|
||||
);
|
||||
setDialogOpen(false);
|
||||
setDroppedFile(null);
|
||||
cleanupUploadState();
|
||||
router.push(`/projects/${projectId}`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -259,9 +352,11 @@ export function VideoDragDropUploader({
|
||||
throw new Error(initPayload?.error || 'Failed to initialize upload');
|
||||
}
|
||||
|
||||
createdVideoId = initPayload.data.videoId;
|
||||
uploadToken = initPayload.data.uploadToken;
|
||||
const createdVideoId = initPayload.data.videoId;
|
||||
const uploadToken = initPayload.data.uploadToken;
|
||||
pendingCleanup = { type: 'bunny', videoId: createdVideoId, uploadToken };
|
||||
pendingUploadRef.current = {
|
||||
type: 'bunny',
|
||||
projectId,
|
||||
videoId: createdVideoId,
|
||||
uploadToken,
|
||||
@@ -346,13 +441,20 @@ export function VideoDragDropUploader({
|
||||
return;
|
||||
}
|
||||
|
||||
if (createdVideoId && uploadToken) {
|
||||
if (pendingCleanup) {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: createdVideoId, uploadToken }),
|
||||
});
|
||||
if (pendingCleanup.type === 'bunny') {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoId: pendingCleanup.videoId,
|
||||
uploadToken: pendingCleanup.uploadToken,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to cleanup pending upload:', cleanupError);
|
||||
}
|
||||
@@ -364,7 +466,7 @@ export function VideoDragDropUploader({
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
|
||||
}
|
||||
},
|
||||
[bunnyCdnHostname, cleanupUploadState, projectsById, router]
|
||||
[bunnyCdnHostname, cleanupUploadState, directUploadProvider, projectsById, router]
|
||||
);
|
||||
|
||||
const handleDropFile = useCallback(
|
||||
|
||||
@@ -71,14 +71,16 @@ interface VideoPageContentProps {
|
||||
mode: VideoPageMode;
|
||||
videoId: string;
|
||||
projectId?: string;
|
||||
bunnyUploadsEnabled?: boolean;
|
||||
directUploadsEnabled?: boolean;
|
||||
directUploadProvider?: import('@/components/video-page/types').DirectUploadProvider;
|
||||
}
|
||||
|
||||
export function VideoPageContent({
|
||||
mode,
|
||||
videoId,
|
||||
projectId: propProjectId,
|
||||
bunnyUploadsEnabled = true,
|
||||
directUploadsEnabled = false,
|
||||
directUploadProvider = 'bunny',
|
||||
}: VideoPageContentProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -200,7 +202,8 @@ export function VideoPageContent({
|
||||
} = useVersionActions({
|
||||
projectId: propProjectId,
|
||||
videoId,
|
||||
bunnyUploadsEnabled,
|
||||
directUploadsEnabled,
|
||||
directUploadProvider,
|
||||
setVideo,
|
||||
activeVersionId,
|
||||
setActiveVersionId,
|
||||
@@ -282,6 +285,20 @@ export function VideoPageContent({
|
||||
if (!bunnyCdnHostname) return '';
|
||||
return `https://${bunnyCdnHostname}/${activeVersion.videoId}/playlist.m3u8`;
|
||||
}
|
||||
if (activeVersion.providerId === 'r2') {
|
||||
if (activeVersion.originalUrl.startsWith('/api/upload/video/')) {
|
||||
return activeVersion.originalUrl;
|
||||
}
|
||||
if (activeVersion.originalUrl.startsWith('videos/')) {
|
||||
const filename = activeVersion.originalUrl.slice('videos/'.length);
|
||||
return `/api/upload/video/${filename}`;
|
||||
}
|
||||
if (activeVersion.videoId.startsWith('videos/')) {
|
||||
const filename = activeVersion.videoId.slice('videos/'.length);
|
||||
return `/api/upload/video/${filename}`;
|
||||
}
|
||||
return activeVersion.originalUrl;
|
||||
}
|
||||
try {
|
||||
const url = new URL(activeVersion.originalUrl);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
@@ -551,7 +568,8 @@ export function VideoPageContent({
|
||||
const isBunnyVersion = activeVersion?.providerId === 'bunny';
|
||||
const showBunnyProcessingOverlay =
|
||||
isBunnyVersion && bunnyPlaybackState === 'processing' && !isReady;
|
||||
const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error';
|
||||
const isR2Version = activeVersion?.providerId === 'r2';
|
||||
const showBunnyErrorOverlay = (isBunnyVersion || isR2Version) && bunnyPlaybackState === 'error';
|
||||
|
||||
const confirmGuestName = useCallback(() => {
|
||||
if (!guestName.trim()) return;
|
||||
@@ -732,7 +750,7 @@ export function VideoPageContent({
|
||||
onDownload={headerActions.onDownload}
|
||||
projectId={projectId}
|
||||
videoId={videoId}
|
||||
bunnyUploadsEnabled={bunnyUploadsEnabled}
|
||||
directUploadsEnabled={directUploadsEnabled}
|
||||
showVersionDialog={showVersionDialog}
|
||||
setShowVersionDialog={setShowVersionDialog}
|
||||
newVersionMode={newVersionMode}
|
||||
|
||||
@@ -45,7 +45,9 @@ export const DownloadControls = memo(function DownloadControls({
|
||||
|
||||
const isVideoDownloadAvailable =
|
||||
videoCanDownload &&
|
||||
(activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
|
||||
(activeVersion.providerId === 'bunny' ||
|
||||
activeVersion.providerId === 'direct' ||
|
||||
activeVersion.providerId === 'r2');
|
||||
|
||||
if (activeVersion.providerId === 'bunny') {
|
||||
return (
|
||||
@@ -180,7 +182,9 @@ export const DownloadMenuItems = memo(function DownloadMenuItems({
|
||||
|
||||
const isVideoDownloadAvailable =
|
||||
videoCanDownload &&
|
||||
(activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
|
||||
(activeVersion.providerId === 'bunny' ||
|
||||
activeVersion.providerId === 'direct' ||
|
||||
activeVersion.providerId === 'r2');
|
||||
|
||||
if (activeVersion.providerId === 'bunny') {
|
||||
return (
|
||||
|
||||
@@ -51,6 +51,15 @@ interface UseCommentActionsParams extends CommentActionsConfig {
|
||||
fetchAssets: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getAudioUploadFilename(blob: Blob): string {
|
||||
const mime = blob.type.split(';')[0].trim().toLowerCase();
|
||||
if (mime === 'audio/mp4') return 'recording.m4a';
|
||||
if (mime === 'audio/ogg' || mime === 'audio/opus') return 'recording.ogg';
|
||||
if (mime === 'audio/mpeg') return 'recording.mp3';
|
||||
if (mime === 'audio/wav') return 'recording.wav';
|
||||
return 'recording.webm';
|
||||
}
|
||||
|
||||
export function useCommentActions({
|
||||
videoId,
|
||||
setVideo,
|
||||
@@ -430,7 +439,8 @@ export function useCommentActions({
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||
const recordedMime = mediaRecorder.mimeType || 'audio/webm';
|
||||
const blob = new Blob(audioChunksRef.current, { type: recordedMime });
|
||||
setAudioBlob(blob);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
if (recordingTimerRef.current) {
|
||||
@@ -472,7 +482,8 @@ export function useCommentActions({
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.webm');
|
||||
const uploadFilename = getAudioUploadFilename(audioBlob);
|
||||
formData.append('audio', audioBlob, uploadFilename);
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
@@ -514,7 +525,7 @@ export function useCommentActions({
|
||||
let voiceData: { url: string; duration: number } | undefined;
|
||||
if (audioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.webm');
|
||||
formData.append('audio', audioBlob, getAudioUploadFilename(audioBlob));
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
@@ -829,7 +840,8 @@ export function useCommentActions({
|
||||
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' });
|
||||
const recordedMime = mediaRecorder.mimeType || 'audio/webm';
|
||||
const blob = new Blob(replyAudioChunksRef.current, { type: recordedMime });
|
||||
setReplyAudioBlob(blob);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
if (replyRecordingTimerRef.current) {
|
||||
@@ -870,7 +882,7 @@ export function useCommentActions({
|
||||
setIsUploadingReplyAudio(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', replyAudioBlob, 'recording.webm');
|
||||
formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
@@ -912,7 +924,7 @@ export function useCommentActions({
|
||||
|
||||
if (replyAudioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', replyAudioBlob, 'recording.webm');
|
||||
formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
|
||||
@@ -67,7 +67,11 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
||||
toast.error('Download is disabled for this shared link');
|
||||
return;
|
||||
}
|
||||
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
|
||||
if (
|
||||
activeVersion.providerId !== 'bunny' &&
|
||||
activeVersion.providerId !== 'direct' &&
|
||||
activeVersion.providerId !== 'r2'
|
||||
) {
|
||||
toast.error('This video source does not support direct download');
|
||||
return;
|
||||
}
|
||||
@@ -97,6 +101,11 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
||||
}
|
||||
|
||||
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
|
||||
} else if (activeVersion.providerId === 'r2') {
|
||||
if (!activeVersion.originalUrl.startsWith('/api/upload/video/')) {
|
||||
throw new Error('Direct download URL is not allowed');
|
||||
}
|
||||
downloadUrl = activeVersion.originalUrl;
|
||||
} else {
|
||||
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
|
||||
if (!downloadUrl) {
|
||||
@@ -113,8 +122,9 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
||||
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
if (activeVersion.providerId === 'direct') {
|
||||
a.download = `${baseName}.mp4`;
|
||||
if (activeVersion.providerId === 'direct' || activeVersion.providerId === 'r2') {
|
||||
const ext = activeVersion.originalUrl.split('.').pop()?.toLowerCase() || 'mp4';
|
||||
a.download = `${baseName}.${ext}`;
|
||||
}
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/lib/video-providers';
|
||||
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||
|
||||
interface UseVersionActionsParams extends VersionActionsConfig {
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
@@ -21,7 +22,8 @@ interface UseVersionActionsParams extends VersionActionsConfig {
|
||||
export function useVersionActions({
|
||||
projectId,
|
||||
videoId,
|
||||
bunnyUploadsEnabled = true,
|
||||
directUploadsEnabled = false,
|
||||
directUploadProvider = 'bunny',
|
||||
setVideo,
|
||||
activeVersionId,
|
||||
setActiveVersionId,
|
||||
@@ -58,13 +60,111 @@ export function useVersionActions({
|
||||
}
|
||||
};
|
||||
|
||||
const uploadNewVersionFile = async (file: File, title: string) => {
|
||||
if (!projectId) throw new Error('Missing project');
|
||||
|
||||
if (directUploadProvider === 'r2') {
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const uploaded = await uploadVideoToR2(projectId, file, {
|
||||
onProgress: (progress) => {
|
||||
setNewVersionUploadProgress(progress);
|
||||
setNewVersionUploadStatus(`Uploading... ${progress}%`);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
finalVideoUrl: uploaded.proxyUrl,
|
||||
finalProviderId: 'r2',
|
||||
finalProviderVideoId: uploaded.objectKey,
|
||||
finalThumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
|
||||
finalDuration: uploaded.duration,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
objectKey: uploaded.objectKey,
|
||||
reservationId: uploaded.reservationId,
|
||||
pendingCleanup: {
|
||||
objectKey: uploaded.objectKey,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
reservationId: uploaded.reservationId,
|
||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
||||
const {
|
||||
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
||||
} = await initRes.json();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
setNewVersionUploadStatus('Uploading video...');
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
AuthorizationSignature: signature,
|
||||
AuthorizationExpire: expirationTime.toString(),
|
||||
VideoId: bunnyVideoId,
|
||||
LibraryId: libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: file.type,
|
||||
title,
|
||||
},
|
||||
onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
||||
setNewVersionUploadProgress(Number(percentage));
|
||||
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setNewVersionUploadStatus('Processing video...');
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
upload.start();
|
||||
});
|
||||
|
||||
return {
|
||||
finalVideoUrl: `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`,
|
||||
finalProviderId: 'bunny',
|
||||
finalProviderVideoId: bunnyVideoId,
|
||||
finalThumbnailUrl: bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
|
||||
: null,
|
||||
finalDuration: null as number | null,
|
||||
uploadToken,
|
||||
objectKey: null as string | null,
|
||||
reservationId: null as string | null,
|
||||
pendingCleanup: {
|
||||
bunnyVideoId,
|
||||
uploadToken,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreateVersion = async () => {
|
||||
if (!projectId) return;
|
||||
setIsCreatingVersion(true);
|
||||
setNewVersionUploadStatus('');
|
||||
setNewVersionUploadProgress(0);
|
||||
let uploadedBunnyVideoId: string | null = null;
|
||||
let uploadedBunnyUploadToken: string | null = null;
|
||||
let pendingCleanup:
|
||||
| {
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
}
|
||||
| {
|
||||
bunnyVideoId: string;
|
||||
uploadToken: string;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
try {
|
||||
let finalVideoUrl = '';
|
||||
@@ -72,6 +172,9 @@ export function useVersionActions({
|
||||
let finalProviderVideoId = '';
|
||||
let finalThumbnailUrl: string | null = null;
|
||||
let finalDuration: number | null = null;
|
||||
let uploadToken: string | null = null;
|
||||
let objectKey: string | null = null;
|
||||
let reservationId: string | null = null;
|
||||
|
||||
if (newVersionMode === 'url') {
|
||||
if (!newVersionSource) throw new Error('Invalid URL');
|
||||
@@ -82,7 +185,7 @@ export function useVersionActions({
|
||||
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
|
||||
finalDuration = meta?.duration || null;
|
||||
} else {
|
||||
if (!bunnyUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
|
||||
if (!directUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
|
||||
if (!newVersionFile) throw new Error('No file selected');
|
||||
let title = newVersionFile.name;
|
||||
if (newVersionLabel.trim()) {
|
||||
@@ -91,55 +194,16 @@ export function useVersionActions({
|
||||
title = title.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
||||
const {
|
||||
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
||||
} = await initRes.json();
|
||||
uploadedBunnyVideoId = bunnyVideoId;
|
||||
uploadedBunnyUploadToken = uploadToken;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
setNewVersionUploadStatus('Uploading video...');
|
||||
const upload = new tus.Upload(newVersionFile, {
|
||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
AuthorizationSignature: signature,
|
||||
AuthorizationExpire: expirationTime.toString(),
|
||||
VideoId: bunnyVideoId,
|
||||
LibraryId: libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: newVersionFile.type,
|
||||
title,
|
||||
},
|
||||
onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
||||
setNewVersionUploadProgress(Number(percentage));
|
||||
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setNewVersionUploadStatus('Processing video...');
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
upload.start();
|
||||
});
|
||||
|
||||
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`;
|
||||
finalProviderId = 'bunny';
|
||||
finalProviderVideoId = bunnyVideoId;
|
||||
finalThumbnailUrl = bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
|
||||
: null;
|
||||
const uploaded = await uploadNewVersionFile(newVersionFile, title);
|
||||
finalVideoUrl = uploaded.finalVideoUrl;
|
||||
finalProviderId = uploaded.finalProviderId;
|
||||
finalProviderVideoId = uploaded.finalProviderVideoId;
|
||||
finalThumbnailUrl = uploaded.finalThumbnailUrl;
|
||||
finalDuration = uploaded.finalDuration;
|
||||
uploadToken = uploaded.uploadToken;
|
||||
objectKey = uploaded.objectKey;
|
||||
reservationId = uploaded.reservationId;
|
||||
pendingCleanup = uploaded.pendingCleanup;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
|
||||
@@ -149,7 +213,9 @@ export function useVersionActions({
|
||||
videoUrl: finalVideoUrl,
|
||||
providerId: finalProviderId,
|
||||
providerVideoId: finalProviderVideoId,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
uploadToken,
|
||||
objectKey,
|
||||
reservationId,
|
||||
versionLabel: newVersionLabel.trim() || null,
|
||||
thumbnailUrl: finalThumbnailUrl,
|
||||
duration: finalDuration,
|
||||
@@ -180,24 +246,30 @@ export function useVersionActions({
|
||||
setNewVersionSource(null);
|
||||
setNewVersionFile(null);
|
||||
setNewVersionUploadStatus('');
|
||||
pendingCleanup = null;
|
||||
} catch (err) {
|
||||
const errorObj = err as Error;
|
||||
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoId: uploadedBunnyVideoId,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
}),
|
||||
}).catch((cleanupError) => {
|
||||
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
|
||||
});
|
||||
if (pendingCleanup && projectId) {
|
||||
if ('objectKey' in pendingCleanup) {
|
||||
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
|
||||
} else {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoId: pendingCleanup.bunnyVideoId,
|
||||
uploadToken: pendingCleanup.uploadToken,
|
||||
}),
|
||||
}).catch((cleanupError) => {
|
||||
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
|
||||
});
|
||||
}
|
||||
}
|
||||
console.error('Failed to create version:', errorObj);
|
||||
toast.error(errorObj.message || 'Failed to create version');
|
||||
} finally {
|
||||
setIsCreatingVersion(false);
|
||||
setNewVersionUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,34 +281,28 @@ export function useVersionActions({
|
||||
`/api/projects/${projectId}/videos/${videoId}/versions/${versionToDelete}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (res.ok) {
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
return { ...prev, versions: remaining };
|
||||
});
|
||||
|
||||
if (activeVersionId === versionToDelete) {
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
if (remaining.length > 0) {
|
||||
setActiveVersionId(remaining[0].id);
|
||||
} else {
|
||||
setActiveVersionId(null);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
setShowDeleteVersionDialog(false);
|
||||
setVersionToDelete(null);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || 'Failed to delete version');
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Failed to delete version');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete version');
|
||||
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
if (activeVersionId === versionToDelete && remaining.length > 0) {
|
||||
const nextActive = remaining.find((v) => v.isActive) ?? remaining[0];
|
||||
setActiveVersionId(nextActive.id);
|
||||
}
|
||||
return { ...prev, versions: remaining };
|
||||
});
|
||||
|
||||
setShowDeleteVersionDialog(false);
|
||||
setVersionToDelete(null);
|
||||
toast.success('Version deleted');
|
||||
} catch (err) {
|
||||
const errorObj = err as Error;
|
||||
console.error('Failed to delete version:', errorObj);
|
||||
toast.error(errorObj.message || 'Failed to delete version');
|
||||
} finally {
|
||||
setIsDeletingVersion(false);
|
||||
}
|
||||
@@ -259,10 +325,8 @@ export function useVersionActions({
|
||||
newVersionUploadStatus,
|
||||
handleNewVersionUrlChange,
|
||||
handleCreateVersion,
|
||||
|
||||
showDeleteVersionDialog,
|
||||
setShowDeleteVersionDialog,
|
||||
versionToDelete,
|
||||
setVersionToDelete,
|
||||
isDeletingVersion,
|
||||
handleDeleteVersion,
|
||||
|
||||
@@ -204,9 +204,10 @@ export function useVideoPlayer({
|
||||
if (!activeProviderId) return;
|
||||
const isYoutube = activeProviderId === 'youtube';
|
||||
const isBunny = activeProviderId === 'bunny';
|
||||
const isR2 = activeProviderId === 'r2';
|
||||
|
||||
if (isYoutube && !isApiLoaded) return;
|
||||
if (!isYoutube && !isBunny) return;
|
||||
if (!isYoutube && !isBunny && !isR2) return;
|
||||
|
||||
const currentVersionKey = `${activeProviderId ?? 'none'}:${activeVersionId ?? 'none'}`;
|
||||
const versionChanged = previousVersionKeyRef.current !== currentVersionKey;
|
||||
@@ -623,6 +624,132 @@ export function useVideoPlayer({
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
} else if (isR2) {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl) return;
|
||||
|
||||
let cachedDuration = 0;
|
||||
let destroyed = false;
|
||||
|
||||
const syncDuration = () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
setVideoDuration(videoEl.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProgress = () => {
|
||||
const current = videoEl.currentTime || 0;
|
||||
const duration =
|
||||
Number.isFinite(videoEl.duration) && videoEl.duration > 0
|
||||
? videoEl.duration
|
||||
: cachedDuration;
|
||||
scheduleWatchProgressSaveRef.current({
|
||||
progress: current,
|
||||
duration,
|
||||
immediate: true,
|
||||
force: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (destroyed) return;
|
||||
setBunnyPlaybackState('none');
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
||||
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||
}
|
||||
setIsReady(true);
|
||||
syncDuration();
|
||||
if (!videoEl.paused) {
|
||||
startBunnyFrameTracking();
|
||||
}
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
setIsPlaying(true);
|
||||
setBunnyPlaybackState('none');
|
||||
syncDuration();
|
||||
startBunnyFrameTracking();
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
setIsPlaying(false);
|
||||
stopBunnyFrameTracking();
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
stopBunnyFrameTracking();
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (!isDraggingRef.current) {
|
||||
setCurrentTime(videoEl.currentTime || 0);
|
||||
}
|
||||
syncDuration();
|
||||
};
|
||||
|
||||
const onVideoError = () => {
|
||||
if (destroyed) return;
|
||||
setBunnyPlaybackState('error');
|
||||
};
|
||||
|
||||
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.addEventListener('play', onPlay);
|
||||
videoEl.addEventListener('pause', onPause);
|
||||
videoEl.addEventListener('ended', onEnded);
|
||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.addEventListener('error', onVideoError);
|
||||
|
||||
const playbackSrc =
|
||||
embedUrl.startsWith('/') && typeof window !== 'undefined'
|
||||
? `${window.location.origin}${embedUrl}`
|
||||
: embedUrl;
|
||||
videoEl.src = playbackSrc;
|
||||
videoEl.load();
|
||||
|
||||
playerRef.current = {
|
||||
playVideo: () => {
|
||||
videoEl.play().catch((err) => console.error('Error playing video:', err));
|
||||
},
|
||||
pauseVideo: () => videoEl.pause(),
|
||||
seekTo: (time: number) => {
|
||||
videoEl.currentTime = time;
|
||||
},
|
||||
mute: () => {
|
||||
videoEl.muted = true;
|
||||
},
|
||||
unMute: () => {
|
||||
videoEl.muted = false;
|
||||
},
|
||||
isMuted: () => videoEl.muted,
|
||||
getCurrentTime: () => videoEl.currentTime || 0,
|
||||
getDuration: () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
|
||||
return cachedDuration;
|
||||
},
|
||||
getPlayerState: () =>
|
||||
videoEl.paused
|
||||
? (window.YT?.PlayerState?.PAUSED ?? 2)
|
||||
: (window.YT?.PlayerState?.PLAYING ?? 1),
|
||||
setPlaybackRate: (rate: number) => {
|
||||
videoEl.playbackRate = rate;
|
||||
},
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
stopBunnyFrameTracking();
|
||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
videoEl.removeEventListener('pause', onPause);
|
||||
videoEl.removeEventListener('ended', onEnded);
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.removeEventListener('error', onVideoError);
|
||||
videoEl.removeAttribute('src');
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -633,7 +760,7 @@ export function useVideoPlayer({
|
||||
} else {
|
||||
window.onYouTubeIframeAPIReady = initPlayer;
|
||||
}
|
||||
} else if (isBunny) {
|
||||
} else if (isBunny || isR2) {
|
||||
initPlayer();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
@@ -170,7 +170,7 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
onMouseLeave={handleVideoMouseLeave}
|
||||
>
|
||||
<div className={cn('relative w-full h-full', isFullscreenMode && 'absolute inset-0')}>
|
||||
{activeProviderId === 'bunny' ? (
|
||||
{activeProviderId === 'bunny' || activeProviderId === 'r2' ? (
|
||||
<div
|
||||
ref={bunnyViewportRef}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black"
|
||||
@@ -259,7 +259,9 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
Unable To Load Video
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The Bunny stream is unavailable right now. Please refresh this page in a moment.
|
||||
{activeProviderId === 'r2'
|
||||
? 'This video file could not be loaded. Try refreshing the page or re-uploading the version.'
|
||||
: 'The Bunny stream is unavailable right now. Please refresh this page in a moment.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,10 +185,13 @@ export interface CommentActionsConfig {
|
||||
videoId: string;
|
||||
}
|
||||
|
||||
export type DirectUploadProvider = 'bunny' | 'r2';
|
||||
|
||||
export interface VersionActionsConfig {
|
||||
projectId?: string;
|
||||
videoId: string;
|
||||
bunnyUploadsEnabled?: boolean;
|
||||
directUploadsEnabled?: boolean;
|
||||
directUploadProvider?: DirectUploadProvider;
|
||||
}
|
||||
|
||||
export interface VideoPageHeaderActions {
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { VideoSource } from '@/lib/video-providers';
|
||||
interface VersionActionsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bunnyUploadsEnabled: boolean;
|
||||
directUploadsEnabled: boolean;
|
||||
newVersionMode: 'url' | 'file';
|
||||
onNewVersionModeChange: (mode: 'url' | 'file') => void;
|
||||
newVersionUrl: string;
|
||||
@@ -49,7 +49,7 @@ interface VersionActionsDialogProps {
|
||||
export const VersionActionsDialog = memo(function VersionActionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
bunnyUploadsEnabled,
|
||||
directUploadsEnabled,
|
||||
newVersionMode,
|
||||
onNewVersionModeChange,
|
||||
newVersionUrl,
|
||||
@@ -88,10 +88,10 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
|
||||
className="mb-2"
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
className={`grid w-full ${directUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
<TabsTrigger value="url">Link URL</TabsTrigger>
|
||||
{bunnyUploadsEnabled ? <TabsTrigger value="file">Upload File</TabsTrigger> : null}
|
||||
{directUploadsEnabled ? <TabsTrigger value="file">Upload File</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ interface VideoPageHeaderProps {
|
||||
onDownload: (preference?: BunnyDownloadPreference) => void;
|
||||
projectId?: string;
|
||||
videoId: string;
|
||||
bunnyUploadsEnabled: boolean;
|
||||
directUploadsEnabled: boolean;
|
||||
showVersionDialog: boolean;
|
||||
setShowVersionDialog: (open: boolean) => void;
|
||||
newVersionMode: 'url' | 'file';
|
||||
@@ -105,7 +105,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
onDownload,
|
||||
projectId,
|
||||
videoId,
|
||||
bunnyUploadsEnabled,
|
||||
directUploadsEnabled,
|
||||
showVersionDialog,
|
||||
setShowVersionDialog,
|
||||
newVersionMode,
|
||||
@@ -261,7 +261,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
<VersionActionsDialog
|
||||
open={showVersionDialog}
|
||||
onOpenChange={setShowVersionDialog}
|
||||
bunnyUploadsEnabled={bunnyUploadsEnabled}
|
||||
directUploadsEnabled={directUploadsEnabled}
|
||||
newVersionMode={newVersionMode}
|
||||
onNewVersionModeChange={setNewVersionMode}
|
||||
newVersionUrl={newVersionUrl}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
|
||||
|
||||
export type R2VideoInitResponse = {
|
||||
presignedPutUrl: string;
|
||||
objectKey: string;
|
||||
proxyUrl: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
contentType: string;
|
||||
thumbnailPresignedPutUrl: string;
|
||||
thumbnailObjectKey: string;
|
||||
thumbnailProxyUrl: string;
|
||||
};
|
||||
|
||||
export type R2VideoUploadResult = R2VideoInitResponse & {
|
||||
duration: number | null;
|
||||
thumbnailUrl: string | null;
|
||||
};
|
||||
|
||||
type UploadProgressHandler = (progress: number) => void;
|
||||
|
||||
function uploadBytesWithProgress(
|
||||
url: string,
|
||||
body: Blob | File,
|
||||
contentType: string,
|
||||
onProgress?: UploadProgressHandler
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', url);
|
||||
xhr.setRequestHeader('Content-Type', contentType);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!onProgress || !event.lengthComputable) return;
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(
|
||||
new Error(
|
||||
'Network error during upload. If you use direct S3/R2 uploads, configure bucket CORS to allow PUT from this site origin.'
|
||||
)
|
||||
);
|
||||
};
|
||||
xhr.onabort = () => reject(new Error('Upload aborted'));
|
||||
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
async function readVideoDuration(file: File): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
|
||||
const cleanup = () => {
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
const duration =
|
||||
Number.isFinite(video.duration) && video.duration > 0 ? Math.round(video.duration) : null;
|
||||
cleanup();
|
||||
resolve(duration);
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
video.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export async function initR2VideoUpload(
|
||||
projectId: string,
|
||||
file: File
|
||||
): Promise<R2VideoInitResponse> {
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/r2-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
sizeBytes: file.size,
|
||||
}),
|
||||
});
|
||||
|
||||
const initPayload = (await initRes.json().catch(() => null)) as {
|
||||
data?: R2VideoInitResponse;
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!initRes.ok || !initPayload?.data) {
|
||||
throw new Error(initPayload?.error || 'Failed to initialize video upload');
|
||||
}
|
||||
|
||||
return initPayload.data;
|
||||
}
|
||||
|
||||
export async function cleanupPendingR2VideoUpload(
|
||||
projectId: string,
|
||||
input: {
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
thumbnailObjectKey?: string | null;
|
||||
},
|
||||
keepalive = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/videos/r2-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
objectKey: input.objectKey,
|
||||
uploadToken: input.uploadToken,
|
||||
reservationId: input.reservationId,
|
||||
thumbnailObjectKey: input.thumbnailObjectKey ?? undefined,
|
||||
}),
|
||||
keepalive,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup pending R2 video upload:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadVideoToR2(
|
||||
projectId: string,
|
||||
file: File,
|
||||
options?: { onProgress?: UploadProgressHandler }
|
||||
): Promise<R2VideoUploadResult> {
|
||||
const init = await initR2VideoUpload(projectId, file);
|
||||
|
||||
const cleanupInput = {
|
||||
objectKey: init.objectKey,
|
||||
uploadToken: init.uploadToken,
|
||||
reservationId: init.reservationId,
|
||||
thumbnailObjectKey: init.thumbnailObjectKey,
|
||||
};
|
||||
|
||||
try {
|
||||
await uploadBytesWithProgress(
|
||||
init.presignedPutUrl,
|
||||
file,
|
||||
init.contentType,
|
||||
options?.onProgress
|
||||
);
|
||||
} catch (error) {
|
||||
await cleanupPendingR2VideoUpload(projectId, cleanupInput);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [duration, thumbnailBlob] = await Promise.all([
|
||||
readVideoDuration(file),
|
||||
captureVideoThumbnail(file),
|
||||
]);
|
||||
|
||||
let thumbnailUrl: string | null = null;
|
||||
if (thumbnailBlob) {
|
||||
try {
|
||||
await uploadBytesWithProgress(init.thumbnailPresignedPutUrl, thumbnailBlob, 'image/jpeg');
|
||||
thumbnailUrl = init.thumbnailProxyUrl;
|
||||
} catch (error) {
|
||||
console.warn('Failed to upload video thumbnail:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...init, duration, thumbnailUrl };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
const THUMBNAIL_MAX_WIDTH = 640;
|
||||
const DEFAULT_SEEK_SECONDS = 1;
|
||||
|
||||
export async function captureVideoThumbnail(
|
||||
file: File,
|
||||
seekSeconds = DEFAULT_SEEK_SECONDS
|
||||
): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
let settled = false;
|
||||
|
||||
const finish = (blob: Blob | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
resolve(blob);
|
||||
};
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : 0;
|
||||
const target =
|
||||
duration > 0 ? Math.min(Math.max(seekSeconds, 0), Math.max(0, duration - 0.1)) : 0;
|
||||
video.currentTime = target;
|
||||
};
|
||||
|
||||
video.onseeked = () => {
|
||||
try {
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
if (width <= 0 || height <= 0) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = Math.min(1, THUMBNAIL_MAX_WIDTH / width);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(width * scale);
|
||||
canvas.height = Math.round(height * scale);
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob((blob) => finish(blob), 'image/jpeg', 0.85);
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
};
|
||||
|
||||
video.onerror = () => finish(null);
|
||||
video.src = objectUrl;
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
function readBooleanEnv(name: string, defaultValue: boolean): boolean {
|
||||
const value = process.env[name];
|
||||
if (!value) return defaultValue;
|
||||
@@ -9,6 +11,20 @@ function readBooleanEnv(name: string, defaultValue: boolean): boolean {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
let warnedAboutConflictingUploadFlags = false;
|
||||
|
||||
function warnIfConflictingDirectUploadFlags(): void {
|
||||
if (warnedAboutConflictingUploadFlags) return;
|
||||
if (!isS3VideoUploadsFeatureEnabled() || !isBunnyUploadsFeatureEnabled()) return;
|
||||
if (!hasR2Config() || !hasBunnyUploadsConfig()) return;
|
||||
|
||||
warnedAboutConflictingUploadFlags = true;
|
||||
logError(
|
||||
'OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_ENABLE_BUNNY_UPLOADS are both enabled with valid config. S3 video uploads take precedence; disable Bunny uploads for self-hosted deployments.',
|
||||
new Error('Conflicting direct upload feature flags')
|
||||
);
|
||||
}
|
||||
|
||||
export function isStripeFeatureEnabled() {
|
||||
return readBooleanEnv('OPENFRAME_ENABLE_STRIPE', true);
|
||||
}
|
||||
@@ -32,10 +48,52 @@ export function hasBunnyUploadsConfig() {
|
||||
);
|
||||
}
|
||||
|
||||
export function isS3VideoUploadsFeatureEnabled() {
|
||||
return readBooleanEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', false);
|
||||
}
|
||||
|
||||
export function hasR2Config() {
|
||||
return Boolean(
|
||||
process.env.R2_ACCESS_KEY_ID &&
|
||||
process.env.R2_SECRET_ACCESS_KEY &&
|
||||
process.env.R2_BUCKET_NAME &&
|
||||
(process.env.R2_ENDPOINT || process.env.R2_ACCOUNT_ID)
|
||||
);
|
||||
}
|
||||
|
||||
export function isS3VideoUploadsEnabled() {
|
||||
warnIfConflictingDirectUploadFlags();
|
||||
return isS3VideoUploadsFeatureEnabled() && hasR2Config();
|
||||
}
|
||||
|
||||
export function isBunnyUploadsEnabled() {
|
||||
if (isS3VideoUploadsEnabled()) {
|
||||
return false;
|
||||
}
|
||||
return isBunnyUploadsFeatureEnabled() && hasBunnyUploadsConfig();
|
||||
}
|
||||
|
||||
export function isDirectFileUploadEnabled() {
|
||||
return isS3VideoUploadsEnabled() || isBunnyUploadsEnabled();
|
||||
}
|
||||
|
||||
export function getMaxVideoUploadBytes(): bigint {
|
||||
const raw = process.env.OPENFRAME_MAX_VIDEO_UPLOAD_BYTES?.trim();
|
||||
if (!raw) {
|
||||
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = BigInt(raw);
|
||||
if (parsed <= BigInt(0)) {
|
||||
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||
}
|
||||
}
|
||||
|
||||
export function isInviteCodeRequired() {
|
||||
return readBooleanEnv('OPENFRAME_REQUIRE_INVITE_CODE', true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Converts values for JSON responses (e.g. Prisma BigInt fields).
|
||||
*/
|
||||
export function toJsonSafe<T>(value: T): T {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_key, val) => (typeof val === 'bigint' ? val.toString() : val))
|
||||
) as T;
|
||||
}
|
||||
+30
-4
@@ -2,6 +2,7 @@ import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { db } from '@/lib/db';
|
||||
import { runWithConcurrency } from '@/lib/async-pool';
|
||||
import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
/** The path prefix for images served by the upload API. */
|
||||
@@ -32,7 +33,8 @@ export function mediaUrlToKey(url: string): string | null {
|
||||
const filename = url.slice(IMAGE_PATH_PREFIX.length);
|
||||
return filename ? `images/${filename}` : null;
|
||||
}
|
||||
return null;
|
||||
|
||||
return videoProxyPathToObjectKey(url);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +82,7 @@ export async function deleteMediaFilesBestEffort(mediaUrls: string[]): Promise<R
|
||||
* Collect all media URLs from comments under a given video (all versions).
|
||||
*/
|
||||
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
|
||||
const [comments, assets] = await Promise.all([
|
||||
const [comments, assets, versions] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
@@ -95,6 +97,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
||||
},
|
||||
select: { sourceUrl: true },
|
||||
}),
|
||||
db.videoVersion.findMany({
|
||||
where: { videoParentId: videoId, providerId: 'r2' },
|
||||
select: { originalUrl: true, thumbnailUrl: true },
|
||||
}),
|
||||
]);
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
@@ -104,6 +110,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
||||
assets.forEach((asset) => {
|
||||
if (asset.sourceUrl) urls.push(asset.sourceUrl);
|
||||
});
|
||||
versions.forEach((version) => {
|
||||
if (version.originalUrl) urls.push(version.originalUrl);
|
||||
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
@@ -111,7 +121,7 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
||||
* Collect all media URLs from comments under all videos in a project.
|
||||
*/
|
||||
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
|
||||
const [comments, assets] = await Promise.all([
|
||||
const [comments, assets, versions] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
@@ -126,6 +136,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
||||
},
|
||||
select: { sourceUrl: true },
|
||||
}),
|
||||
db.videoVersion.findMany({
|
||||
where: { providerId: 'r2', video: { projectId } },
|
||||
select: { originalUrl: true, thumbnailUrl: true },
|
||||
}),
|
||||
]);
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
@@ -135,6 +149,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
||||
assets.forEach((asset) => {
|
||||
if (asset.sourceUrl) urls.push(asset.sourceUrl);
|
||||
});
|
||||
versions.forEach((version) => {
|
||||
if (version.originalUrl) urls.push(version.originalUrl);
|
||||
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
@@ -142,7 +160,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
||||
* Collect all media URLs from comments under all projects in a workspace.
|
||||
*/
|
||||
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
|
||||
const [comments, assets] = await Promise.all([
|
||||
const [comments, assets, versions] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
|
||||
@@ -157,6 +175,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
|
||||
},
|
||||
select: { sourceUrl: true },
|
||||
}),
|
||||
db.videoVersion.findMany({
|
||||
where: { providerId: 'r2', video: { project: { workspaceId } } },
|
||||
select: { originalUrl: true, thumbnailUrl: true },
|
||||
}),
|
||||
]);
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
@@ -166,6 +188,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
|
||||
assets.forEach((asset) => {
|
||||
if (asset.sourceUrl) urls.push(asset.sourceUrl);
|
||||
});
|
||||
versions.forEach((version) => {
|
||||
if (version.originalUrl) urls.push(version.originalUrl);
|
||||
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
|
||||
@@ -168,13 +168,19 @@ export async function proxyR2MediaObject({
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
setIfPresent(headers, 'Content-Type', objectResponse.ContentType || fallbackContentType);
|
||||
const resolvedContentType =
|
||||
objectResponse.ContentType && objectResponse.ContentType !== 'application/octet-stream'
|
||||
? objectResponse.ContentType
|
||||
: fallbackContentType;
|
||||
setIfPresent(headers, 'Content-Type', resolvedContentType);
|
||||
setIfPresent(headers, 'Content-Length', objectResponse.ContentLength);
|
||||
setIfPresent(headers, 'Content-Range', objectResponse.ContentRange);
|
||||
setIfPresent(headers, 'ETag', objectResponse.ETag);
|
||||
setIfPresent(headers, 'Last-Modified', objectResponse.LastModified?.toUTCString());
|
||||
setIfPresent(headers, 'Accept-Ranges', objectResponse.AcceptRanges || 'bytes');
|
||||
headers.set('Cache-Control', cacheControl);
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
headers.set('Content-Disposition', 'inline');
|
||||
|
||||
if (extraHeaders) {
|
||||
for (const [name, value] of Object.entries(extraHeaders)) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export type CreateR2UploadSessionInput = {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
billedUserId: string;
|
||||
objectKey: string;
|
||||
thumbnailObjectKey: string;
|
||||
declaredSizeBytes: bigint;
|
||||
contentType: string;
|
||||
reservationId: string | null;
|
||||
uploadJti: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export async function createR2UploadSession(input: CreateR2UploadSessionInput) {
|
||||
return db.videoUploadSession.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
projectId: input.projectId,
|
||||
billedUserId: input.billedUserId,
|
||||
objectKey: input.objectKey,
|
||||
thumbnailObjectKey: input.thumbnailObjectKey,
|
||||
declaredSizeBytes: input.declaredSizeBytes,
|
||||
contentType: input.contentType,
|
||||
reservationId: input.reservationId,
|
||||
uploadJti: input.uploadJti,
|
||||
expiresAt: input.expiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelR2UploadSession(sessionId: string) {
|
||||
return db.videoUploadSession.updateMany({
|
||||
where: {
|
||||
id: sessionId,
|
||||
status: 'INITIATED',
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
data: {
|
||||
status: 'CANCELLED',
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const R2_UPLOAD_TOKEN_TYPE = 'r2-upload';
|
||||
const DEFAULT_TOKEN_TTL_SECONDS = 60 * 60;
|
||||
|
||||
interface R2UploadTokenPayload {
|
||||
typ: typeof R2_UPLOAD_TOKEN_TYPE;
|
||||
uid: string;
|
||||
pid: string;
|
||||
key: string;
|
||||
sid: string;
|
||||
jti: string;
|
||||
tkey: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface R2UploadTokenSubject {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
objectKey: string;
|
||||
sessionId?: string;
|
||||
tokenId?: string;
|
||||
thumbnailObjectKey?: string;
|
||||
}
|
||||
|
||||
function getR2UploadTokenSecret(): string {
|
||||
const secret = process.env.R2_UPLOAD_TOKEN_SECRET || process.env.NEXTAUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('Missing R2_UPLOAD_TOKEN_SECRET or NEXTAUTH_SECRET.');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function signPayload(payload: string, secret: string): string {
|
||||
return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
||||
}
|
||||
|
||||
function isValidPayload(value: unknown): value is R2UploadTokenPayload {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const payload = value as Partial<R2UploadTokenPayload>;
|
||||
return (
|
||||
payload.typ === R2_UPLOAD_TOKEN_TYPE &&
|
||||
typeof payload.uid === 'string' &&
|
||||
typeof payload.pid === 'string' &&
|
||||
typeof payload.key === 'string' &&
|
||||
typeof payload.sid === 'string' &&
|
||||
typeof payload.jti === 'string' &&
|
||||
typeof payload.tkey === 'string' &&
|
||||
typeof payload.iat === 'number' &&
|
||||
Number.isFinite(payload.iat) &&
|
||||
typeof payload.exp === 'number' &&
|
||||
Number.isFinite(payload.exp)
|
||||
);
|
||||
}
|
||||
|
||||
export function createR2UploadToken(
|
||||
subject: R2UploadTokenSubject & {
|
||||
sessionId: string;
|
||||
tokenId: string;
|
||||
thumbnailObjectKey: string;
|
||||
},
|
||||
ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS
|
||||
): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: R2UploadTokenPayload = {
|
||||
typ: R2_UPLOAD_TOKEN_TYPE,
|
||||
uid: subject.userId,
|
||||
pid: subject.projectId,
|
||||
key: subject.objectKey,
|
||||
sid: subject.sessionId,
|
||||
jti: subject.tokenId,
|
||||
tkey: subject.thumbnailObjectKey,
|
||||
iat: now,
|
||||
exp: now + ttlSeconds,
|
||||
};
|
||||
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
const signature = signPayload(encodedPayload, getR2UploadTokenSecret());
|
||||
return `${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyR2UploadToken(token: string, subject: R2UploadTokenSubject): boolean {
|
||||
const payload = parseR2UploadToken(token);
|
||||
if (!payload) return false;
|
||||
|
||||
if (
|
||||
payload.uid !== subject.userId ||
|
||||
payload.pid !== subject.projectId ||
|
||||
payload.key !== subject.objectKey
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (subject.sessionId && payload.sid !== subject.sessionId) return false;
|
||||
if (subject.tokenId && payload.jti !== subject.tokenId) return false;
|
||||
if (subject.thumbnailObjectKey && payload.tkey !== subject.thumbnailObjectKey) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseR2UploadToken(token: string): R2UploadTokenPayload | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
|
||||
const [encodedPayload, providedSignature] = parts;
|
||||
if (!encodedPayload || !providedSignature) return null;
|
||||
|
||||
const expectedSignature = signPayload(encodedPayload, getR2UploadTokenSecret());
|
||||
const providedBuffer = Buffer.from(providedSignature, 'utf8');
|
||||
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
||||
|
||||
if (providedBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) return null;
|
||||
|
||||
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf8');
|
||||
const payloadUnknown: unknown = JSON.parse(payloadJson);
|
||||
|
||||
if (!isValidPayload(payloadUnknown)) return null;
|
||||
|
||||
const payload = payloadUnknown;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < now) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { getMaxVideoUploadBytes } from '@/lib/feature-flags';
|
||||
import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2';
|
||||
import { parseR2UploadToken, verifyR2UploadToken } from '@/lib/r2-upload-token';
|
||||
import {
|
||||
objectKeyToVideoProxyPath,
|
||||
videoProxyPathToObjectKey,
|
||||
} from '@/lib/video-upload-validation';
|
||||
|
||||
export type R2VideoFinalizeInput = {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
videoUrl: string;
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
};
|
||||
|
||||
export type R2VideoFinalizeResult =
|
||||
| {
|
||||
ok: true;
|
||||
sizeBytes: bigint;
|
||||
proxyUrl: string;
|
||||
objectKey: string;
|
||||
sessionId: string;
|
||||
reservationId: string | null;
|
||||
billedUserId: string;
|
||||
thumbnailObjectKey: string;
|
||||
thumbnailProxyUrl: string;
|
||||
}
|
||||
| { ok: false; error: string; status: 400 | 403 };
|
||||
|
||||
function hasKnownVideoMagicBytes(bytes: Uint8Array): boolean {
|
||||
if (bytes.length >= 12) {
|
||||
const box = String.fromCharCode(bytes[4] ?? 0, bytes[5] ?? 0, bytes[6] ?? 0, bytes[7] ?? 0);
|
||||
if (box === 'ftyp') return true;
|
||||
}
|
||||
if (
|
||||
bytes.length >= 4 &&
|
||||
bytes[0] === 0x1a &&
|
||||
bytes[1] === 0x45 &&
|
||||
bytes[2] === 0xdf &&
|
||||
bytes[3] === 0xa3
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
bytes.length >= 4 &&
|
||||
bytes[0] === 0x4f &&
|
||||
bytes[1] === 0x67 &&
|
||||
bytes[2] === 0x67 &&
|
||||
bytes[3] === 0x53
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x41 &&
|
||||
bytes[9] === 0x56 &&
|
||||
bytes[10] === 0x49 &&
|
||||
bytes[11] === 0x20
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function finalizeR2VideoUpload(
|
||||
input: R2VideoFinalizeInput
|
||||
): Promise<R2VideoFinalizeResult> {
|
||||
const { userId, projectId, videoUrl, objectKey, uploadToken } = input;
|
||||
|
||||
if (!objectKey || !uploadToken) {
|
||||
return { ok: false, error: 'R2 uploads must include objectKey and uploadToken', status: 400 };
|
||||
}
|
||||
|
||||
const expectedProxyUrl = objectKeyToVideoProxyPath(objectKey);
|
||||
if (!expectedProxyUrl) {
|
||||
return { ok: false, error: 'Invalid object key', status: 400 };
|
||||
}
|
||||
|
||||
if (videoUrl !== expectedProxyUrl) {
|
||||
return { ok: false, error: 'Video URL does not match the uploaded object', status: 400 };
|
||||
}
|
||||
|
||||
const keyFromUrl = videoProxyPathToObjectKey(videoUrl);
|
||||
if (!keyFromUrl || keyFromUrl !== objectKey) {
|
||||
return { ok: false, error: 'Video URL does not match the uploaded object', status: 400 };
|
||||
}
|
||||
|
||||
const tokenPayload = parseR2UploadToken(uploadToken);
|
||||
if (!tokenPayload) {
|
||||
return { ok: false, error: 'Invalid upload token', status: 403 };
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyR2UploadToken(uploadToken, {
|
||||
userId,
|
||||
projectId,
|
||||
objectKey,
|
||||
sessionId: tokenPayload.sid,
|
||||
tokenId: tokenPayload.jti,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return { ok: false, error: 'Invalid upload token', status: 403 };
|
||||
}
|
||||
|
||||
const uploadSession = await db.videoUploadSession.findFirst({
|
||||
where: {
|
||||
id: tokenPayload.sid,
|
||||
uploadJti: tokenPayload.jti,
|
||||
status: 'INITIATED',
|
||||
userId,
|
||||
projectId,
|
||||
objectKey,
|
||||
thumbnailObjectKey: tokenPayload.tkey,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
billedUserId: true,
|
||||
reservationId: true,
|
||||
declaredSizeBytes: true,
|
||||
thumbnailObjectKey: true,
|
||||
},
|
||||
});
|
||||
if (!uploadSession) {
|
||||
return { ok: false, error: 'Invalid upload token', status: 403 };
|
||||
}
|
||||
|
||||
const thumbnailFilename = uploadSession.thumbnailObjectKey.startsWith('images/')
|
||||
? uploadSession.thumbnailObjectKey.slice('images/'.length)
|
||||
: '';
|
||||
if (!thumbnailFilename) {
|
||||
return { ok: false, error: 'Invalid upload token', status: 403 };
|
||||
}
|
||||
|
||||
const cancelPendingUpload = async (error: string): Promise<R2VideoFinalizeResult> => {
|
||||
await db.videoUploadSession.updateMany({
|
||||
where: { id: uploadSession.id, status: 'INITIATED' },
|
||||
data: { status: 'CANCELLED', consumedAt: new Date() },
|
||||
});
|
||||
await Promise.all([
|
||||
deleteVideoObject(objectKey).catch(() => undefined),
|
||||
deleteR2Object(uploadSession.thumbnailObjectKey).catch(() => undefined),
|
||||
]);
|
||||
return { ok: false, error, status: 400 };
|
||||
};
|
||||
|
||||
const head = await headVideoObject(objectKey);
|
||||
if (!head || head.contentLength <= BigInt(0)) {
|
||||
return cancelPendingUpload('Uploaded video was not found in storage');
|
||||
}
|
||||
|
||||
if (head.contentLength > getMaxVideoUploadBytes()) {
|
||||
return cancelPendingUpload('Uploaded video exceeds the maximum allowed upload size');
|
||||
}
|
||||
|
||||
if (head.contentLength > uploadSession.declaredSizeBytes) {
|
||||
return cancelPendingUpload('Uploaded video size does not match upload request');
|
||||
}
|
||||
|
||||
const headerBytes = await readVideoObjectBytes(objectKey, 64);
|
||||
if (!headerBytes || !hasKnownVideoMagicBytes(headerBytes)) {
|
||||
return cancelPendingUpload('Uploaded file is not a valid video');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sizeBytes: head.contentLength,
|
||||
proxyUrl: expectedProxyUrl,
|
||||
objectKey,
|
||||
sessionId: uploadSession.id,
|
||||
reservationId: uploadSession.reservationId,
|
||||
billedUserId: uploadSession.billedUserId,
|
||||
thumbnailObjectKey: uploadSession.thumbnailObjectKey,
|
||||
thumbnailProxyUrl: `/api/upload/image/${thumbnailFilename}`,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,29 @@
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
GetBucketCorsCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
PutBucketCorsCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { VIDEO_OBJECT_KEY_PREFIX } from '@/lib/video-upload-validation';
|
||||
|
||||
const IMAGE_OBJECT_KEY_PREFIX = 'images/';
|
||||
|
||||
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID;
|
||||
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID;
|
||||
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY;
|
||||
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME ?? '';
|
||||
const R2_ENDPOINT = process.env.R2_ENDPOINT;
|
||||
const R2_PRESIGN_ENDPOINT = process.env.R2_PRESIGN_ENDPOINT;
|
||||
const R2_PUBLIC_BASE_URL = process.env.R2_PUBLIC_BASE_URL;
|
||||
|
||||
let cachedR2Client: S3Client | null = null;
|
||||
let cachedR2PresignClient: S3Client | null = null;
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
@@ -38,6 +49,13 @@ function getR2Endpoint(): string {
|
||||
return `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
|
||||
}
|
||||
|
||||
function getR2PresignEndpoint(): string {
|
||||
if (R2_PRESIGN_ENDPOINT) {
|
||||
return trimTrailingSlashes(R2_PRESIGN_ENDPOINT);
|
||||
}
|
||||
return getR2Endpoint();
|
||||
}
|
||||
|
||||
function getOrCreateR2Client(): S3Client {
|
||||
if (cachedR2Client) {
|
||||
return cachedR2Client;
|
||||
@@ -47,6 +65,8 @@ function getOrCreateR2Client(): S3Client {
|
||||
region: 'auto',
|
||||
endpoint: getR2Endpoint(),
|
||||
forcePathStyle: Boolean(R2_ENDPOINT),
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: requireStorageValue('R2_ACCESS_KEY_ID', R2_ACCESS_KEY_ID),
|
||||
secretAccessKey: requireStorageValue('R2_SECRET_ACCESS_KEY', R2_SECRET_ACCESS_KEY),
|
||||
@@ -56,6 +76,26 @@ function getOrCreateR2Client(): S3Client {
|
||||
return cachedR2Client;
|
||||
}
|
||||
|
||||
function getOrCreateR2PresignClient(): S3Client {
|
||||
if (cachedR2PresignClient) {
|
||||
return cachedR2PresignClient;
|
||||
}
|
||||
|
||||
cachedR2PresignClient = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: getR2PresignEndpoint(),
|
||||
forcePathStyle: Boolean(R2_PRESIGN_ENDPOINT || R2_ENDPOINT),
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: requireStorageValue('R2_ACCESS_KEY_ID', R2_ACCESS_KEY_ID),
|
||||
secretAccessKey: requireStorageValue('R2_SECRET_ACCESS_KEY', R2_SECRET_ACCESS_KEY),
|
||||
},
|
||||
});
|
||||
|
||||
return cachedR2PresignClient;
|
||||
}
|
||||
|
||||
export const r2Client = new Proxy({} as S3Client, {
|
||||
get(_target, prop, receiver) {
|
||||
if (prop === 'destroy') {
|
||||
@@ -63,6 +103,9 @@ export const r2Client = new Proxy({} as S3Client, {
|
||||
if (!cachedR2Client) return;
|
||||
cachedR2Client.destroy();
|
||||
cachedR2Client = null;
|
||||
if (!cachedR2PresignClient) return;
|
||||
cachedR2PresignClient.destroy();
|
||||
cachedR2PresignClient = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,4 +170,230 @@ export async function uploadAudio(
|
||||
return getR2PublicObjectUrl(key);
|
||||
}
|
||||
|
||||
const DEFAULT_PRESIGNED_PUT_TTL_SECONDS = 60 * 60;
|
||||
|
||||
export function getR2UploadCorsOrigins(extraOrigins: string[] = []): string[] {
|
||||
const origins = new Set<string>();
|
||||
|
||||
for (const raw of [process.env.NEXTAUTH_URL, process.env.NEXT_PUBLIC_APP_URL, ...extraOrigins]) {
|
||||
if (!raw?.trim()) continue;
|
||||
try {
|
||||
origins.add(new URL(trimTrailingSlashes(raw.trim())).origin);
|
||||
} catch {
|
||||
// Ignore invalid origin URLs.
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
origins.add('http://localhost:3000');
|
||||
origins.add('http://127.0.0.1:3000');
|
||||
}
|
||||
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
function corsRulesMatchOrigins(
|
||||
existing:
|
||||
| {
|
||||
AllowedOrigins?: string[];
|
||||
AllowedMethods?: string[];
|
||||
}
|
||||
| undefined,
|
||||
requiredOrigins: string[]
|
||||
): boolean {
|
||||
if (!existing?.AllowedOrigins?.length || !existing.AllowedMethods?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedOrigins = new Set(existing.AllowedOrigins);
|
||||
const methods = new Set(existing.AllowedMethods.map((method) => method.toUpperCase()));
|
||||
const hasRequiredOrigins = requiredOrigins.every((origin) => allowedOrigins.has(origin));
|
||||
const hasPut = methods.has('PUT');
|
||||
const hasGet = methods.has('GET') || methods.has('HEAD');
|
||||
|
||||
return hasRequiredOrigins && hasPut && hasGet;
|
||||
}
|
||||
|
||||
export async function ensureR2UploadCors(extraOrigins: string[] = []): Promise<string[]> {
|
||||
const allowedOrigins = getR2UploadCorsOrigins(extraOrigins);
|
||||
if (allowedOrigins.length === 0) {
|
||||
throw new Error(
|
||||
'No origins configured for R2 upload CORS (set NEXTAUTH_URL or NEXT_PUBLIC_APP_URL)'
|
||||
);
|
||||
}
|
||||
|
||||
const managedRule = {
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: ['GET', 'PUT', 'HEAD'],
|
||||
AllowedHeaders: ['*'],
|
||||
ExposeHeaders: ['ETag'],
|
||||
MaxAgeSeconds: 3600,
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = await r2Client.send(
|
||||
new GetBucketCorsCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
})
|
||||
);
|
||||
const existingRules = existing.CORSRules ?? [];
|
||||
if (existingRules.some((rule) => corsRulesMatchOrigins(rule, allowedOrigins))) {
|
||||
return allowedOrigins;
|
||||
}
|
||||
|
||||
await r2Client.send(
|
||||
new PutBucketCorsCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
CORSConfiguration: {
|
||||
CORSRules: [...existingRules, managedRule],
|
||||
},
|
||||
})
|
||||
);
|
||||
return allowedOrigins;
|
||||
} catch {
|
||||
// No CORS config yet, or insufficient permissions to read — attempt to write.
|
||||
}
|
||||
|
||||
await r2Client.send(
|
||||
new PutBucketCorsCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
CORSConfiguration: {
|
||||
CORSRules: [managedRule],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return allowedOrigins;
|
||||
}
|
||||
|
||||
export async function createPresignedVideoPutUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
contentLength: bigint,
|
||||
expiresInSeconds = DEFAULT_PRESIGNED_PUT_TTL_SECONDS
|
||||
): Promise<string> {
|
||||
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) {
|
||||
throw new Error('Invalid video object key');
|
||||
}
|
||||
|
||||
if (contentLength <= BigInt(0) || contentLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new Error('Invalid video content length');
|
||||
}
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
ContentLength: Number(contentLength),
|
||||
});
|
||||
|
||||
return getSignedUrl(getOrCreateR2PresignClient(), command, { expiresIn: expiresInSeconds });
|
||||
}
|
||||
|
||||
export async function createPresignedImagePutUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
expiresInSeconds = DEFAULT_PRESIGNED_PUT_TTL_SECONDS
|
||||
): Promise<string> {
|
||||
if (!key.startsWith(IMAGE_OBJECT_KEY_PREFIX)) {
|
||||
throw new Error('Invalid image object key');
|
||||
}
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
return getSignedUrl(getOrCreateR2PresignClient(), command, { expiresIn: expiresInSeconds });
|
||||
}
|
||||
|
||||
export async function headVideoObject(key: string): Promise<{
|
||||
contentLength: bigint;
|
||||
contentType: string | undefined;
|
||||
} | null> {
|
||||
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await r2Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
|
||||
const contentLength =
|
||||
typeof result.ContentLength === 'number' && result.ContentLength >= 0
|
||||
? BigInt(result.ContentLength)
|
||||
: BigInt(0);
|
||||
|
||||
return {
|
||||
contentLength,
|
||||
contentType: result.ContentType,
|
||||
};
|
||||
} catch (error) {
|
||||
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
|
||||
?.httpStatusCode;
|
||||
if (statusCode === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readVideoObjectBytes(
|
||||
key: string,
|
||||
byteLength: number
|
||||
): Promise<Uint8Array | null> {
|
||||
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX) || byteLength <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rangeEnd = Math.max(0, byteLength - 1);
|
||||
try {
|
||||
const result = await r2Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
Range: `bytes=0-${rangeEnd}`,
|
||||
})
|
||||
);
|
||||
|
||||
if (!result.Body) return null;
|
||||
const body = result.Body as { transformToByteArray?: () => Promise<Uint8Array> };
|
||||
if (typeof body.transformToByteArray !== 'function') return null;
|
||||
return await body.transformToByteArray();
|
||||
} catch (error) {
|
||||
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
|
||||
?.httpStatusCode;
|
||||
if (statusCode === 404 || statusCode === 416) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedObjectKey(key: string): void {
|
||||
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX) && !key.startsWith(IMAGE_OBJECT_KEY_PREFIX)) {
|
||||
throw new Error('Invalid object key');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteVideoObject(key: string): Promise<void> {
|
||||
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) {
|
||||
throw new Error('Invalid video object key');
|
||||
}
|
||||
|
||||
await deleteR2Object(key);
|
||||
}
|
||||
|
||||
export async function deleteR2Object(key: string): Promise<void> {
|
||||
assertAllowedObjectKey(key);
|
||||
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export { R2_BUCKET_NAME };
|
||||
|
||||
+37
-9
@@ -20,13 +20,22 @@ class QuotaExceededError extends Error {}
|
||||
* every upload.
|
||||
*/
|
||||
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
|
||||
const [r2Rows, bunnyByUser, reservationRows] = await Promise.all([
|
||||
const [r2AssetRows, r2VideoRows, bunnyByUser, reservationRows] = await Promise.all([
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||
FROM video_assets
|
||||
WHERE "billedUserId" = ${userId}
|
||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
|
||||
`,
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
|
||||
FROM video_versions vv
|
||||
INNER JOIN videos v ON v.id = vv."videoParentId"
|
||||
INNER JOIN projects p ON p.id = v."projectId"
|
||||
INNER JOIN workspaces w ON w.id = p."workspaceId"
|
||||
WHERE w."ownerId" = ${userId}
|
||||
AND vv."providerId" = 'r2'
|
||||
`,
|
||||
getCachedUserBunnyStorage(),
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
|
||||
@@ -36,11 +45,12 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
|
||||
`,
|
||||
]);
|
||||
|
||||
const r2Bytes = r2Rows[0]?.total ?? BigInt(0);
|
||||
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
|
||||
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
|
||||
const bunnyBytes = BigInt(bunnyByUser[userId] ?? 0);
|
||||
const reservedBytes = reservationRows[0]?.total ?? BigInt(0);
|
||||
|
||||
return r2Bytes + bunnyBytes + reservedBytes;
|
||||
return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,13 +112,14 @@ export async function enforceStorageQuota(
|
||||
*/
|
||||
export async function reserveStorageQuota(
|
||||
userId: string,
|
||||
incomingSizeBytes: bigint
|
||||
incomingSizeBytes: bigint,
|
||||
reservationTtlMs: number = RESERVATION_TTL_MS
|
||||
): Promise<{ reservationId: string | null } | { error: NextResponse }> {
|
||||
if (!isStripeFeatureEnabled()) {
|
||||
return { reservationId: null };
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + RESERVATION_TTL_MS);
|
||||
const expiresAt = new Date(Date.now() + reservationTtlMs);
|
||||
|
||||
// Fetch Bunny storage BEFORE entering the transaction to avoid holding the
|
||||
// advisory lock during a potentially slow/failing HTTP call on cache miss.
|
||||
@@ -128,13 +139,22 @@ export async function reserveStorageQuota(
|
||||
`;
|
||||
|
||||
// Read committed R2 storage under the lock
|
||||
const [r2Row] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||
const [r2AssetRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||
FROM video_assets
|
||||
WHERE "billedUserId" = ${userId}
|
||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
|
||||
`;
|
||||
const r2Bytes = r2Row?.total ?? BigInt(0);
|
||||
const [r2VideoRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
|
||||
FROM video_versions vv
|
||||
INNER JOIN videos v ON v.id = vv."videoParentId"
|
||||
INNER JOIN projects p ON p.id = v."projectId"
|
||||
INNER JOIN workspaces w ON w.id = p."workspaceId"
|
||||
WHERE w."ownerId" = ${userId}
|
||||
AND vv."providerId" = 'r2'
|
||||
`;
|
||||
const r2Bytes = (r2AssetRow?.total ?? BigInt(0)) + (r2VideoRow?.total ?? BigInt(0));
|
||||
|
||||
// Read active (non-expired) reservations under the same lock
|
||||
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||
@@ -171,7 +191,15 @@ export async function reserveStorageQuota(
|
||||
* Deletes an upload reservation created by `reserveStorageQuota`.
|
||||
* Safe to call with `null` (no-op) for flows where billing is disabled.
|
||||
*/
|
||||
export async function releaseStorageReservation(reservationId: string | null): Promise<void> {
|
||||
export async function releaseStorageReservation(
|
||||
reservationId: string | null,
|
||||
billedUserId?: string | null
|
||||
): Promise<void> {
|
||||
if (!reservationId) return;
|
||||
await db.uploadReservation.deleteMany({ where: { id: reservationId } });
|
||||
await db.uploadReservation.deleteMany({
|
||||
where: {
|
||||
id: reservationId,
|
||||
...(billedUserId ? { billedUserId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,3 +91,32 @@ export function validateOptionalUrl(
|
||||
|
||||
return validateUrl(urlString, fieldName);
|
||||
}
|
||||
|
||||
const SAFE_APP_RELATIVE_PATH =
|
||||
/^\/(?:api\/upload\/(?:image|audio|video)\/[0-9a-f-]{36}\.[a-z0-9]+|placeholder-video-thumbnail\.png)$/i;
|
||||
|
||||
export function isSafeAppRelativePath(path: string): boolean {
|
||||
if (!path.startsWith('/') || path.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SAFE_APP_RELATIVE_PATH.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts optional absolute http(s) URLs or safe same-origin app paths (upload proxy, placeholders).
|
||||
*/
|
||||
export function validateOptionalUrlOrAppPath(
|
||||
urlString: string | null | undefined,
|
||||
fieldName: string = 'URL'
|
||||
): string | null {
|
||||
if (!urlString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSafeAppRelativePath(urlString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return validateOptionalUrl(urlString, fieldName);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { youtubeProvider } from './youtube';
|
||||
import { directProvider } from './direct';
|
||||
import { bunnyProvider } from './bunny';
|
||||
import { r2Provider } from './r2';
|
||||
import type { VideoProvider, VideoSource, VideoMetadata, VideoProviderType } from './types';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
@@ -10,7 +11,7 @@ import { logError } from '@/lib/logger';
|
||||
export * from './types';
|
||||
|
||||
// Registry of all available providers
|
||||
const providers: VideoProvider[] = [youtubeProvider, directProvider, bunnyProvider];
|
||||
const providers: VideoProvider[] = [youtubeProvider, directProvider, bunnyProvider, r2Provider];
|
||||
|
||||
// Provider lookup map for quick access
|
||||
const providerMap = new Map<string, VideoProvider>(providers.map((p) => [p.id, p]));
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
|
||||
|
||||
const R2_VIDEO_PROXY_PATH = /^\/api\/upload\/video\/[0-9a-f-]{36}\.[a-z0-9]+$/i;
|
||||
|
||||
export const r2Provider: VideoProvider = {
|
||||
id: 'r2',
|
||||
name: 'Self-hosted',
|
||||
icon: 'Upload',
|
||||
|
||||
canHandle(url: string): boolean {
|
||||
return R2_VIDEO_PROXY_PATH.test(url);
|
||||
},
|
||||
|
||||
extractVideoId(url: string): string | null {
|
||||
if (this.canHandle(url)) {
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options.startTime) params.set('t', String(Math.floor(options.startTime)));
|
||||
const queryString = params.toString();
|
||||
return `${videoId}${queryString ? `?${queryString}` : ''}`;
|
||||
},
|
||||
|
||||
getThumbnailUrl(_videoId: string): string {
|
||||
void _videoId;
|
||||
return '/placeholder-video-thumbnail.png';
|
||||
},
|
||||
|
||||
async getMetadata(videoId: string): Promise<VideoMetadata> {
|
||||
const filename = videoId.split('/').pop() || 'Video';
|
||||
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '');
|
||||
|
||||
return {
|
||||
title: nameWithoutExt,
|
||||
thumbnailUrl: this.getThumbnailUrl(videoId),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -38,7 +38,7 @@ export interface EmbedOptions {
|
||||
export type ThumbnailSize = 'small' | 'medium' | 'large' | 'maxres';
|
||||
|
||||
// Supported provider types - extend as we add more
|
||||
export type VideoProviderType = 'youtube' | 'direct' | 'bunny';
|
||||
export type VideoProviderType = 'youtube' | 'direct' | 'bunny' | 'r2';
|
||||
|
||||
// Video source stored in database
|
||||
export interface VideoSource {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
const VIDEO_MIME_TO_EXT: Record<string, string> = {
|
||||
'video/mp4': 'mp4',
|
||||
'video/webm': 'webm',
|
||||
'video/ogg': 'ogg',
|
||||
'video/quicktime': 'mov',
|
||||
'video/x-matroska': 'mkv',
|
||||
'video/x-msvideo': 'avi',
|
||||
};
|
||||
|
||||
const EXT_TO_MIME: 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',
|
||||
};
|
||||
|
||||
const ALLOWED_VIDEO_EXTENSIONS = new Set(Object.keys(EXT_TO_MIME));
|
||||
|
||||
export function normalizeVideoMime(mime: string | undefined): string | null {
|
||||
if (!mime) return null;
|
||||
const normalized = mime.split(';')[0]?.trim().toLowerCase() ?? '';
|
||||
if (!normalized.startsWith('video/')) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getVideoExtensionFromMime(mime: string): string | null {
|
||||
return VIDEO_MIME_TO_EXT[mime] ?? null;
|
||||
}
|
||||
|
||||
export function getVideoExtensionFromFileName(fileName: string): string | null {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
if (!ext || !ALLOWED_VIDEO_EXTENSIONS.has(ext)) return null;
|
||||
return ext;
|
||||
}
|
||||
|
||||
export function resolveVideoContentType(fileName: string, mime: string | undefined): string | null {
|
||||
const normalizedMime = normalizeVideoMime(mime);
|
||||
if (normalizedMime) {
|
||||
const extFromMime = getVideoExtensionFromMime(normalizedMime);
|
||||
const extFromName = getVideoExtensionFromFileName(fileName);
|
||||
if (extFromMime && extFromName && extFromMime !== extFromName) {
|
||||
return EXT_TO_MIME[extFromName] ?? normalizedMime;
|
||||
}
|
||||
return normalizedMime;
|
||||
}
|
||||
|
||||
const ext = getVideoExtensionFromFileName(fileName);
|
||||
if (!ext) return null;
|
||||
return EXT_TO_MIME[ext] ?? null;
|
||||
}
|
||||
|
||||
export function isAllowedVideoFile(fileName: string, mime: string | undefined): boolean {
|
||||
return resolveVideoContentType(fileName, mime) !== null;
|
||||
}
|
||||
|
||||
export const VIDEO_OBJECT_KEY_PREFIX = 'videos/';
|
||||
|
||||
const SAFE_VIDEO_BASENAME =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
|
||||
export function buildVideoObjectKey(filename: string): string {
|
||||
return `${VIDEO_OBJECT_KEY_PREFIX}${filename}`;
|
||||
}
|
||||
|
||||
export function videoProxyPathFromFilename(filename: string): string {
|
||||
return `/api/upload/video/${filename}`;
|
||||
}
|
||||
|
||||
export function videoProxyPathToObjectKey(proxyPath: string): string | null {
|
||||
const prefix = '/api/upload/video/';
|
||||
if (!proxyPath.startsWith(prefix)) return null;
|
||||
const filename = proxyPath.slice(prefix.length);
|
||||
if (!SAFE_VIDEO_BASENAME.test(filename)) return null;
|
||||
return buildVideoObjectKey(filename);
|
||||
}
|
||||
|
||||
export function objectKeyToVideoProxyPath(objectKey: string): string | null {
|
||||
if (!objectKey.startsWith(VIDEO_OBJECT_KEY_PREFIX)) return null;
|
||||
const filename = objectKey.slice(VIDEO_OBJECT_KEY_PREFIX.length);
|
||||
if (!SAFE_VIDEO_BASENAME.test(filename)) return null;
|
||||
return videoProxyPathFromFilename(filename);
|
||||
}
|
||||
|
||||
export { SAFE_VIDEO_BASENAME };
|
||||
@@ -15,6 +15,41 @@ function resolveBunnyCdnHostname(): string | null {
|
||||
const bunnyCdnHostname = resolveBunnyCdnHostname();
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
function resolveR2ConnectOrigins(): string[] {
|
||||
const origins = new Set<string>();
|
||||
|
||||
for (const raw of [
|
||||
process.env.R2_ENDPOINT?.trim(),
|
||||
process.env.R2_PRESIGN_ENDPOINT?.trim(),
|
||||
process.env.R2_PUBLIC_BASE_URL?.trim(),
|
||||
]) {
|
||||
if (!raw) continue;
|
||||
try {
|
||||
origins.add(new URL(raw).origin);
|
||||
} catch {
|
||||
// Ignore invalid custom endpoints in CSP generation.
|
||||
}
|
||||
}
|
||||
|
||||
const accountId = process.env.R2_ACCOUNT_ID?.trim();
|
||||
const bucket = process.env.R2_BUCKET_NAME?.trim();
|
||||
if (accountId) {
|
||||
origins.add(`https://${accountId}.r2.cloudflarestorage.com`);
|
||||
if (bucket) {
|
||||
origins.add(`https://${bucket}.${accountId}.r2.cloudflarestorage.com`);
|
||||
}
|
||||
origins.add('https://*.r2.cloudflarestorage.com');
|
||||
}
|
||||
|
||||
// Docker/MinIO self-hosted defaults. Keep unconditional because CSP is
|
||||
// compiled at build time and build env may not include runtime self-hosted
|
||||
// variables.
|
||||
origins.add('http://localhost:9000');
|
||||
origins.add('http://127.0.0.1:9000');
|
||||
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
// Build Content-Security-Policy from resolved config
|
||||
const cdnOrigin = bunnyCdnHostname ? `https://${bunnyCdnHostname}` : '';
|
||||
|
||||
@@ -23,6 +58,7 @@ const connectSrcParts = [
|
||||
'https://video.bunnycdn.com',
|
||||
'https://www.youtube.com',
|
||||
cdnOrigin,
|
||||
...resolveR2ConnectOrigins(),
|
||||
// Allow Next.js HMR websocket in development
|
||||
...(isDev ? ['ws://localhost:* wss://localhost:*'] : []),
|
||||
].filter(Boolean);
|
||||
|
||||
+3
-1
@@ -21,6 +21,7 @@
|
||||
"db:seed": "prisma db seed",
|
||||
"db:setup": "bun run db:generate && bun run db:migrate",
|
||||
"self-host:bootstrap": "bun run scripts/self-host-bootstrap.ts",
|
||||
"r2:configure-cors": "bun run scripts/configure-r2-cors.ts",
|
||||
"r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
|
||||
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
|
||||
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",
|
||||
@@ -28,7 +29,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@aws-sdk/client-s3": "^3.1029.0",
|
||||
"@aws-sdk/client-s3": "3.1054.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1054.0",
|
||||
"@prisma/adapter-pg": "^7.3.0",
|
||||
"@prisma/client": "^7.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "video_versions" ADD COLUMN "size_bytes" BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UploadSessionStatus" AS ENUM ('INITIATED', 'FINALIZED', 'CANCELLED', 'EXPIRED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "video_upload_sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"upload_jti" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"billed_user_id" TEXT NOT NULL,
|
||||
"object_key" TEXT NOT NULL,
|
||||
"thumbnail_object_key" TEXT NOT NULL,
|
||||
"declared_size_bytes" BIGINT NOT NULL,
|
||||
"content_type" TEXT NOT NULL,
|
||||
"reservation_id" TEXT,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"status" "UploadSessionStatus" NOT NULL DEFAULT 'INITIATED',
|
||||
"consumed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "video_upload_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "video_upload_sessions_upload_jti_key" ON "video_upload_sessions"("upload_jti");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "video_upload_sessions_object_key_key" ON "video_upload_sessions"("object_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "video_upload_sessions_projectId_status_idx" ON "video_upload_sessions"("projectId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "video_upload_sessions_userId_status_idx" ON "video_upload_sessions"("userId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "video_upload_sessions_billed_user_id_idx" ON "video_upload_sessions"("billed_user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "video_upload_sessions_expires_at_idx" ON "video_upload_sessions"("expires_at");
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Ensure a finalized R2 object key and proxy URL can only be claimed once.
|
||||
CREATE UNIQUE INDEX "video_versions_r2_videoid_unique"
|
||||
ON "video_versions" ("videoId")
|
||||
WHERE "providerId" = 'r2';
|
||||
|
||||
CREATE UNIQUE INDEX "video_versions_r2_originalurl_unique"
|
||||
ON "video_versions" ("originalUrl")
|
||||
WHERE "providerId" = 'r2' AND "originalUrl" LIKE '/api/upload/video/%';
|
||||
|
||||
CREATE UNIQUE INDEX "video_versions_r2_thumbnail_unique"
|
||||
ON "video_versions" ("thumbnailUrl")
|
||||
WHERE "providerId" = 'r2' AND "thumbnailUrl" LIKE '/api/upload/image/%';
|
||||
@@ -361,6 +361,7 @@ model VideoVersion {
|
||||
title String?
|
||||
thumbnailUrl String?
|
||||
duration Int? // Duration in seconds
|
||||
sizeBytes BigInt @default(0) @map("size_bytes") // R2-hosted video file size
|
||||
|
||||
// Status
|
||||
isActive Boolean @default(true) // Currently displayed version
|
||||
@@ -701,6 +702,37 @@ model UploadReservation {
|
||||
@@map("upload_reservations")
|
||||
}
|
||||
|
||||
enum UploadSessionStatus {
|
||||
INITIATED
|
||||
FINALIZED
|
||||
CANCELLED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
model VideoUploadSession {
|
||||
id String @id @default(cuid())
|
||||
uploadJti String @unique @map("upload_jti")
|
||||
userId String
|
||||
projectId String
|
||||
billedUserId String @map("billed_user_id")
|
||||
objectKey String @unique @map("object_key")
|
||||
thumbnailObjectKey String @map("thumbnail_object_key")
|
||||
declaredSizeBytes BigInt @map("declared_size_bytes")
|
||||
contentType String @map("content_type")
|
||||
reservationId String? @map("reservation_id")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
status UploadSessionStatus @default(INITIATED)
|
||||
consumedAt DateTime? @map("consumed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([projectId, status])
|
||||
@@index([userId, status])
|
||||
@@index([billedUserId])
|
||||
@@index([expiresAt])
|
||||
@@map("video_upload_sessions")
|
||||
}
|
||||
|
||||
// Rate limiting table (created as UNLOGGED via raw SQL migration)
|
||||
// Defined here so `prisma db push` doesn't drop it
|
||||
model RateLimit {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'dotenv/config';
|
||||
import { ensureR2UploadCors, R2_BUCKET_NAME, r2Client } from '@/lib/r2';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
async function main() {
|
||||
const origins = await ensureR2UploadCors();
|
||||
console.log(`Configured upload CORS on bucket "${R2_BUCKET_NAME}" for origins:`);
|
||||
for (const origin of origins) {
|
||||
console.log(` - ${origin}`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
logError('Failed to configure R2 upload CORS:', error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
r2Client.destroy();
|
||||
});
|
||||
@@ -82,6 +82,16 @@ async function getPublicTables(client: Client) {
|
||||
return result.rows.map((row) => row.table_name);
|
||||
}
|
||||
|
||||
async function ensureRateLimitCleanupFunction(client: Client) {
|
||||
await client.query(`
|
||||
CREATE OR REPLACE FUNCTION cleanup_rate_limits() RETURNS void AS $$
|
||||
BEGIN
|
||||
DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '1 hour';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL is required');
|
||||
@@ -132,6 +142,7 @@ async function main() {
|
||||
await runPrisma(['migrate', 'resolve', '--applied', migrationName]);
|
||||
}
|
||||
|
||||
await ensureRateLimitCleanupFunction(client);
|
||||
console.log('Fresh database bootstrap complete');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { logError } from '@/lib/logger';
|
||||
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
const CHUNK_SIZE = 500;
|
||||
const PREFIXES = ['images/', 'voice/'] as const;
|
||||
const PREFIXES = ['images/', 'voice/', 'videos/'] as const;
|
||||
|
||||
type CleanupCandidate = {
|
||||
key: string;
|
||||
@@ -33,6 +33,10 @@ function keyToProxyUrl(key: string): string | null {
|
||||
const filename = key.slice('voice/'.length);
|
||||
return filename ? `/api/upload/audio/${filename}` : null;
|
||||
}
|
||||
if (key.startsWith('videos/')) {
|
||||
const filename = key.slice('videos/'.length);
|
||||
return filename ? `/api/upload/video/${filename}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -93,31 +97,38 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
|
||||
).userFeedbackScreenshot;
|
||||
|
||||
for (const group of chunk(urls, CHUNK_SIZE)) {
|
||||
const [commentRows, feedbackRows, feedbackAttachmentRows, assetRows] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }],
|
||||
},
|
||||
select: {
|
||||
voiceUrl: true,
|
||||
imageUrl: true,
|
||||
},
|
||||
}),
|
||||
db.userFeedback.findMany({
|
||||
where: { screenshotUrl: { in: group } },
|
||||
select: { screenshotUrl: true },
|
||||
}),
|
||||
userFeedbackScreenshotDelegate
|
||||
? userFeedbackScreenshotDelegate.findMany({
|
||||
where: { url: { in: group } },
|
||||
select: { url: true },
|
||||
})
|
||||
: Promise.resolve([] as Array<{ url: string }>),
|
||||
db.videoAsset.findMany({
|
||||
where: { sourceUrl: { in: group } },
|
||||
select: { sourceUrl: true },
|
||||
}),
|
||||
]);
|
||||
const [commentRows, feedbackRows, feedbackAttachmentRows, assetRows, versionRows] =
|
||||
await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }],
|
||||
},
|
||||
select: {
|
||||
voiceUrl: true,
|
||||
imageUrl: true,
|
||||
},
|
||||
}),
|
||||
db.userFeedback.findMany({
|
||||
where: { screenshotUrl: { in: group } },
|
||||
select: { screenshotUrl: true },
|
||||
}),
|
||||
userFeedbackScreenshotDelegate
|
||||
? userFeedbackScreenshotDelegate.findMany({
|
||||
where: { url: { in: group } },
|
||||
select: { url: true },
|
||||
})
|
||||
: Promise.resolve([] as Array<{ url: string }>),
|
||||
db.videoAsset.findMany({
|
||||
where: { sourceUrl: { in: group } },
|
||||
select: { sourceUrl: true },
|
||||
}),
|
||||
db.videoVersion.findMany({
|
||||
where: {
|
||||
OR: [{ originalUrl: { in: group } }, { thumbnailUrl: { in: group } }],
|
||||
},
|
||||
select: { originalUrl: true, thumbnailUrl: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
for (const row of commentRows) {
|
||||
if (row.voiceUrl) referenced.add(row.voiceUrl);
|
||||
@@ -132,6 +143,10 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
|
||||
for (const row of assetRows) {
|
||||
if (row.sourceUrl) referenced.add(row.sourceUrl);
|
||||
}
|
||||
for (const row of versionRows) {
|
||||
if (row.originalUrl) referenced.add(row.originalUrl);
|
||||
if (row.thumbnailUrl) referenced.add(row.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return referenced;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dotenv/config';
|
||||
import { ensureR2BucketExists, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { ensureR2BucketExists, ensureR2UploadCors, R2_BUCKET_NAME, r2Client } from '@/lib/r2';
|
||||
import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const shouldCreateBucket = /^(1|true|yes|on)$/i.test(
|
||||
@@ -15,9 +16,26 @@ async function main() {
|
||||
console.log(`Ensuring object storage bucket exists: ${R2_BUCKET_NAME}`);
|
||||
await ensureR2BucketExists();
|
||||
console.log(`Bucket is ready: ${R2_BUCKET_NAME}`);
|
||||
|
||||
if (isS3VideoUploadsEnabled()) {
|
||||
try {
|
||||
const origins = await ensureR2UploadCors();
|
||||
console.log(`Configured upload CORS for origins: ${origins.join(', ')}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
`Skipping automatic bucket CORS setup (${message}). ` +
|
||||
'If direct S3 uploads fail in the browser, configure bucket CORS manually.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logError('Self-host bootstrap failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
main()
|
||||
.catch((error) => {
|
||||
logError('Self-host bootstrap failed:', error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
r2Client.destroy();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user