yusufipk b51e690062 fix: close the findings the test suite surfaced
The suite that landed in #43/#44 was written against existing behaviour, so a
number of tests pinned bugs rather than asserting correct behaviour. This fixes
the production code and moves each of those tests onto the fixed behaviour in
the same change.

Security:

- project-download: derive the archive entry extension from the last path
  segment and restrict it to a short alphanumeric run, so an extensionless
  allowlisted url can no longer contribute a path separator; validate the r2
  branch against the strict proxy-path pattern instead of a `startsWith`, which
  let `/api/upload/video/clip.mp4/../../etc/passwd` through verbatim.
- rate-limit: hash a key or action wider than its column instead of skipping the
  query. Both the guard and the failing INSERT used to answer "allowed", so the
  limit stopped applying entirely. Warn at startup when TRUSTED_PROXY_MODE is
  unset in production.
- video uploads: the file name decides the content type; a client-declared video
  mime no longer makes `payload.exe` acceptable.
- email templates: escape in the helpers rather than relying on every caller,
  with an explicit `rawEmailHtml()` opt-out for the one call site that builds
  markup. `escapeHtml` now covers the single quote.
- CSP: allow loopback object storage outside production only.
- route-access: reach the billing redirect only for the workspace owner. Keying
  it off the owner's billing status alone made the redirect target an oracle for
  whose subscription had lapsed, and sent members to a page they cannot act on.
- search: carry the same billing condition every other read path carries.
- logger: check `err.name` as well as `err.constructor.name`, so a re-thrown,
  deserialised or minified Prisma error is still redacted.
- upload tokens: resolve the signing secret outside the try, so a server booted
  without one fails loudly instead of reporting every grant as a forgery.
- invitations: never downgrade an existing membership, and report a scoped
  invitation that points at nothing as not_found rather than accepted.
- auth: resolve the workspace role for every signed-in caller, so
  checkProjectAccess and computeProjectAccess stop disagreeing about the owner
  who also owns the workspace. The `intent` option is gone with it.
- r2-media-proxy: validate the object key inside the proxy so the guard travels
  with the function; delete the unused, unanchored `mediaUrlToR2Key`.
- r2: sign the content type into presigned PUT grants.

Correctness:

- frame rate snapping picks the nearest standard, not the first within
  tolerance, so 24, 30 and 60 fps are reachable at all.
- a version upload registers its Bunny cleanup as soon as bunny-init answers, so
  a failed tus upload no longer leaves a billed video behind.
- deleting videos clears storage before the rows, so a refused DELETE leaves a
  retryable row rather than an orphaned object.
- an expired upload session can be cancelled, which is what releases its quota.
- `voice/` joins the delete allowlist, so a voice note can be removed by the
  module that wrote it.
- a failed CORS write propagates instead of being mistaken for an empty config
  and replacing the bucket's rules.
- filtering projects by workspace no longer hides projects the unfiltered call
  returns.
- upload retries skip aborts and permanent 4xx; progress no longer divides by
  zero.
- reply edits no longer clear the comment's tag; optimistic resolve rolls back
  to the state it replaced; the delete snapshot is captured once.
- assorted UI fixes: duplicate React keys, double-click guards reading stale
  closures, the tag list fetched twice per load, a failed member list rendering
  as an empty one, a stale "Initializing upload..." beside a failure, and a
  registration banner pointing at an email that never arrives.

Consistency and access:

- the two download routes answer 404 for an id belonging to another tenant, as
  the comment export route already did. A caller who does belong still gets 403.
- accessible names for the share-link password field, the guest name gates, the
  version dialog inputs and the comment-tag controls.

Repository health:

- the runner image installs production dependencies only.
- a setup file for the unit project restores stubbed env centrally.
- native tsconfig path resolution replaces vite-tsconfig-paths.
- `uploadBytesWithProgress` exists once.
- admin stats bill Bunny storage to the workspace owner like every other
  quota, gate on the configured flag, wire up the single-flight guard and count
  the statuses that belonged to no bucket.
- `r2Client.destroy()` releases the presign client too.
- `prepare` tolerates a production install, where husky is absent.
2026-07-26 18:53:54 +07:00

OpenFrame

OpenFrame is a fair 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.

Prefer not to self-host? You can try OpenFrame at open-frame.net with a 7-day free trial, then continue on the hosted plan starting at $10.

Product Screenshot

OpenFrame 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 direct uploads (Bunny Stream or self-hosted S3)

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 and direct video uploads
  • Bunny Stream for optional hosted direct video uploads (mutually exclusive with S3 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

cp .env.docker.example .env.docker

Edit .env.docker, set strong values for NEXTAUTH_SECRET, POSTGRES_PASSWORD, and the MinIO credentials, then start the stack:

docker compose up --build

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.

Use The Published Image

If you do not want to build OpenFrame locally, use the published Docker Hub image instead.

Pull a specific version:

podman pull docker.io/yusufipk/openframe:v0.1.0

You can also inspect these tags on Docker Hub:

  • yusufipk/openframe:v0.1.0 for a fixed release
  • yusufipk/openframe:latest for the newest build from the main branch
  • yusufipk/openframe:sha-<commit> for a commit-pinned image

Use latest if you want the newest mainline build. For real deployments, prefer a fixed version tag such as v0.1.0 instead of latest.

To use the published image in Compose, open docker-compose.yml and change only the app service from a local build: block to an image: reference such as docker.io/yusufipk/openframe:v0.1.0. Keep the rest of the service and the postgres and minio services unchanged.

Replace this:

app:
  build:
    context: .
    dockerfile: Dockerfile

With this:

app:
  image: docker.io/yusufipk/openframe:v0.1.0

If the build: block is still present, podman compose up -d will try to build locally from the current directory instead of pulling the published image.

Then start the stack normally:

podman compose up -d

Open http://localhost:3000/login after the containers become healthy.

To verify a published image manually, point your Compose app service at a fixed image tag such as docker.io/yusufipk/openframe:v0.1.0, start the stack with podman compose up -d, and open http://localhost:3000/login after the containers become healthy.

Optional Integrations And Feature Flags

The Docker example disables hosted-only features by default:

OPENFRAME_ENABLE_STRIPE=false
OPENFRAME_ENABLE_BUNNY_UPLOADS=false
OPENFRAME_REQUIRE_INVITE_CODE=false

Behavior when disabled:

  • OPENFRAME_ENABLE_STRIPE=false disables Stripe checkout and customer portal flows and removes billing-based workspace restrictions.
  • OPENFRAME_ENABLE_BUNNY_UPLOADS=false hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available.
  • OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true (with R2_* configured) enables presigned uploads to your own S3-compatible storage. Set OPENFRAME_ENABLE_BUNNY_UPLOADS=false — only one direct-upload backend can be active. The bucket must allow CORS PUT from your app origin (for example http://localhost:3000 in dev and your production URL). For Docker + MinIO, keep R2_ENDPOINT=http://minio:9000 (app-internal) and set R2_PRESIGN_ENDPOINT to the browser-reachable MinIO origin (for example http://localhost:9000 locally, or https://minio.example.com when MinIO is behind a reverse proxy). Use the origin only — no path suffix. The app's Content-Security-Policy is generated from runtime env at request time, so published Docker images pick up custom R2_PRESIGN_ENDPOINT values without rebuilding or editing next.config.ts.
  • OPENFRAME_REQUIRE_INVITE_CODE=false allows open registration while keeping invitation-link registration intact.

For self-hosted MinIO behind a reverse proxy, choose one of these browser-facing layouts:

  • Separate storage host: route https://minio.example.com to MinIO and set R2_PRESIGN_ENDPOINT=https://minio.example.com plus R2_PUBLIC_BASE_URL=https://minio.example.com/openframe.
  • Same app host: route the bucket path, for example https://openframe.example.com/openframe/*, to MinIO and keep all other paths routed to the OpenFrame app. Set R2_PRESIGN_ENDPOINT=https://openframe.example.com and R2_PUBLIC_BASE_URL=https://openframe.example.com/openframe.

Do not add an extra path prefix such as /s3 in front of the bucket unless your proxy rewrites it away before MinIO sees the request. S3 path-style presigned URLs expect the first path segment to be the bucket name, so /openframe/videos/... is valid while /s3/openframe/videos/... makes MinIO treat s3 as the bucket.

These integrations remain optional for self-hosted deployments and can be enabled later by setting the related environment variables:

  • Stripe billing
  • Bunny direct uploads (hosted) or S3 video uploads via OPENFRAME_ENABLE_S3_VIDEO_UPLOADS (self-hosted)
  • SMTP for invitation and notification delivery
  • Telegram notifications
  • External S3-compatible storage such as Cloudflare R2 or another compatible provider instead of bundled MinIO
  • Google and GitHub OAuth

Development

Install dependencies and run validation with Bun:

bun install
bun run check

Feature flags and self-hosting environment variables are documented in .env.example and .env.docker.example.

Running The Tests

The testing stack, layout, and conventions are documented in TESTING.md.

bun run test        # unit and component suites, no database needed
bun run test:api    # API integration suites, needs the test database
bun run test:e2e    # Playwright end-to-end specs, needs the test database
bun run verify      # bun run check plus the unit and component suites

The API and end-to-end suites need the disposable Postgres defined in docker-compose.test.yml, and the end-to-end suite also needs the MinIO service in its e2e profile. Start Postgres with bun run test:db:up and stop everything with bun run test:db:down. Run those two suites one at a time: they share a database, and the API suite empties every table between its tests.

scripts/test.sh <unit|api|e2e|all> is the shortcut: it runs a suite inside a container, so no package manager runs on your host, and it starts the test database first when the suite needs one.

./scripts/test.sh unit
./scripts/test.sh api

The pre-push Git hook runs bun run verify on every push. It leaves bun run test:api out on purpose, because that suite needs the database container.

License

OpenFrame is Fair Source, licensed under the Functional Source License (FSL-1.1-ALv2). The full source code is publicly available, you can self-host it, and every release automatically becomes Apache 2.0 open source two years after its publication. See LICENCE for the full terms.

Contributing

Contributions are welcome.

S
Description
No description provided
Readme
8.1 MiB
Languages
TypeScript 99.4%
Shell 0.3%
CSS 0.1%