mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(docker): add Docker support with configuration files and entrypoint scripts
This commit is contained in:
@@ -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
|
||||||
@@ -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=""
|
||||||
@@ -20,6 +20,7 @@ NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
|
|||||||
OPENFRAME_ENABLE_STRIPE="true"
|
OPENFRAME_ENABLE_STRIPE="true"
|
||||||
OPENFRAME_ENABLE_BUNNY_UPLOADS="true"
|
OPENFRAME_ENABLE_BUNNY_UPLOADS="true"
|
||||||
OPENFRAME_REQUIRE_INVITE_CODE="true"
|
OPENFRAME_REQUIRE_INVITE_CODE="true"
|
||||||
|
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# OAUTH PROVIDERS
|
# OAUTH PROVIDERS
|
||||||
@@ -38,7 +39,10 @@ GITHUB_CLIENT_SECRET="your-github-client-secret"
|
|||||||
# FILE STORAGE
|
# FILE STORAGE
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Cloudflare R2 (S3-compatible)
|
# 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_ACCOUNT_ID="your-account-id"
|
||||||
|
R2_ENDPOINT=""
|
||||||
|
R2_PUBLIC_BASE_URL=""
|
||||||
R2_ACCESS_KEY_ID="your-access-key"
|
R2_ACCESS_KEY_ID="your-access-key"
|
||||||
R2_SECRET_ACCESS_KEY="your-secret-key"
|
R2_SECRET_ACCESS_KEY="your-secret-key"
|
||||||
R2_BUCKET_NAME="openframe"
|
R2_BUCKET_NAME="openframe"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ yarn-error.log*
|
|||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.docker.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
+59
@@ -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"]
|
||||||
@@ -1,27 +1,124 @@
|
|||||||
# OpenFrame
|
# 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
```bash
|
||||||
bun install
|
cp .env.docker.example .env.docker
|
||||||
bun run check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Self-hosting flags
|
Edit `.env.docker`, set strong values for `NEXTAUTH_SECRET`, `POSTGRES_PASSWORD`, and the MinIO credentials, then start the stack:
|
||||||
|
|
||||||
OpenFrame supports env flags so self-hosted installs can disable hosted-only features without code changes:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OPENFRAME_ENABLE_STRIPE=true
|
docker compose up --build
|
||||||
OPENFRAME_ENABLE_BUNNY_UPLOADS=true
|
|
||||||
OPENFRAME_REQUIRE_INVITE_CODE=true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```bash
|
||||||
OPENFRAME_ENABLE_STRIPE=false
|
OPENFRAME_ENABLE_STRIPE=false
|
||||||
@@ -31,8 +128,26 @@ OPENFRAME_REQUIRE_INVITE_CODE=false
|
|||||||
|
|
||||||
Behavior when disabled:
|
Behavior when disabled:
|
||||||
|
|
||||||
- `OPENFRAME_ENABLE_STRIPE=false`: disables Stripe checkout and portal flows and removes billing-based workspace restrictions.
|
- `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 continue to work.
|
- `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.
|
- `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`.
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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_ACCOUNT_ID = process.env.R2_ACCOUNT_ID;
|
||||||
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_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_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY;
|
||||||
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME!;
|
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME ?? '';
|
||||||
const R2_ENDPOINT = process.env.R2_ENDPOINT;
|
const R2_ENDPOINT = process.env.R2_ENDPOINT;
|
||||||
|
const R2_PUBLIC_BASE_URL = process.env.R2_PUBLIC_BASE_URL;
|
||||||
|
|
||||||
export const r2Client = new S3Client({
|
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',
|
region: 'auto',
|
||||||
endpoint: R2_ENDPOINT || `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
endpoint: getR2Endpoint(),
|
||||||
|
forcePathStyle: Boolean(R2_ENDPOINT),
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: R2_ACCESS_KEY_ID,
|
accessKeyId: requireStorageValue('R2_ACCESS_KEY_ID', R2_ACCESS_KEY_ID),
|
||||||
secretAccessKey: R2_SECRET_ACCESS_KEY,
|
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<void> {
|
||||||
|
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(
|
export async function uploadAudio(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
filename: string,
|
filename: string,
|
||||||
@@ -34,9 +123,7 @@ export async function uploadAudio(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// R2 public URL — uses the R2.dev subdomain or custom domain
|
return getR2PublicObjectUrl(key);
|
||||||
// For development, we use the R2.dev auto-generated URL
|
|
||||||
return `https://${R2_BUCKET_NAME}.${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${key}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { R2_BUCKET_NAME };
|
export { R2_BUCKET_NAME };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"prebuild": "bun run typecheck",
|
"prebuild": "bun run typecheck",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"start:docker": "sh ./scripts/docker-entrypoint.sh",
|
||||||
"lint": "eslint --max-warnings=0",
|
"lint": "eslint --max-warnings=0",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"check": "bun run lint && bun run typecheck",
|
"check": "bun run lint && bun run typecheck",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"db:migrate": "prisma migrate deploy",
|
"db:migrate": "prisma migrate deploy",
|
||||||
"db:seed": "prisma db seed",
|
"db:seed": "prisma db seed",
|
||||||
"db:setup": "bun run db:generate && bun run db:migrate",
|
"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:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
|
||||||
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
|
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
|
||||||
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",
|
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
@@ -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<void>((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<MigrationRow>(
|
||||||
|
`
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user