diff --git a/.gitignore b/.gitignore index 36bb59a..bcf596a 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ web_modules/ # Next.js build output .next +.open-next next-env.d.ts out @@ -110,6 +111,7 @@ dist # Serverless directories .serverless/ +.wrangler/ # FuseBox cache .fusebox/ diff --git a/apps/web/.dev.vars b/apps/web/.dev.vars new file mode 100644 index 0000000..a2a6158 --- /dev/null +++ b/apps/web/.dev.vars @@ -0,0 +1 @@ +NEXTJS_ENV=development diff --git a/apps/web/.env.development.example b/apps/web/.env.development.example index a1c9eb0..6b390eb 100644 --- a/apps/web/.env.development.example +++ b/apps/web/.env.development.example @@ -33,6 +33,9 @@ UMAMI_TRACKING_ID= STORAGE_ENDPOINT=localhost STORAGE_PORT=9002 STORAGE_USESSL=false +STORAGE_FORCE_PATH_STYLE=true STORAGE_ACCESS_KEY=formbase STORAGE_SECRET_KEY=password STORAGE_BUCKET=formbase +STORAGE_REGION=us-east-1 +CRON_SECRET= diff --git a/apps/web/.env.production.example b/apps/web/.env.production.example index 0437816..cbd4570 100644 --- a/apps/web/.env.production.example +++ b/apps/web/.env.production.example @@ -1,5 +1,5 @@ -# Production environment (.env.production) -# Use these values in Vercel environment settings. +# production environment (.env.production) +# use these values in Cloudflare Workers build variables and runtime secrets. # Drizzle / libSQL (Turso) DATABASE_URL=libsql://-.turso.io @@ -27,6 +27,9 @@ UMAMI_TRACKING_ID= STORAGE_ENDPOINT=.r2.cloudflarestorage.com STORAGE_PORT=443 STORAGE_USESSL=true +STORAGE_FORCE_PATH_STYLE=true STORAGE_ACCESS_KEY= STORAGE_SECRET_KEY= STORAGE_BUCKET= +STORAGE_REGION=auto +CRON_SECRET= diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 07ce97b..9c2e377 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,3 +1,22 @@ +import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare'; + +initOpenNextCloudflareForDev(); + +const appUrl = process.env.NEXT_PUBLIC_APP_URL + ? new URL(process.env.NEXT_PUBLIC_APP_URL) + : null; + +const appImageRemotePatterns = appUrl + ? [ + { + protocol: appUrl.protocol.replace(':', ''), + hostname: appUrl.hostname, + port: appUrl.port, + pathname: '/api/files/**', + }, + ] + : []; + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, @@ -11,6 +30,7 @@ const nextConfig = { protocol: 'http', hostname: 'localhost', }, + ...appImageRemotePatterns, ], }, transpilePackages: [ @@ -20,9 +40,14 @@ const nextConfig = { '@formbase/env', '@formbase/ui', '@formbase/utils', - "@formbase/tailwind", + '@formbase/tailwind', + ], + serverExternalPackages: [ + 'libsql', + '@libsql/client', + '@libsql/isomorphic-fetch', + '@libsql/isomorphic-ws', ], - serverExternalPackages: ['libsql', '@libsql/client'], typescript: { ignoreBuildErrors: true, }, diff --git a/apps/web/open-next.config.ts b/apps/web/open-next.config.ts new file mode 100644 index 0000000..7a3d171 --- /dev/null +++ b/apps/web/open-next.config.ts @@ -0,0 +1,3 @@ +import { defineCloudflareConfig } from '@opennextjs/cloudflare'; + +export default defineCloudflareConfig(); diff --git a/apps/web/package.json b/apps/web/package.json index e213318..1ee7bcd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,8 +5,12 @@ "type": "module", "scripts": { "build": "next build", + "build:worker": "opennextjs-cloudflare build", + "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts", + "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "dev": "next dev -p 3000", "lint": "eslint . --cache --max-warnings 0", + "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "start": "next start", "typecheck": "tsc --noEmit --tsBuildInfoFile .tsbuildinfo" }, @@ -19,6 +23,7 @@ "@formbase/ui": "workspace:*", "@formbase/utils": "workspace:*", "@hookform/resolvers": "^3.4.2", + "@opennextjs/cloudflare": "1.19.5", "@radix-ui/react-icons": "^1.3.0", "@react-email/components": "^1.0.12", "@react-email/render": "^2.0.8", @@ -36,7 +41,6 @@ "disposable-email-domains": "^1.0.62", "framer-motion": "^11.2.10", "lucide-react": "^1.14.0", - "minio": "^8.0.7", "next": "16.2.4", "next-themes": "^0.4.6", "react": "^19.2.5", @@ -60,6 +64,7 @@ "eslint-config-formbase": "workspace:*", "postcss": "^8.5.12", "shiki": "^1.6.3", - "tailwindcss": "^4.2.4" + "tailwindcss": "^4.2.4", + "wrangler": "4.87.0" } } diff --git a/apps/web/public/_headers b/apps/web/public/_headers new file mode 100644 index 0000000..3b460e6 --- /dev/null +++ b/apps/web/public/_headers @@ -0,0 +1,2 @@ +/_next/static/* + Cache-Control: public,max-age=31536000,immutable diff --git a/apps/web/src/app/(landing)/_components/header.tsx b/apps/web/src/app/(landing)/_components/header.tsx index 56a1281..abd0927 100644 --- a/apps/web/src/app/(landing)/_components/header.tsx +++ b/apps/web/src/app/(landing)/_components/header.tsx @@ -1,19 +1,16 @@ import Link from 'next/link'; -import { type User } from '@formbase/auth'; -import { env } from '@formbase/env'; - import { Logo } from '../../(auth)/_components/logo'; import { MobileNavigation } from './mobile-navigation'; const routes = [{ name: 'Docs', href: 'https://docs.formbase.dev' }] as const; type LandingHeaderProps = { - user: User | null; + isLoggedIn?: boolean; }; -export const Header = ({ user }: LandingHeaderProps) => { - const isLoggedIn = user !== null; +export const Header = ({ isLoggedIn = false }: LandingHeaderProps) => { + const showAuthLinks = process.env['ALLOW_SIGNIN_SIGNUP']?.trim() !== 'false'; return (
@@ -46,7 +43,7 @@ export const Header = ({ user }: LandingHeaderProps) => { <> - {env.ALLOW_SIGNIN_SIGNUP === 'false' ? null : ( + {showAuthLinks ? ( <> {isLoggedIn ? ( { )} - )} + ) : null} diff --git a/apps/web/src/app/(landing)/layout.tsx b/apps/web/src/app/(landing)/layout.tsx index 8712bf0..78cb235 100644 --- a/apps/web/src/app/(landing)/layout.tsx +++ b/apps/web/src/app/(landing)/layout.tsx @@ -1,22 +1,33 @@ import { type ReactNode } from 'react'; import { redirect } from 'next/navigation'; -import { getSession } from '@formbase/auth/server'; - import { Header } from './_components/header'; import { SiteFooter } from './_components/site-footer'; +export const dynamic = 'force-dynamic'; + async function LandingPageLayout({ children }: { children: ReactNode }) { - const session = await getSession(); - const user = session?.user ?? null; + const databaseUrl = process.env['DATABASE_URL']; + const hasSessionRuntimeEnv = + !!databaseUrl && + (!databaseUrl.startsWith('libsql://') || !!process.env['TURSO_AUTH_TOKEN']) && + !!process.env['BETTER_AUTH_SECRET'] && + !!process.env['NEXT_PUBLIC_APP_URL'] && + !!process.env['ALLOW_SIGNIN_SIGNUP']; + let isLoggedIn = false; + + if (hasSessionRuntimeEnv) { + const { getSession } = await import('@formbase/auth/server'); + isLoggedIn = !!(await getSession())?.user; - if (user) { - redirect('/dashboard'); + if (isLoggedIn) { + redirect('/dashboard'); + } } return (
-
+
{children} diff --git a/apps/web/src/app/(main)/onboarding/form/send-submission-button.tsx b/apps/web/src/app/(main)/onboarding/form/send-submission-button.tsx index 68b449b..06d6361 100644 --- a/apps/web/src/app/(main)/onboarding/form/send-submission-button.tsx +++ b/apps/web/src/app/(main)/onboarding/form/send-submission-button.tsx @@ -8,8 +8,6 @@ import { toast } from 'sonner'; import { LoadingButton } from '~/components/loading-button'; -import { revalidateFromClient } from '../../_actions/revalidateDashboard'; - type SendFormSubmissionButton = { formId: string | null; }; @@ -31,7 +29,6 @@ export default function SendFormSubmissionButton({ toast.success('Form submission sent!'); - void revalidateFromClient(`/form/${formId}`); router.push(`/form/${formId}`); }); }; diff --git a/apps/web/src/app/api/cron/cleanup-audit-logs/route.ts b/apps/web/src/app/api/cron/cleanup-audit-logs/route.ts index 299daa8..87d5e86 100644 --- a/apps/web/src/app/api/cron/cleanup-audit-logs/route.ts +++ b/apps/web/src/app/api/cron/cleanup-audit-logs/route.ts @@ -1,12 +1,13 @@ -import { db } from '@formbase/db'; import { cleanupOldAuditLogs } from '@formbase/api/lib/audit-log'; +import { db } from '@formbase/db'; +import { env } from '@formbase/env'; export const dynamic = 'force-dynamic'; export async function GET(request: Request) { const authHeader = request.headers.get('authorization'); - if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + if (!env.CRON_SECRET || authHeader !== `Bearer ${env.CRON_SECRET}`) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/apps/web/src/app/api/files/[...key]/route.ts b/apps/web/src/app/api/files/[...key]/route.ts new file mode 100644 index 0000000..9efe56f --- /dev/null +++ b/apps/web/src/app/api/files/[...key]/route.ts @@ -0,0 +1,26 @@ +import { createFileDownloadUrl } from '~/lib/upload-file'; + +export const dynamic = 'force-dynamic'; + +const isFileKey = (key: string) => + /^[0-9A-Za-z]{15}\.[+0-9A-Za-z._-]+$/.test(key); + +export async function GET( + _request: Request, + { params }: { params: Promise<{ key: string[] }> }, +) { + const { key } = await params; + const fileKey = key.join('/'); + + if (!isFileKey(fileKey)) { + return new Response('File not found', { status: 404 }); + } + + return new Response(null, { + status: 302, + headers: { + 'Cache-Control': 'private, no-store, max-age=0', + Location: await createFileDownloadUrl(fileKey), + }, + }); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index adbe4c3..c16053c 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -7,7 +7,6 @@ import type { Metadata, Viewport } from 'next'; import { Toaster } from 'sonner'; -import { env } from '@formbase/env'; import { TooltipProvider } from '@formbase/ui/primitives/tooltip'; import { cn } from '@formbase/ui/utils/cn'; @@ -19,6 +18,8 @@ const fontSans = FontSans({ variable: '--font-sans', }); +const umamiTrackingId = process.env['UMAMI_TRACKING_ID']; + export const metadata: Metadata = { title: { default: 'Formbase', @@ -59,11 +60,11 @@ export default function RootLayout({ - {env.UMAMI_TRACKING_ID && ( + {umamiTrackingId && (