diff --git a/.env.example b/.env.example index 8b528bd..889a991 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,13 @@ DB_POOL_DEBUG="false" NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32" +# ============================================================================ +# OPTIONAL SELF-HOSTING FEATURE FLAGS +# ============================================================================ +OPENFRAME_ENABLE_STRIPE="true" +OPENFRAME_ENABLE_BUNNY_UPLOADS="true" +OPENFRAME_REQUIRE_INVITE_CODE="true" + # ============================================================================ # OAUTH PROVIDERS # ============================================================================ diff --git a/README.md b/README.md index e215bc4..da2b365 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,38 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# OpenFrame -## Getting Started +OpenFrame is a collaborative video feedback platform built with Next.js, Bun, Prisma, and PostgreSQL. -First, run the development server: +## Development + +Install dependencies and run checks with Bun: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +bun install +bun run check ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Self-hosting flags -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +OpenFrame supports env flags so self-hosted installs can disable hosted-only features without code changes: -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +```bash +OPENFRAME_ENABLE_STRIPE=true +OPENFRAME_ENABLE_BUNNY_UPLOADS=true +OPENFRAME_REQUIRE_INVITE_CODE=true +``` -## Learn More +Recommended self-hosted values for a single-team deployment: -To learn more about Next.js, take a look at the following resources: +```bash +OPENFRAME_ENABLE_STRIPE=false +OPENFRAME_ENABLE_BUNNY_UPLOADS=false +OPENFRAME_REQUIRE_INVITE_CODE=false +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +Behavior when disabled: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +- `OPENFRAME_ENABLE_STRIPE=false`: disables Stripe checkout and portal flows and removes billing-based workspace restrictions. +- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false`: hides direct-upload entry points. URL-based providers such as YouTube continue to work. +- `OPENFRAME_REQUIRE_INVITE_CODE=false`: allows open registration while keeping invitation-link registration intact. -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Feature flags are documented in `.env.example`. Hosted defaults remain enabled. diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 8aad243..af5e9d6 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,229 +1,6 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Video, Loader2, KeyRound, UserPlus } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; +import { isInviteCodeRequired } from '@/lib/feature-flags'; +import RegisterPageClient from './register-page-client'; export default function RegisterPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]); - const invitedEmail = useMemo(() => searchParams.get('email') || '', [searchParams]); - const isInvitationFlow = invitationToken.length > 0; - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - const [formData, setFormData] = useState({ - name: '', - email: '', - password: '', - confirmPassword: '', - inviteCode: '', - }); - - useEffect(() => { - if (!invitedEmail) return; - setFormData((prev) => ({ - ...prev, - email: invitedEmail, - })); - }, [invitedEmail]); - - const handleChange = (e: React.ChangeEvent) => { - setFormData(prev => ({ - ...prev, - [e.target.name]: e.target.value, - })); - setError(''); - }; - - const handleRegister = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setIsLoading(true); - - // Client-side validation - if (formData.password !== formData.confirmPassword) { - setError('Passwords do not match'); - setIsLoading(false); - return; - } - - if (formData.password.length < 8) { - setError('Password must be at least 8 characters'); - setIsLoading(false); - return; - } - - try { - const response = await fetch('/api/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: formData.name, - email: formData.email, - password: formData.password, - inviteCode: formData.inviteCode || undefined, - invitationToken: invitationToken || undefined, - }), - }); - - const data = await response.json(); - - if (!response.ok) { - setError(data.error || 'Registration failed'); - return; - } - - // Redirect to login on success - router.push('/login?registered=true'); - } catch { - setError('Something went wrong. Please try again.'); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
- {/* Logo */} - -
- ); + return ; } diff --git a/app/(auth)/register/register-page-client.tsx b/app/(auth)/register/register-page-client.tsx new file mode 100644 index 0000000..274003d --- /dev/null +++ b/app/(auth)/register/register-page-client.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Video, Loader2, KeyRound, UserPlus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export default function RegisterPageClient({ requireInviteCode }: { requireInviteCode: boolean }) { + const router = useRouter(); + const searchParams = useSearchParams(); + const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]); + const invitedEmail = useMemo(() => searchParams.get('email') || '', [searchParams]); + const isInvitationFlow = invitationToken.length > 0; + const shouldShowInviteCode = requireInviteCode && !isInvitationFlow; + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + confirmPassword: '', + inviteCode: '', + }); + + useEffect(() => { + if (!invitedEmail) return; + setFormData((prev) => ({ + ...prev, + email: invitedEmail, + })); + }, [invitedEmail]); + + const handleChange = (e: React.ChangeEvent) => { + setFormData((prev) => ({ + ...prev, + [e.target.name]: e.target.value, + })); + setError(''); + }; + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + if (formData.password !== formData.confirmPassword) { + setError('Passwords do not match'); + setIsLoading(false); + return; + } + + if (formData.password.length < 8) { + setError('Password must be at least 8 characters'); + setIsLoading(false); + return; + } + + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: formData.name, + email: formData.email, + password: formData.password, + inviteCode: shouldShowInviteCode ? formData.inviteCode || undefined : undefined, + invitationToken: invitationToken || undefined, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || 'Registration failed'); + return; + } + + router.push('/login?registered=true'); + } catch { + setError('Something went wrong. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ +
+ ); +} diff --git a/app/(dashboard)/dashboard/dashboard-client.tsx b/app/(dashboard)/dashboard/dashboard-client.tsx index 90d7de5..7864152 100644 --- a/app/(dashboard)/dashboard/dashboard-client.tsx +++ b/app/(dashboard)/dashboard/dashboard-client.tsx @@ -21,6 +21,7 @@ interface DashboardClientProps { totalPages: number; canCreateProjects: boolean; canUploadVideos: boolean; + bunnyUploadsEnabled: boolean; } export function DashboardClient({ @@ -29,10 +30,11 @@ export function DashboardClient({ totalPages, canCreateProjects, canUploadVideos, + bunnyUploadsEnabled, }: DashboardClientProps) { return (
- + ); } diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx index 9493903..328ca3c 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx @@ -1,5 +1,6 @@ import { VideoPageContent } from '@/components/video-page-content'; import { auth } from '@/lib/auth'; +import { isBunnyUploadsEnabled } from '@/lib/feature-flags'; import { requireVideoProjectAccessOrRedirect } from '@/lib/route-access'; interface VideoPageProps { @@ -18,5 +19,12 @@ export default async function VideoPage({ params }: VideoPageProps) { allowPublicView: true, }); - return ; + return ( + + ); } diff --git a/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx b/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx index 5c5fff5..526bb75 100644 --- a/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx @@ -23,7 +23,13 @@ function isVideoFile(file: File): boolean { return !!extension && VIDEO_FILE_EXTENSIONS.includes(extension); } -export default function NewVideoPageClient({ projectId }: { projectId: string }) { +export default function NewVideoPageClient({ + projectId, + bunnyUploadsEnabled, +}: { + projectId: string; + bunnyUploadsEnabled: boolean; +}) { const router = useRouter(); const bunnyCdnHostname = resolvePublicBunnyCdnHostname(); @@ -348,6 +354,10 @@ export default function NewVideoPageClient({ projectId }: { projectId: string }) finalThumbnailUrl = getThumbnailUrl(videoSource, 'large'); finalDuration = videoSource.metadata?.duration || null; } else { + if (!bunnyUploadsEnabled) { + throw new Error('Direct uploads are disabled by this host'); + } + if (!selectedFile) { setSubmitError('Please select a video file to upload'); setIsLoading(false); @@ -439,14 +449,18 @@ export default function NewVideoPageClient({ projectId }: { projectId: string }) Add Video - Paste a video link or upload a file directly to add it to your project. Currently supports YouTube. + {bunnyUploadsEnabled + ? '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.'} !isLoading && setUploadMode(v as 'url' | 'file')} className="mb-6"> - + Paste URL - Direct Upload + {bunnyUploadsEnabled ? ( + Direct Upload + ) : null} diff --git a/app/(dashboard)/projects/[projectId]/videos/new/page.tsx b/app/(dashboard)/projects/[projectId]/videos/new/page.tsx index ffbc128..0f06ede 100644 --- a/app/(dashboard)/projects/[projectId]/videos/new/page.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/new/page.tsx @@ -1,4 +1,5 @@ import { requireProjectAccessOrRedirect } from '@/lib/route-access'; +import { isBunnyUploadsEnabled } from '@/lib/feature-flags'; import NewVideoPageClient from './new-video-page-client'; interface NewVideoPageProps { @@ -13,5 +14,5 @@ export default async function NewVideoPage({ params }: NewVideoPageProps) { intent: 'manage', }); - return ; + return ; } diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 5c6e64b..a407cef 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -33,7 +33,9 @@ interface NotificationSettings { } interface BillingOverview { + isEnabled: boolean; isConfigured: boolean; + status: 'disabled' | 'ready' | 'misconfigured'; checkoutAvailable: boolean; portalAvailable: boolean; subscription: { @@ -320,6 +322,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
+ ) : !billing.isEnabled ? ( +
+ Stripe billing is disabled by this host. Workspace creation is unrestricted in this environment. +
) : !billing.isConfigured ? (
Stripe is not configured yet. Add your Stripe environment variables before using billing. diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 93ca8f6..676d0c9 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,6 +1,7 @@ import { Metadata } from 'next'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; +import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags'; import { redirect } from 'next/navigation'; import { getCachedBunnyStorageStats, getCachedTotalStorage } from '@/lib/admin-stats'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -173,7 +174,9 @@ export default async function AdminDashboardPage() { -
{formatBytes(bunnyStorageStats.totalBytes)}
+
+ {isBunnyUploadsFeatureEnabled() ? formatBytes(bunnyStorageStats.totalBytes) : 'Disabled'} +
diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index c9aa794..454ab10 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -2,6 +2,7 @@ import { Metadata } from 'next'; import { Prisma } from '@prisma/client'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; +import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags'; import { redirect } from 'next/navigation'; import { getCachedBunnyStorageStats, @@ -280,7 +281,9 @@ export default async function AdminUsersPage({ -
{formatBytes(bunnyStorageStats.totalBytes)}
+
+ {isBunnyUploadsFeatureEnabled() ? formatBytes(bunnyStorageStats.totalBytes) : 'Disabled'} +
diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index ec04117..b45ab46 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -4,6 +4,7 @@ import bcrypt from 'bcryptjs'; import { acceptInvitationTokenForUser, getValidInvitationByToken } from '@/lib/invitations'; import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { isInviteCodeRequired } from '@/lib/feature-flags'; export async function POST(request: NextRequest) { try { @@ -49,7 +50,7 @@ export async function POST(request: NextRequest) { } } - if (!invitationIsValid) { + if (!invitationIsValid && isInviteCodeRequired()) { // Validate invite code using constant-time comparison to prevent timing attacks const validInviteCode = process.env.INVITE_CODE; if (!validInviteCode || !inviteCode) { diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index 60c657a..cce5392 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -7,6 +7,7 @@ import { getStripeCheckoutState, } from '@/lib/billing'; import { rateLimit } from '@/lib/rate-limit'; +import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe'; import { isTrustedSameOriginRequest } from '@/lib/request-origin'; @@ -35,6 +36,10 @@ export async function POST(request: NextRequest) { return apiErrors.unauthorized(); } + if (!isStripeFeatureEnabled()) { + return apiErrors.badRequest('Stripe billing is disabled by this host'); + } + if (!isStripeConfigured()) { return apiErrors.internalError('Stripe billing is not configured'); } diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts index 8e230d7..1f97e4f 100644 --- a/app/api/billing/portal/route.ts +++ b/app/api/billing/portal/route.ts @@ -3,6 +3,7 @@ import { auth } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { getBillingOverview } from '@/lib/billing'; import { rateLimit } from '@/lib/rate-limit'; +import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { getStripe, isStripeConfigured } from '@/lib/stripe'; import { isTrustedSameOriginRequest } from '@/lib/request-origin'; @@ -31,6 +32,10 @@ export async function POST(request: NextRequest) { return apiErrors.unauthorized(); } + if (!isStripeFeatureEnabled()) { + return apiErrors.badRequest('Stripe billing is disabled by this host'); + } + if (!isStripeConfigured()) { return apiErrors.internalError('Stripe billing is not configured'); } diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts index 636ab3b..5b7205d 100644 --- a/app/api/billing/route.ts +++ b/app/api/billing/route.ts @@ -1,7 +1,8 @@ import { auth } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { getBillingOverview } from '@/lib/billing'; -import { isStripeConfigured } from '@/lib/stripe'; +import { isStripeFeatureEnabled } from '@/lib/feature-flags'; +import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe'; export async function GET() { try { @@ -11,8 +12,12 @@ export async function GET() { } const billing = await getBillingOverview(session.user.id); + const isEnabled = isStripeFeatureEnabled(); + const isConfigured = hasStripeRuntimeConfig(); const response = successResponse({ - isConfigured: isStripeConfigured(), + isEnabled, + isConfigured, + status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured', checkoutAvailable: isStripeConfigured() && !billing.subscription.hasActiveSubscription, portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId), subscription: { diff --git a/app/api/projects/[projectId]/videos/bunny-init/route.ts b/app/api/projects/[projectId]/videos/bunny-init/route.ts index e769620..23f9516 100644 --- a/app/api/projects/[projectId]/videos/bunny-init/route.ts +++ b/app/api/projects/[projectId]/videos/bunny-init/route.ts @@ -6,6 +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'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -50,6 +51,10 @@ 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'); + } + const apiKey = process.env.BUNNY_STREAM_API_KEY; const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID; diff --git a/app/api/videos/[videoId]/assets/bunny-init/route.ts b/app/api/videos/[videoId]/assets/bunny-init/route.ts index db88d1f..272ba6c 100644 --- a/app/api/videos/[videoId]/assets/bunny-init/route.ts +++ b/app/api/videos/[videoId]/assets/bunny-init/route.ts @@ -10,6 +10,7 @@ import { enforceGuestUploadQuota, verifyGuestUploadToken, } from '@/lib/guest-upload-token'; +import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags'; import { getShareSessionFromRequest } from '@/lib/share-session'; import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets'; @@ -30,6 +31,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const title = typeof body?.title === 'string' ? body.title.trim() : ''; if (!title) return apiErrors.badRequest('Title is required'); + if (!isBunnyUploadsFeatureEnabled()) { + return apiErrors.badRequest('Direct uploads are disabled by this host'); + } + const shareSession = getShareSessionFromRequest(request, context.video.id); if (!context.viewerUserId) { const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null); diff --git a/components/video-drag-drop-uploader.tsx b/components/video-drag-drop-uploader.tsx index 3552887..14ded0a 100644 --- a/components/video-drag-drop-uploader.tsx +++ b/components/video-drag-drop-uploader.tsx @@ -158,6 +158,13 @@ export function VideoDragDropUploader({ } }, [canUpload, needsProjectSelection, projectOptions, workspaceId]); + useEffect(() => { + if (!canUpload) { + setDialogOpen(false); + setDroppedFile(null); + } + }, [canUpload]); + useEffect(() => { hasLoadedProjectsRef.current = false; if (projectOptions && projectOptions.length > 0) { diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index bc9be61..3b09bec 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -67,9 +67,15 @@ interface VideoPageContentProps { mode: VideoPageMode; videoId: string; projectId?: string; + bunnyUploadsEnabled?: boolean; } -export function VideoPageContent({ mode, videoId, projectId: propProjectId }: VideoPageContentProps) { +export function VideoPageContent({ + mode, + videoId, + projectId: propProjectId, + bunnyUploadsEnabled = true, +}: VideoPageContentProps) { const iframeRef = useRef(null); const videoRef = useRef(null); const bunnyViewportRef = useRef(null); @@ -193,6 +199,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } = useVersionActions({ projectId: propProjectId, videoId, + bunnyUploadsEnabled, setVideo, activeVersionId, setActiveVersionId, @@ -676,6 +683,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi onDownload={headerActions.onDownload} projectId={projectId} videoId={videoId} + bunnyUploadsEnabled={bunnyUploadsEnabled} showVersionDialog={showVersionDialog} setShowVersionDialog={setShowVersionDialog} newVersionMode={newVersionMode} diff --git a/components/video-page/hooks/use-version-actions.ts b/components/video-page/hooks/use-version-actions.ts index e5560bb..77c5f69 100644 --- a/components/video-page/hooks/use-version-actions.ts +++ b/components/video-page/hooks/use-version-actions.ts @@ -16,6 +16,7 @@ interface UseVersionActionsParams extends VersionActionsConfig { export function useVersionActions({ projectId, videoId, + bunnyUploadsEnabled = true, setVideo, activeVersionId, setActiveVersionId, @@ -76,6 +77,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 (!newVersionFile) throw new Error('No file selected'); let title = newVersionFile.name; if (newVersionLabel.trim()) { diff --git a/components/video-page/types.ts b/components/video-page/types.ts index 9563cb3..3b88029 100644 --- a/components/video-page/types.ts +++ b/components/video-page/types.ts @@ -184,6 +184,7 @@ export interface CommentActionsConfig { export interface VersionActionsConfig { projectId?: string; videoId: string; + bunnyUploadsEnabled?: boolean; } export interface VideoPageHeaderActions { diff --git a/components/video-page/version-actions-dialog.tsx b/components/video-page/version-actions-dialog.tsx index 3a8a859..451dcd9 100644 --- a/components/video-page/version-actions-dialog.tsx +++ b/components/video-page/version-actions-dialog.tsx @@ -13,6 +13,7 @@ import type { VideoSource } from '@/lib/video-providers'; interface VersionActionsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + bunnyUploadsEnabled: boolean; newVersionMode: 'url' | 'file'; onNewVersionModeChange: (mode: 'url' | 'file') => void; newVersionUrl: string; @@ -33,6 +34,7 @@ interface VersionActionsDialogProps { export const VersionActionsDialog = memo(function VersionActionsDialog({ open, onOpenChange, + bunnyUploadsEnabled, newVersionMode, onNewVersionModeChange, newVersionUrl, @@ -66,9 +68,9 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
onNewVersionModeChange(v as 'url' | 'file')} className="mb-2"> - + Link URL - Upload File + {bunnyUploadsEnabled ? Upload File : null} diff --git a/components/video-page/video-page-header.tsx b/components/video-page/video-page-header.tsx index f95ade4..1a12176 100644 --- a/components/video-page/video-page-header.tsx +++ b/components/video-page/video-page-header.tsx @@ -43,6 +43,7 @@ interface VideoPageHeaderProps { onDownload: (preference?: BunnyDownloadPreference) => void; projectId?: string; videoId: string; + bunnyUploadsEnabled: boolean; showVersionDialog: boolean; setShowVersionDialog: (open: boolean) => void; newVersionMode: 'url' | 'file'; @@ -90,6 +91,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({ onDownload, projectId, videoId, + bunnyUploadsEnabled, showVersionDialog, setShowVersionDialog, newVersionMode, @@ -227,6 +229,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({ { } async function fetchBunnyStorageStats(): Promise { + if (!isBunnyUploadsFeatureEnabled()) { + return { totalBytes: 0, byVideoId: {} }; + } + const { apiKey, libraryId } = getBunnyConfig(); const byVideoId: Record = {}; let totalBytes = 0; diff --git a/lib/billing.ts b/lib/billing.ts index d02a4db..608b83b 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -3,6 +3,7 @@ import type Stripe from 'stripe'; import { BillingSubscriptionStatus } from '@prisma/client'; import { db } from '@/lib/db'; import { getStripe, getStripePriceId } from '@/lib/stripe'; +import { isStripeFeatureEnabled } from '@/lib/feature-flags'; const ACTIVE_SUBSCRIPTION_STATUSES = new Set([ BillingSubscriptionStatus.ACTIVE, @@ -35,6 +36,10 @@ export function hasActiveSubscription(status: BillingSubscriptionStatus | null | } export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) { + if (!isStripeFeatureEnabled()) { + return true; + } + if (hasActiveSubscription(subject.subscriptionStatus)) { return true; } @@ -68,6 +73,10 @@ export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) { } export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.UserWhereInput { + if (!isStripeFeatureEnabled()) { + return {}; + } + return { OR: [ { subscriptionStatus: { in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING] } }, @@ -217,10 +226,10 @@ export async function getWorkspaceCreationEligibility(userId: string) { const billingAccess = hasBillingAccess(user); const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount; const canCreateWorkspace = - billingAccess || (ownedWorkspaceCount === 0 && collaborationCount === 0); + !isStripeFeatureEnabled() || billingAccess || (ownedWorkspaceCount === 0 && collaborationCount === 0); let reason: string | null = null; - if (!canCreateWorkspace) { + if (!canCreateWorkspace && isStripeFeatureEnabled()) { if (collaborationCount > 0 && ownedWorkspaceCount === 0) { reason = 'You are currently collaborating in someone else’s workspace or project. Start a subscription to create a workspace of your own.'; diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts new file mode 100644 index 0000000..335704e --- /dev/null +++ b/lib/feature-flags.ts @@ -0,0 +1,41 @@ +function readBooleanEnv(name: string, defaultValue: boolean): boolean { + const value = process.env[name]; + if (!value) return defaultValue; + + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + + return defaultValue; +} + +export function isStripeFeatureEnabled() { + return readBooleanEnv('OPENFRAME_ENABLE_STRIPE', true); +} + +export function hasStripeConfig() { + return Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID); +} + +export function isStripeBillingEnabled() { + return isStripeFeatureEnabled() && hasStripeConfig(); +} + +export function isBunnyUploadsFeatureEnabled() { + return readBooleanEnv('OPENFRAME_ENABLE_BUNNY_UPLOADS', true); +} + +export function hasBunnyUploadsConfig() { + return Boolean( + process.env.BUNNY_STREAM_API_KEY && + (process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID) + ); +} + +export function isBunnyUploadsEnabled() { + return isBunnyUploadsFeatureEnabled() && hasBunnyUploadsConfig(); +} + +export function isInviteCodeRequired() { + return readBooleanEnv('OPENFRAME_REQUIRE_INVITE_CODE', true); +} diff --git a/lib/route-access.ts b/lib/route-access.ts index 2b7eed4..1406624 100644 --- a/lib/route-access.ts +++ b/lib/route-access.ts @@ -1,6 +1,6 @@ import { notFound, redirect } from 'next/navigation'; import { auth, checkProjectAccess, checkWorkspaceAccess } from '@/lib/auth'; -import { hasBillingAccess } from '@/lib/billing'; +import { buildBillingAccessWhereInput, hasBillingAccess } from '@/lib/billing'; import { db } from '@/lib/db'; type AccessIntent = 'view' | 'manage'; @@ -98,13 +98,7 @@ export async function hasCollaboratorBillingBackedAccess(userId: string) { const [workspaceCount, projectCount] = await Promise.all([ db.workspace.count({ where: { - owner: { - OR: [ - { subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } }, - { trialEndsAt: { gt: now } }, - { stripeCurrentPeriodEnd: { gt: now } }, - ], - }, + owner: buildBillingAccessWhereInput(now), OR: [ { ownerId: userId }, { members: { some: { userId } } }, @@ -114,13 +108,7 @@ export async function hasCollaboratorBillingBackedAccess(userId: string) { db.project.count({ where: { workspace: { - owner: { - OR: [ - { subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } }, - { trialEndsAt: { gt: now } }, - { stripeCurrentPeriodEnd: { gt: now } }, - ], - }, + owner: buildBillingAccessWhereInput(now), }, OR: [ { ownerId: userId }, diff --git a/lib/stripe.ts b/lib/stripe.ts index 6e68a1a..8e1adda 100644 --- a/lib/stripe.ts +++ b/lib/stripe.ts @@ -1,9 +1,14 @@ import Stripe from 'stripe'; +import { hasStripeConfig, isStripeBillingEnabled } from '@/lib/feature-flags'; let stripeClient: Stripe | null = null; export function isStripeConfigured() { - return Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID); + return isStripeBillingEnabled(); +} + +export function hasStripeRuntimeConfig() { + return hasStripeConfig(); } export function getStripe() {