diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8887c82 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.gitignore +.next +node_modules +.env +.env.docker +.env.local +.env.production +.env.development +coverage +dist +testsprite_tests +tsconfig.tsbuildinfo +README.md +PROGRESS.md +OPTIMIZATIONS.md +CLAUDE.md +AGENTS.md diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..984990a --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,55 @@ +# Copy to .env.docker before starting the Docker stack. + +# Application URLs +NEXTAUTH_URL="http://localhost:3000" +NEXT_PUBLIC_APP_URL="http://localhost:3000" +AUTH_TRUST_HOST="true" + +# Generate a strong random secret before first boot. +NEXTAUTH_SECRET="replace-with-openssl-rand-base64-32" + +# Docker Compose defaults +POSTGRES_DB="openframe" +POSTGRES_USER="replace-with-postgres-user" +POSTGRES_PASSWORD="replace-with-strong-postgres-password" +DATABASE_URL="postgresql://replace-with-postgres-user:replace-with-strong-postgres-password@postgres:5432/openframe?schema=public" +NODE_ENV="production" + +# Self-host defaults +OPENFRAME_ENABLE_STRIPE="false" +OPENFRAME_ENABLE_BUNNY_UPLOADS="false" +OPENFRAME_REQUIRE_INVITE_CODE="false" +SELF_HOSTED_AUTO_CREATE_BUCKET="true" + +# MinIO-backed S3 storage +MINIO_ROOT_USER="replace-with-minio-root-user" +MINIO_ROOT_PASSWORD="replace-with-strong-minio-password" +R2_ENDPOINT="http://minio: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" +R2_BUCKET_NAME="openframe" + +# Optional auth/admin configuration +ADMIN_EMAILS="" +INVITE_CODE="" + +# Optional integrations. Leave blank to disable. +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +SMTP_HOST="" +SMTP_PORT="587" +SMTP_USER="" +SMTP_PASSWORD="" +SMTP_FROM="" +TELEGRAM_BOT_TOKEN="" +STRIPE_SECRET_KEY="" +STRIPE_PRICE_ID="" +STRIPE_WEBHOOK_SECRET="" +BUNNY_STREAM_API_KEY="" +BUNNY_STREAM_LIBRARY_ID="" +BUNNY_API_KEY="" +BUNNY_CDN_URL="" +NEXT_PUBLIC_BUNNY_CDN_URL="" diff --git a/.env.example b/.env.example index 889a991..c0ac923 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32" OPENFRAME_ENABLE_STRIPE="true" OPENFRAME_ENABLE_BUNNY_UPLOADS="true" OPENFRAME_REQUIRE_INVITE_CODE="true" +SELF_HOSTED_AUTO_CREATE_BUCKET="false" # ============================================================================ # OAUTH PROVIDERS @@ -38,7 +39,10 @@ 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. R2_ACCOUNT_ID="your-account-id" +R2_ENDPOINT="" +R2_PUBLIC_BASE_URL="" R2_ACCESS_KEY_ID="your-access-key" R2_SECRET_ACCESS_KEY="your-secret-key" R2_BUCKET_NAME="openframe" diff --git a/.gitignore b/.gitignore index 28a8dfa..fe08722 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example +!.env.docker.example # vercel .vercel @@ -49,4 +50,4 @@ OPTIMIZATIONS.md .kilocode # Claude doesn't respect AGENTS.md and I don't want double AGENTS.md files on the repo. -CLAUDE.md \ No newline at end of file +CLAUDE.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aafeb47 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +FROM oven/bun:1 AS base +WORKDIR /app + +FROM base AS deps +COPY package.json bun.lock ./ +COPY prisma ./prisma +RUN bun install --frozen-lockfile + +FROM deps AS build +COPY app ./app +COPY components ./components +COPY lib ./lib +COPY prisma ./prisma +COPY public ./public +COPY scripts ./scripts +COPY types ./types +COPY components.json ./components.json +COPY next-env.d.ts ./next-env.d.ts +COPY next.config.ts ./next.config.ts +COPY postcss.config.mjs ./postcss.config.mjs +COPY prisma.config.ts ./prisma.config.ts +COPY tsconfig.json ./tsconfig.json +ENV NODE_ENV=production +RUN bun run db:generate +RUN bun run build + +FROM oven/bun:1 AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/package.json ./package.json +COPY --from=build /app/bun.lock ./bun.lock +COPY --from=build /app/next.config.ts ./next.config.ts +COPY --from=build /app/public ./public +COPY --from=build /app/prisma ./prisma +COPY --from=build /app/scripts ./scripts +COPY --from=build /app/lib ./lib +COPY --from=build /app/app ./app +COPY --from=build /app/components ./components +COPY --from=build /app/types ./types +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/.next ./.next +COPY --from=build /app/tsconfig.json ./tsconfig.json +COPY --from=build /app/postcss.config.mjs ./postcss.config.mjs +COPY --from=build /app/components.json ./components.json +COPY --from=build /app/prisma.config.ts ./prisma.config.ts +COPY --from=build /app/next-env.d.ts ./next-env.d.ts + +RUN chmod +x /app/scripts/docker-entrypoint.sh + +EXPOSE 3000 + +CMD ["bun", "run", "start:docker"] diff --git a/README.md b/README.md index da2b365..1cc221e 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,124 @@ # OpenFrame -OpenFrame is a collaborative video feedback platform built with Next.js, Bun, Prisma, and PostgreSQL. +OpenFrame is an open source video review and approval platform for teams that need clear feedback, version control, and client-friendly review links in one place. It supports collaborative review workflows out of the box and can be self-hosted with the Docker setup included in this repository. -## Development +Prefer not to self-host? You can try OpenFrame at [open-frame.net](https://open-frame.net) with a 7-day free trial, then continue on the hosted plan starting at $10. -Install dependencies and run checks with Bun: +## Product Screenshot + +![OpenFrame product screenshot](public/github-readme.jpeg) + +## What OpenFrame Covers + +OpenFrame is built for video teams that want one system for review, revision, approval, and delivery feedback. + +- Timestamped comments directly on the video timeline +- Voice notes, image attachments, and frame annotations +- Version history with side-by-side compare +- Approval requests and sign-off tracking +- Share links for client review with optional guest commenting +- Workspaces, projects, member roles, and invitation flows +- 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 + +## Core Workflow + +1. Add a video to a project from a YouTube URL or direct upload flow. +2. Share a review link with internal collaborators or external stakeholders. +3. Collect timestamped feedback with text, voice, images, and annotations. +4. Compare versions, resolve comments, and request approvals. +5. Export feedback or keep everything tracked inside the project timeline. + +## Features + +### Review Without Guesswork + +- Timestamped comments anchor every note to an exact moment in the cut. +- Reviewers can leave text, voice notes, image attachments, and drawn annotations. +- Comment threads support replies, resolution states, and project-specific tags. + +### Versioning And Comparison + +- Videos support multiple versions inside the same review thread. +- Teams can switch between versions without losing review context. +- Compare mode lets reviewers inspect two versions side by side. + +### Client And Team Collaboration + +- Share links can be configured for view or comment access. +- Guest review is supported for external stakeholders. +- Workspaces and projects support member roles, invitations, and scoped access. + +### Approval And Reporting + +- Approval requests can be sent to specific reviewers. +- Approval decisions are tracked per request with pending, approved, rejected, and canceled states. +- Comments can be exported as CSV or PDF for offline review and handoff. + +### Assets, Notifications, And Integrations + +- Videos can include related assets such as images, supplementary videos, and audio. +- Notification settings support email and Telegram delivery. +- Self-hosted setups can run with bundled S3-compatible storage or external object storage. +- Optional integrations include Stripe billing, Bunny direct uploads, OAuth providers, SMTP, and Telegram notifications. + +## Stack + +OpenFrame is built with: + +- Next.js 16 and React 19 +- Bun +- TypeScript +- Prisma +- PostgreSQL +- NextAuth.js +- Tailwind CSS +- MinIO or other S3-compatible object storage for self-hosted media +- Bunny Stream for optional direct video uploads + +## Self-Hosting + +OpenFrame ships with a Docker Compose setup for self-hosting. The default stack brings up: + +- OpenFrame on `http://localhost:3000` +- PostgreSQL for the application database +- MinIO for S3-compatible object storage + +### Quick Start ```bash -bun install -bun run check +cp .env.docker.example .env.docker ``` -## Self-hosting flags - -OpenFrame supports env flags so self-hosted installs can disable hosted-only features without code changes: +Edit `.env.docker`, set strong values for `NEXTAUTH_SECRET`, `POSTGRES_PASSWORD`, and the MinIO credentials, then start the stack: ```bash -OPENFRAME_ENABLE_STRIPE=true -OPENFRAME_ENABLE_BUNNY_UPLOADS=true -OPENFRAME_REQUIRE_INVITE_CODE=true +docker compose up --build ``` -Recommended self-hosted values for a single-team deployment: +Open `http://localhost:3000` after the containers become healthy. + +MinIO is bound to `127.0.0.1` by default, so the S3 API and admin console stay local to the host unless you intentionally re-publish those ports. + +The Docker template already trusts `localhost:3000` for Auth.js via `AUTH_TRUST_HOST=true`, so the default local Compose flow does not require extra auth host setup. + +### First Boot Behavior + +- The app waits for PostgreSQL and MinIO before starting. +- Prisma migrations run automatically on container boot. +- The MinIO bucket is created automatically when `SELF_HOSTED_AUTO_CREATE_BUCKET=true`. + +### Persistence And Upgrades + +- PostgreSQL data is stored in the `postgres-data` Docker volume. +- MinIO objects are stored in the `minio-data` Docker volume. +- After updating the repo, rebuild and restart with `docker compose up --build`. + +### Optional Integrations And Feature Flags + +The Docker example disables hosted-only features by default: ```bash OPENFRAME_ENABLE_STRIPE=false @@ -31,8 +128,26 @@ OPENFRAME_REQUIRE_INVITE_CODE=false Behavior when disabled: -- `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. +- `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_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact. -Feature flags are documented in `.env.example`. Hosted defaults remain enabled. +These integrations remain optional for self-hosted deployments and can be enabled later by setting the related environment variables: + +- Stripe billing +- Bunny direct uploads +- SMTP for invitation and notification delivery +- Telegram notifications +- External S3-compatible storage such as Cloudflare R2 or another compatible provider instead of bundled MinIO +- Google and GitHub OAuth + +## Development + +Install dependencies and run validation with Bun: + +```bash +bun install +bun run check +``` + +Feature flags and self-hosting environment variables are documented in `.env.example` and `.env.docker.example`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..004cb18 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + env_file: + - .env.docker + environment: + DOCKER_DB_HOST: postgres + DOCKER_DB_PORT: "5432" + MINIO_HEALTHCHECK_URL: http://minio:9000/minio/health/live + ports: + - "3000:3000" + restart: unless-stopped + + postgres: + image: postgres:16-alpine + env_file: + - .env.docker + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 20 + volumes: + - postgres-data:/var/lib/postgresql/data + restart: unless-stopped + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + env_file: + - .env.docker + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 20 + ports: + - "127.0.0.1:9000:9000" + - "127.0.0.1:9001:9001" + volumes: + - minio-data:/data + restart: unless-stopped + +volumes: + postgres-data: + minio-data: diff --git a/lib/r2.ts b/lib/r2.ts index 11e6a65..dbdc536 100644 --- a/lib/r2.ts +++ b/lib/r2.ts @@ -1,20 +1,109 @@ -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { + CreateBucketCommand, + HeadBucketCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; -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_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_PUBLIC_BASE_URL = process.env.R2_PUBLIC_BASE_URL; -export const r2Client = new S3Client({ - region: 'auto', - endpoint: R2_ENDPOINT || `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, - credentials: { - accessKeyId: R2_ACCESS_KEY_ID, - secretAccessKey: R2_SECRET_ACCESS_KEY, +let cachedR2Client: S3Client | null = null; + +function trimTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ''); +} + +function requireStorageValue(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`Missing ${name} for S3-compatible storage`); + } + + return value; +} + +function getR2Endpoint(): string { + if (R2_ENDPOINT) { + return trimTrailingSlashes(R2_ENDPOINT); + } + + if (!R2_ACCOUNT_ID) { + throw new Error('Missing R2_ENDPOINT or R2_ACCOUNT_ID for S3-compatible storage'); + } + + return `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`; +} + +function getOrCreateR2Client(): S3Client { + if (cachedR2Client) { + return cachedR2Client; + } + + cachedR2Client = new S3Client({ + region: 'auto', + endpoint: getR2Endpoint(), + forcePathStyle: Boolean(R2_ENDPOINT), + credentials: { + accessKeyId: requireStorageValue('R2_ACCESS_KEY_ID', R2_ACCESS_KEY_ID), + secretAccessKey: requireStorageValue('R2_SECRET_ACCESS_KEY', R2_SECRET_ACCESS_KEY), + }, + }); + + return cachedR2Client; +} + +export const r2Client = new Proxy({} as S3Client, { + get(_target, prop, receiver) { + if (prop === 'destroy') { + return () => { + if (!cachedR2Client) return; + cachedR2Client.destroy(); + cachedR2Client = null; + }; + } + + const client = getOrCreateR2Client(); + const value = Reflect.get(client, prop, receiver); + return typeof value === 'function' ? value.bind(client) : value; }, }); +export function getR2PublicObjectUrl(key: string): string { + const sanitizedKey = key.replace(/^\/+/, ''); + + if (R2_PUBLIC_BASE_URL) { + return `${trimTrailingSlashes(R2_PUBLIC_BASE_URL)}/${sanitizedKey}`; + } + + if (R2_ENDPOINT) { + return `${trimTrailingSlashes(R2_ENDPOINT)}/${R2_BUCKET_NAME}/${sanitizedKey}`; + } + + if (!R2_ACCOUNT_ID) { + throw new Error('Missing R2_PUBLIC_BASE_URL or R2_ACCOUNT_ID for public object URLs'); + } + + return `https://${R2_BUCKET_NAME}.${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${sanitizedKey}`; +} + +export async function ensureR2BucketExists(): Promise { + try { + await r2Client.send(new HeadBucketCommand({ Bucket: R2_BUCKET_NAME })); + return; + } catch (error) { + const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode; + if (statusCode && statusCode !== 404 && statusCode !== 301 && statusCode !== 403) { + throw error; + } + } + + await r2Client.send(new CreateBucketCommand({ Bucket: R2_BUCKET_NAME })); +} + export async function uploadAudio( buffer: Buffer, filename: string, @@ -34,9 +123,7 @@ export async function uploadAudio( }) ); - // R2 public URL — uses the R2.dev subdomain or custom domain - // For development, we use the R2.dev auto-generated URL - return `https://${R2_BUCKET_NAME}.${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${key}`; + return getR2PublicObjectUrl(key); } export { R2_BUCKET_NAME }; diff --git a/package.json b/package.json index de74f65..1ef0126 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "prebuild": "bun run typecheck", "build": "next build", "start": "next start", + "start:docker": "sh ./scripts/docker-entrypoint.sh", "lint": "eslint --max-warnings=0", "typecheck": "tsc --noEmit", "check": "bun run lint && bun run typecheck", @@ -16,6 +17,7 @@ "db:migrate": "prisma migrate deploy", "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: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", diff --git a/public/github-readme.jpeg b/public/github-readme.jpeg new file mode 100644 index 0000000..6819a87 Binary files /dev/null and b/public/github-readme.jpeg differ diff --git a/scripts/docker-db-bootstrap.ts b/scripts/docker-db-bootstrap.ts new file mode 100644 index 0000000..0052fe9 --- /dev/null +++ b/scripts/docker-db-bootstrap.ts @@ -0,0 +1,152 @@ +import 'dotenv/config'; +import { Client } from 'pg'; +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; + +type MigrationRow = { + migration_name: string; + finished_at: Date | null; + rolled_back_at: Date | null; +}; + +async function runPrisma(args: string[]) { + await new Promise((resolve, reject) => { + const proc = spawn('./node_modules/.bin/prisma', args, { + stdio: 'inherit', + env: process.env, + }); + + proc.on('error', reject); + proc.on('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`Prisma command failed: prisma ${args.join(' ')}`)); + }); + }); +} + +async function tableExists(client: Client, tableName: string) { + const result = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ) AS "exists" + `, + [tableName] + ); + + return result.rows[0]?.exists ?? false; +} + +async function getMigrationRows(client: Client) { + const hasMigrationsTable = await tableExists(client, '_prisma_migrations'); + if (!hasMigrationsTable) return []; + + const result = await client.query( + ` + SELECT migration_name, finished_at, rolled_back_at + FROM "_prisma_migrations" + ORDER BY started_at ASC + ` + ); + + return result.rows; +} + +function getMigrationDirectories() { + return readdirSync(join(process.cwd(), 'prisma', 'migrations'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +async function getPublicTables(client: Client) { + const result = await client.query<{ table_name: string }>( + ` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name ASC + ` + ); + + return result.rows.map((row) => row.table_name); +} + +async function main() { + if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL is required'); + } + + const client = new Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + + try { + const [publicTables, migrationRows] = await Promise.all([ + getPublicTables(client), + getMigrationRows(client), + ]); + + const appTables = publicTables.filter((table) => table !== '_prisma_migrations'); + const hasUsersTable = appTables.includes('users'); + const hasWorkspacesTable = appTables.includes('workspaces'); + const hasProjectsTable = appTables.includes('projects'); + const hasCoreTables = hasUsersTable || hasWorkspacesTable || hasProjectsTable; + const failedMigrations = migrationRows.filter((row) => !row.finished_at && !row.rolled_back_at); + const shouldBootstrapFreshSchema = !hasCoreTables; + + console.log(`Detected public tables: ${appTables.length > 0 ? appTables.join(', ') : '(none)'}`); + + if (shouldBootstrapFreshSchema) { + if (failedMigrations.length > 0) { + console.log('Detected failed migration state on a fresh database. Marking failed migrations as rolled back.'); + for (const migration of failedMigrations) { + await runPrisma(['migrate', 'resolve', '--rolled-back', migration.migration_name]); + } + } + + console.log('Fresh self-hosted database detected. Synchronizing schema baseline.'); + await runPrisma(['db', 'push']); + + const appliedMigrationNames = new Set( + migrationRows + .filter((row) => row.finished_at && !row.rolled_back_at) + .map((row) => row.migration_name) + ); + + for (const migrationName of getMigrationDirectories()) { + if (appliedMigrationNames.has(migrationName)) continue; + await runPrisma(['migrate', 'resolve', '--applied', migrationName]); + } + + console.log('Fresh database bootstrap complete'); + return; + } + + if (failedMigrations.length > 0) { + throw new Error( + `Detected failed Prisma migrations on a non-empty database: ${failedMigrations + .map((migration) => migration.migration_name) + .join(', ')}` + ); + } + + console.log('Running Prisma migrations'); + await runPrisma(['migrate', 'deploy']); + } finally { + await client.end(); + } +} + +main().catch((error) => { + console.error('Docker database bootstrap failed:', error); + process.exit(1); +}); diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..09951e1 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env sh +set -eu + +DB_HOST="${DOCKER_DB_HOST:-postgres}" +DB_PORT="${DOCKER_DB_PORT:-5432}" +MINIO_HEALTHCHECK_URL="${MINIO_HEALTHCHECK_URL:-http://minio:9000/minio/health/live}" +MAX_ATTEMPTS="${STARTUP_MAX_ATTEMPTS:-60}" +SLEEP_SECONDS="${STARTUP_SLEEP_SECONDS:-2}" + +wait_for_tcp() { + host="$1" + port="$2" + label="$3" + attempt=1 + + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if nc -z "$host" "$port" >/dev/null 2>&1; then + echo "$label is reachable at $host:$port" + return 0 + fi + + echo "Waiting for $label at $host:$port ($attempt/$MAX_ATTEMPTS)" + attempt=$((attempt + 1)) + sleep "$SLEEP_SECONDS" + done + + echo "Timed out waiting for $label at $host:$port" >&2 + exit 1 +} + +wait_for_http() { + url="$1" + label="$2" + attempt=1 + + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if curl --silent --fail "$url" >/dev/null 2>&1; then + echo "$label is reachable at $url" + return 0 + fi + + echo "Waiting for $label at $url ($attempt/$MAX_ATTEMPTS)" + attempt=$((attempt + 1)) + sleep "$SLEEP_SECONDS" + done + + echo "Timed out waiting for $label at $url" >&2 + exit 1 +} + +wait_for_tcp "$DB_HOST" "$DB_PORT" "Postgres" +wait_for_http "$MINIO_HEALTHCHECK_URL" "MinIO" + +echo "Bootstrapping database" +bun run scripts/docker-db-bootstrap.ts + +echo "Running self-host bootstrap" +bun run self-host:bootstrap + +echo "Starting OpenFrame" +exec bun run start diff --git a/scripts/self-host-bootstrap.ts b/scripts/self-host-bootstrap.ts new file mode 100644 index 0000000..26f403f --- /dev/null +++ b/scripts/self-host-bootstrap.ts @@ -0,0 +1,20 @@ +import 'dotenv/config'; +import { ensureR2BucketExists, R2_BUCKET_NAME } from '@/lib/r2'; + +const shouldCreateBucket = /^(1|true|yes|on)$/i.test(process.env.SELF_HOSTED_AUTO_CREATE_BUCKET ?? ''); + +async function main() { + if (!shouldCreateBucket) { + console.log('Skipping self-host bucket bootstrap'); + return; + } + + console.log(`Ensuring object storage bucket exists: ${R2_BUCKET_NAME}`); + await ensureR2BucketExists(); + console.log(`Bucket is ready: ${R2_BUCKET_NAME}`); +} + +main().catch((error) => { + console.error('Self-host bootstrap failed:', error); + process.exit(1); +});