mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9826c117dd | ||
|
|
0ff8b42b4a | ||
|
|
1f2294c7ac | ||
|
|
bcaf9e0c57 | ||
|
|
68d23ef1e6 | ||
|
|
14ddf19d58 | ||
|
|
b040fe0dc9 | ||
|
|
b9e2006e34 | ||
|
|
689f5bc81b | ||
|
|
7b7b1d21b2 | ||
|
|
4d9d35164f | ||
|
|
7b60f3bf76 | ||
|
|
1d65ed348e | ||
|
|
60c208b3b3 | ||
|
|
bf46860feb | ||
|
|
2bc07c47e6 | ||
|
|
a16f7cd856 | ||
|
|
a055f4a8f0 | ||
|
|
98df4e783a | ||
|
|
f9f08a021d | ||
|
|
09af61b531 | ||
|
|
6561e0817e | ||
|
|
63288761ed | ||
|
|
2c4c6101d5 | ||
|
|
e78911e850 | ||
|
|
00f1d430b8 | ||
|
|
7b36e027ff | ||
|
|
4ff801738c | ||
|
|
bfe3cb28b4 | ||
|
|
32164db15c | ||
|
|
313cd552e6 | ||
|
|
1ec5c53802 | ||
|
|
4c0b18e5e4 | ||
|
|
364e60146d | ||
|
|
03f670cf0e | ||
|
|
e98cfe20ec | ||
|
|
b27b37c11f | ||
|
|
39e81042bb | ||
|
|
b8d68a9196 | ||
|
|
e3676e6142 | ||
|
|
33c845636c | ||
|
|
7ca5abd041 | ||
|
|
93e85683e9 | ||
|
|
e7008c571a | ||
|
|
a527e9f935 | ||
|
|
726c04d9cc | ||
|
|
ea6414348f | ||
|
|
733ac43172 | ||
|
|
38d829597a | ||
|
|
80ef29f787 | ||
|
|
a3036f1a52 | ||
|
|
debfd73214 | ||
|
|
425cc04f4f | ||
|
|
4e60b61edb | ||
|
|
c0e7211052 | ||
|
|
1f7b77873c | ||
|
|
4c2e88b1cc | ||
|
|
b51e690062 | ||
|
|
0ceba72d5b | ||
|
|
4e86969f74 | ||
|
|
6136817f75 | ||
|
|
e3fcbf30bf | ||
|
|
4eff54b0a6 | ||
|
|
4e26da62bd | ||
|
|
0187db5dc7 | ||
|
|
fe42c0836f | ||
|
|
72159377fb | ||
|
|
1d099c68f2 | ||
|
|
ab5ae5ad74 | ||
|
|
b1aed03fca | ||
|
|
9c75ce91e1 | ||
|
|
52b2c8d2a9 | ||
|
|
f9ac7fb089 | ||
|
|
5ad01d620d | ||
|
|
bad64d6d48 | ||
|
|
322395551a | ||
|
|
bac6af0ded | ||
|
|
58910a5f5a | ||
|
|
b23f3de666 | ||
|
|
81285681dc | ||
|
|
60b2bc7369 | ||
|
|
481728b93d | ||
|
|
aeee1fc68b | ||
|
|
2bad0a249f | ||
|
|
63e467f437 | ||
|
|
b50ef39329 | ||
|
|
a14eb9fb84 | ||
|
|
f64c04b271 | ||
|
|
b912c1767b | ||
|
|
5871d4d87d | ||
|
|
0faa4b4e2a | ||
|
|
b5fd73dcf2 | ||
|
|
ae78e97fde | ||
|
|
fa1610b053 | ||
|
|
29d2896cb9 | ||
|
|
22bb6a68fb | ||
|
|
16efba4e19 | ||
|
|
d830c9a386 | ||
|
|
00589f3453 | ||
|
|
9595698b5b | ||
|
|
d405fc09da | ||
|
|
33008d33ad | ||
|
|
6c6df3cf1d | ||
|
|
5821f73d38 | ||
|
|
8845c2c643 | ||
|
|
bede216081 | ||
|
|
8d7d064647 | ||
|
|
57c5a127d1 | ||
|
|
654d3a6bc7 | ||
|
|
34e72f6cbb | ||
|
|
cbaecb92cc | ||
|
|
d500dcb042 | ||
|
|
880d0ac0fa | ||
|
|
82932c6b22 | ||
|
|
cebdf23b38 | ||
|
|
95dcf92d8b | ||
|
|
52e4169db2 | ||
|
|
9613c4f2c6 | ||
|
|
56fb7403cf | ||
|
|
51257e004f | ||
|
|
d301f3d808 | ||
|
|
0a89e52a98 | ||
|
|
00124bc7c2 | ||
|
|
52ace1a1a8 | ||
|
|
4bf6e821af | ||
|
|
b6de3a29aa | ||
|
|
6692db992d | ||
|
|
1dcbb78b22 | ||
|
|
378ca1977b | ||
|
|
63f331d220 |
@@ -10,6 +10,11 @@ node_modules
|
||||
coverage
|
||||
dist
|
||||
testsprite_tests
|
||||
tests
|
||||
vitest.config.ts
|
||||
playwright.config.ts
|
||||
playwright-report
|
||||
test-results
|
||||
tsconfig.tsbuildinfo
|
||||
README.md
|
||||
PROGRESS.md
|
||||
|
||||
@@ -18,9 +18,18 @@ NODE_ENV="production"
|
||||
# Self-host defaults
|
||||
OPENFRAME_ENABLE_STRIPE="false"
|
||||
OPENFRAME_ENABLE_BUNNY_UPLOADS="false"
|
||||
OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="true"
|
||||
# OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120"
|
||||
OPENFRAME_REQUIRE_INVITE_CODE="false"
|
||||
SELF_HOSTED_AUTO_CREATE_BUCKET="true"
|
||||
|
||||
# Project bulk-download manifest limits (GET /api/projects/[projectId]/download).
|
||||
OPENFRAME_PROJECT_DOWNLOAD_MAX_FILES="250"
|
||||
# 20 GiB in bytes (20 * 1024 * 1024 * 1024)
|
||||
OPENFRAME_PROJECT_DOWNLOAD_MAX_BYTES="21474836480"
|
||||
# Comma-separated hostnames for direct version download URLs (optional).
|
||||
NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS=""
|
||||
|
||||
# Trusted reverse proxy mode — controls which headers getClientIp() trusts for rate limiting.
|
||||
# Set this only when you have confirmed that your proxy strips/overwrites client-supplied headers.
|
||||
# cloudflare — trust cf-connecting-ip (Cloudflare edge in front of the origin)
|
||||
@@ -32,6 +41,9 @@ TRUSTED_PROXY_MODE="nginx"
|
||||
MINIO_ROOT_USER="replace-with-minio-root-user"
|
||||
MINIO_ROOT_PASSWORD="replace-with-strong-minio-password"
|
||||
R2_ENDPOINT="http://minio:9000"
|
||||
# Browser-facing MinIO origin for presigned upload URLs (scheme + host, no path).
|
||||
# Use your public MinIO domain when behind a reverse proxy, e.g. https://minio.example.com
|
||||
R2_PRESIGN_ENDPOINT="http://localhost: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"
|
||||
@@ -58,5 +70,7 @@ STRIPE_WEBHOOK_SECRET=""
|
||||
BUNNY_STREAM_API_KEY=""
|
||||
BUNNY_STREAM_LIBRARY_ID=""
|
||||
BUNNY_API_KEY=""
|
||||
# Playback host for Bunny versions. Set BUNNY_CDN_URL: the app reads it at request
|
||||
# time, while NEXT_PUBLIC_BUNNY_CDN_URL only reaches the browser in a source build.
|
||||
BUNNY_CDN_URL=""
|
||||
NEXT_PUBLIC_BUNNY_CDN_URL=""
|
||||
|
||||
+44
-1
@@ -19,7 +19,33 @@ NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
|
||||
# ============================================================================
|
||||
OPENFRAME_ENABLE_STRIPE="true"
|
||||
OPENFRAME_ENABLE_BUNNY_UPLOADS="true"
|
||||
# Self-hosted direct video uploads to your S3-compatible storage (R2_* vars below).
|
||||
# Mutually exclusive with Bunny: set OPENFRAME_ENABLE_BUNNY_UPLOADS=false when enabling this.
|
||||
OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="false"
|
||||
# An absolute per-file ceiling for uploaded videos, in bytes. Leave it unset on a
|
||||
# billed instance: the ceiling is then 80% of the account's own storage quota,
|
||||
# leaving room for the renditions the provider derives from the file. When set,
|
||||
# the lower of the two applies. Instances running without billing have no quota
|
||||
# to divide and fall back to 5 GiB.
|
||||
# OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120"
|
||||
# Files larger than this use chunked (S3 multipart) uploads instead of a single PUT.
|
||||
# Default 90MiB keeps each request under the common 100MB Cloudflare proxy/tunnel cap.
|
||||
# Lower it if your proxy enforces a stricter request-body limit.
|
||||
OPENFRAME_R2_MULTIPART_THRESHOLD_BYTES="94371840"
|
||||
# Size of each multipart chunk in bytes (default 32MiB, minimum 5MiB).
|
||||
OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES="33554432"
|
||||
# Direct browser uploads require bucket CORS allowing PUT from your app origin(s).
|
||||
# Run once after creating the bucket: bun run r2:configure-cors
|
||||
# Or set CORS manually in Cloudflare R2 -> bucket -> Settings -> CORS policy.
|
||||
OPENFRAME_REQUIRE_INVITE_CODE="true"
|
||||
# Acquisition attribution and funnel events, read back on /admin/growth. Off by
|
||||
# default: the rows only pay for themselves if you are running a signup funnel.
|
||||
# Everything is written to this instance's own database and sent nowhere.
|
||||
OPENFRAME_ENABLE_ANALYTICS="false"
|
||||
# The date the cardless trial replaced the card-first one, as an ISO date. Set it
|
||||
# to have /admin/growth compare signup-to-paid either side of the switchover;
|
||||
# leave it empty and that section is simply not shown.
|
||||
OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT=""
|
||||
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
|
||||
|
||||
# ============================================================================
|
||||
@@ -39,9 +65,13 @@ 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.
|
||||
# For self-hosted S3-compatible storage such as MinIO, set:
|
||||
# - R2_ENDPOINT (server/container endpoint)
|
||||
# - R2_PRESIGN_ENDPOINT (browser-reachable endpoint for presigned upload URLs)
|
||||
# - R2_PUBLIC_BASE_URL (public base URL for served files)
|
||||
R2_ACCOUNT_ID="your-account-id"
|
||||
R2_ENDPOINT=""
|
||||
R2_PRESIGN_ENDPOINT=""
|
||||
R2_PUBLIC_BASE_URL=""
|
||||
R2_ACCESS_KEY_ID="your-access-key"
|
||||
R2_SECRET_ACCESS_KEY="your-secret-key"
|
||||
@@ -91,6 +121,19 @@ INVITE_CODE="your-secret-invite-code"
|
||||
# Enable debug logging
|
||||
# DEBUG="openframe:*"
|
||||
|
||||
# ============================================================================
|
||||
# DOWNLOADS
|
||||
# ============================================================================
|
||||
# Project bulk-download manifest limits (GET /api/projects/[projectId]/download).
|
||||
# Caps how many files and total known bytes a single manifest may enumerate.
|
||||
OPENFRAME_PROJECT_DOWNLOAD_MAX_FILES="250"
|
||||
# 20 GiB in bytes (20 * 1024 * 1024 * 1024)
|
||||
OPENFRAME_PROJECT_DOWNLOAD_MAX_BYTES="21474836480"
|
||||
# Comma-separated hostnames allowed for direct (non-proxied) version download URLs
|
||||
# in manifests and the video page. Bunny CDN host is always allowed when configured.
|
||||
# Example: "cdn.example.com,files.example.com"
|
||||
NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS=""
|
||||
|
||||
# ============================================================================
|
||||
# VIDEO PROCESSING
|
||||
# ============================================================================
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Environment for the `api` Vitest project. `scripts/test.sh` copies this to
|
||||
# `.env.test` (gitignored) on the first api or e2e run, so there is usually
|
||||
# nothing to do by hand.
|
||||
#
|
||||
# `tests/setup/db-global.ts` and `tests/setup/api.ts` both load this file (via
|
||||
# `tests/helpers/env.ts`) before anything imports `@/lib/db`, which reads
|
||||
# DATABASE_URL once at module load and memoizes the pool. An already-exported
|
||||
# variable always wins over the file, so CI can override DATABASE_URL without
|
||||
# editing anything. What bun autoloaded from a development `.env` does not count
|
||||
# as exported and is dropped first, or that file would quietly win here and
|
||||
# point the suites at a real deployment.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DATABASE
|
||||
# ---------------------------------------------------------------------------
|
||||
# The default targets the container-to-container hostname, because the test
|
||||
# runner itself runs in a container attached to the `openframe-test` network:
|
||||
# podman compose -f docker-compose.test.yml up -d
|
||||
# podman run --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \
|
||||
# docker.io/oven/bun:alpine sh -c "bun run test:api"
|
||||
DATABASE_URL="postgresql://openframe:openframe@postgres-test:5432/openframe_test?schema=public"
|
||||
|
||||
# From the host instead (psql, or a runner that is not on that network), use the
|
||||
# published port:
|
||||
# DATABASE_URL="postgresql://openframe:[email protected]:55432/openframe_test?schema=public"
|
||||
#
|
||||
# On GitHub Actions, where Postgres is a service container on the job network:
|
||||
# DATABASE_URL="postgresql://openframe:openframe@localhost:5432/openframe_test?schema=public"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AUTH
|
||||
# ---------------------------------------------------------------------------
|
||||
# `auth()` is mocked in tests, so NEXTAUTH_SECRET is only used for real work by
|
||||
# lib/share-session.ts, which HMAC-signs the share cookies.
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="test-secret-not-used-for-anything-real"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FEATURE FLAGS
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stripe on, because that is what production runs and because it is what arms
|
||||
# every billing gate: hasBillingAccess() short-circuits to `true` when the flag
|
||||
# is off, which would silently neuter the whole access-control surface. Tests
|
||||
# that want the self-hosted behaviour stub the flag off per test.
|
||||
OPENFRAME_ENABLE_STRIPE="true"
|
||||
# Dummy credentials so isStripeBillingEnabled() is true and getStripePriceId()
|
||||
# does not throw. `@/lib/stripe` is module-mocked, so no request ever leaves.
|
||||
STRIPE_SECRET_KEY="sk_test_openframe_dummy"
|
||||
STRIPE_PRICE_ID="price_test_openframe_dummy"
|
||||
STRIPE_WEBHOOK_SECRET="whsec_test_openframe_dummy"
|
||||
|
||||
OPENFRAME_REQUIRE_INVITE_CODE="true"
|
||||
INVITE_CODE="test-invite"
|
||||
|
||||
# Direct-upload providers are deliberately left unconfigured, so
|
||||
# isDirectFileUploadEnabled() is false by default. Suites that exercise the
|
||||
# presigned-upload routes stub R2_* / BUNNY_* in per test.
|
||||
|
||||
# No proxy in front of the test runner, so getClientIp() returns 127.0.0.1.
|
||||
TRUSTED_PROXY_MODE="none"
|
||||
|
||||
# Rate limits are DB-backed and keyed on the client IP, which is that same
|
||||
# constant for every request in the suite. Left on, one test exhausting a
|
||||
# window would make the next test's 429 look like a passing authorization
|
||||
# check. tests/api/rate-limit.test.ts re-enables it with vi.stubEnv (the flag
|
||||
# is read per call, not at import) and is the only place that asserts on it.
|
||||
DISABLE_RATE_LIMIT="true"
|
||||
|
||||
# SMTP is configured on purpose: isEmailVerificationEnabled() is derived from
|
||||
# these three variables, and with them unset the register/verify routes take a
|
||||
# different branch than production does. `nodemailer` is module-mocked in
|
||||
# tests/setup/api.ts, so nothing leaves the process; the messages are captured
|
||||
# and assertable through tests/helpers/mail.ts.
|
||||
SMTP_HOST="localhost"
|
||||
SMTP_PORT="1025"
|
||||
SMTP_USER="test"
|
||||
SMTP_PASSWORD="test"
|
||||
SMTP_FROM="OpenFrame Test <[email protected]>"
|
||||
|
||||
NODE_ENV="test"
|
||||
+226
-1
@@ -1,6 +1,17 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
# For the `mutation` job below, which is too slow to run on a push. Everything
|
||||
# else runs on the schedule too, which costs nothing and catches the class of
|
||||
# breakage that comes from a dependency rather than from a commit.
|
||||
schedule:
|
||||
- cron: '0 4 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
@@ -10,3 +21,217 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- run: bun run check
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
# Postgres for the `api` Vitest project. This job runs directly on the
|
||||
# runner, so the service is reachable on localhost through the published
|
||||
# port, not by service name.
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: openframe
|
||||
POSTGRES_PASSWORD: openframe
|
||||
POSTGRES_DB: openframe_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U openframe -d openframe_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 3s
|
||||
--health-retries 30
|
||||
env:
|
||||
DATABASE_URL: postgresql://openframe:openframe@localhost:5432/openframe_test?schema=public
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- name: Create .env.test from the committed example
|
||||
# The `api` project's setup files read `.env.test`, which is gitignored.
|
||||
# The example carries every value the suites need; only the database host
|
||||
# differs, because locally it is the compose service and here it is a
|
||||
# service container. The appended line wins inside the file and the
|
||||
# exported job env wins over the file, so the override holds either way.
|
||||
run: |
|
||||
cp .env.test.example .env.test
|
||||
printf '\nDATABASE_URL=%s\n' "$DATABASE_URL" >> .env.test
|
||||
# No migration step here on purpose. The api project's globalSetup
|
||||
# (tests/setup/db-global.ts) builds the schema itself, and it cannot use
|
||||
# `prisma migrate deploy`: prisma/migrations is a stack of patches on top
|
||||
# of a baseline that was never captured, so the second migration alters an
|
||||
# enum that nothing in the history creates. That file explains it in full.
|
||||
- name: Unit and component tests
|
||||
run: bun run test
|
||||
- name: API integration tests
|
||||
run: bun run test:api
|
||||
- name: Coverage report
|
||||
# Diagnostic only. There is no coverage threshold gate on purpose, see
|
||||
# TESTING.md section 11.
|
||||
#
|
||||
# Run under node rather than `bun run test:coverage`: @vitest/coverage-v8
|
||||
# needs the V8 inspector API, which bun does not implement, so under bun
|
||||
# every file reports "Coverage APIs are not supported" and the numbers
|
||||
# come out as zero. The suite itself passes under both runtimes.
|
||||
run: node node_modules/vitest/vitest.mjs run --project unit --coverage
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
mutation:
|
||||
# Mutation testing on the authorization and validation surface. Not a gate:
|
||||
# it reports, it never fails the build (`break: null` in stryker.config.json),
|
||||
# for the same reason there is no coverage threshold. See TESTING.md
|
||||
# section 11.
|
||||
#
|
||||
# Weekly and on demand only. A full run is minutes rather than seconds,
|
||||
# because Stryker reruns the suite once per mutant, and nobody waits that
|
||||
# long on a pull request. The findings it produces are not the kind that
|
||||
# need catching within the hour: it finds tests that cannot fail, which is a
|
||||
# slow leak rather than a regression.
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# node, not bun: Stryker's instrumenter and its vitest runner both expect
|
||||
# a node runtime, and @stryker-mutator/core declares `engines.node >= 20`.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
# bun for the install (it owns bun.lock), node for the run.
|
||||
- run: bun install
|
||||
- name: Mutation testing
|
||||
run: node node_modules/@stryker-mutator/core/bin/stryker.js run
|
||||
- name: Upload the mutation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mutation-report
|
||||
path: reports/mutation/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check]
|
||||
# Runs directly on the runner rather than in the Playwright container image.
|
||||
# Two reasons, both learned the hard way. MinIO cannot be a `services:` entry
|
||||
# (see the step that starts it below), and starting it as a step needs a
|
||||
# docker CLI, which a container job does not have. And the Playwright image
|
||||
# ships neither bun nor unzip, so bun had to be installed through npm and the
|
||||
# image tag had to be kept in lockstep with the npm package. Installing the
|
||||
# browser here costs about a minute and removes all of that.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: openframe
|
||||
POSTGRES_PASSWORD: openframe
|
||||
POSTGRES_DB: openframe_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U openframe -d openframe_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 3s
|
||||
--health-retries 30
|
||||
env:
|
||||
DATABASE_URL: postgresql://openframe:openframe@localhost:5432/openframe_test?schema=public
|
||||
# The Playwright web server builds and starts the app, and `next build`
|
||||
# does not run with NODE_ENV=test, so it never picks up `.env.test`. These
|
||||
# values are therefore set on the job itself. Port 3100 matches
|
||||
# playwright.config.ts.
|
||||
NEXTAUTH_URL: http://localhost:3100
|
||||
NEXTAUTH_SECRET: ci-secret-not-used-for-anything-real
|
||||
NEXT_PUBLIC_APP_URL: http://localhost:3100
|
||||
# Required. NextAuth v5 answers every /api/auth/* request with
|
||||
# `UntrustedHost` in a production build unless the host is trusted, which
|
||||
# is why .env.docker.example sets the same variable for real deployments.
|
||||
AUTH_TRUST_HOST: 'true'
|
||||
# Stripe ON, with dummy credentials, and deliberately not 'false'.
|
||||
# hasBillingAccess() short-circuits to `true` when the flag is off and
|
||||
# buildBillingAccessWhereInput() returns `{}`, which disarms the whole
|
||||
# billing gate: billing-gate.spec.ts would then assert nothing. No spec
|
||||
# walks into checkout, so nothing reaches Stripe. This also keeps the
|
||||
# e2e job consistent with .env.test, which the api suite already runs
|
||||
# with the flag on for the same reason.
|
||||
OPENFRAME_ENABLE_STRIPE: 'true'
|
||||
STRIPE_SECRET_KEY: sk_test_openframe_dummy
|
||||
STRIPE_PRICE_ID: price_test_openframe_dummy
|
||||
STRIPE_WEBHOOK_SECRET: whsec_test_openframe_dummy
|
||||
OPENFRAME_REQUIRE_INVITE_CODE: 'true'
|
||||
INVITE_CODE: test-invite
|
||||
TRUSTED_PROXY_MODE: none
|
||||
# Direct video uploads, pointed at the MinIO container started below.
|
||||
# Without these the `Direct Upload` tab is not rendered and
|
||||
# video-upload.spec.ts fails on its first assertion rather than silently
|
||||
# testing nothing. The browser PUTs the file straight at the presigned URL,
|
||||
# so the app and the browser have to agree on this host, and both run on
|
||||
# the runner.
|
||||
OPENFRAME_ENABLE_S3_VIDEO_UPLOADS: 'true'
|
||||
OPENFRAME_ENABLE_BUNNY_UPLOADS: 'false'
|
||||
R2_ENDPOINT: http://localhost:9000
|
||||
R2_ACCESS_KEY_ID: openframe
|
||||
R2_SECRET_ACCESS_KEY: openframe-test-secret
|
||||
R2_BUCKET_NAME: openframe-test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- name: Create .env.test from the committed example
|
||||
run: |
|
||||
cp .env.test.example .env.test
|
||||
printf '\nDATABASE_URL=%s\n' "$DATABASE_URL" >> .env.test
|
||||
- name: Start MinIO
|
||||
# Not a `services:` entry, because a service block cannot pass a command
|
||||
# to the container and the MinIO entrypoint requires `server /data`.
|
||||
# Without arguments the container prints its usage text, exits, and the
|
||||
# job dies at "Failed to initialize container minio/minio:latest".
|
||||
run: |
|
||||
docker run -d --name minio -p 9000:9000 \
|
||||
-e MINIO_ROOT_USER="$R2_ACCESS_KEY_ID" \
|
||||
-e MINIO_ROOT_PASSWORD="$R2_SECRET_ACCESS_KEY" \
|
||||
-e MINIO_REGION_NAME=auto \
|
||||
minio/minio:latest server /data
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -sf http://localhost:9000/minio/health/live >/dev/null; then
|
||||
echo 'minio is ready'
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo 'minio did not become ready within 60 seconds' >&2
|
||||
docker logs minio >&2
|
||||
exit 1
|
||||
- name: Create the MinIO bucket
|
||||
# Nothing at runtime creates it: ensureR2BucketExists() lives in
|
||||
# scripts/self-host-bootstrap.ts, not on the request path, so a missing
|
||||
# bucket would surface as a presigned PUT returning NoSuchBucket.
|
||||
run: |
|
||||
curl -sSfL -o "$RUNNER_TEMP/mc" https://dl.min.io/client/mc/release/linux-amd64/mc
|
||||
chmod +x "$RUNNER_TEMP/mc"
|
||||
"$RUNNER_TEMP/mc" alias set ciminio "$R2_ENDPOINT" "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY"
|
||||
"$RUNNER_TEMP/mc" mb --ignore-existing "ciminio/$R2_BUCKET_NAME"
|
||||
- name: Install the Playwright browser
|
||||
# Only chromium: playwright.config.ts runs a desktop Chromium project and
|
||||
# a Pixel 7 project, and the mobile one is Chromium too.
|
||||
run: bunx playwright install --with-deps chromium
|
||||
# No `bun run test:db:bootstrap` step: tests/e2e/global-setup.ts calls the
|
||||
# same setup function before the web server starts, and also clears the
|
||||
# rate_limits table so a retry does not inherit a spent window.
|
||||
- name: End-to-end tests
|
||||
run: bun run test:e2e
|
||||
- name: Upload the Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright-report
|
||||
/test-results
|
||||
/.playwright
|
||||
/reports
|
||||
/.stryker-tmp
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -34,6 +39,7 @@ yarn-error.log*
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.docker.example
|
||||
!.env.test.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
bun run verify
|
||||
@@ -2,3 +2,8 @@
|
||||
node_modules/
|
||||
prisma/migrations/
|
||||
bun.lock
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
reports/
|
||||
.stryker-tmp/
|
||||
|
||||
@@ -10,6 +10,104 @@
|
||||
## Validation before finishing
|
||||
|
||||
- Run `bun run check`.
|
||||
- Run `bun run verify` (this is `bun run check` plus the unit and component tests).
|
||||
- If you touched an API route, also run `bun run test:api`. It needs the test database:
|
||||
`bun run test:db:up` first.
|
||||
- If you added a batch of tests, hand them to a second reviewer before calling the work
|
||||
done. See "A batch of new tests gets an adversarial review, by somebody else" below.
|
||||
- If you added an end-to-end spec, run it under CI conditions too, not only locally. Hosts
|
||||
differ between the two, and a `page.route()` glob that matches nothing passes locally and
|
||||
tests nothing anywhere.
|
||||
|
||||
## Testing
|
||||
|
||||
- The testing stack, layout and conventions live in `TESTING.md`. Read it before adding a
|
||||
test.
|
||||
- Tests live in a top-level `tests/` tree, never colocated with the code.
|
||||
- Import test globals explicitly: `import { describe, it, expect, vi } from 'vitest';`.
|
||||
- Never write a BigInt literal (`1n`) in any file. `tsconfig.json` targets ES2017, so `tsc`
|
||||
rejects the syntax with TS2737 and `bun run check` fails. Use `BigInt(1)` instead, and
|
||||
compare `BigInt(...)` against `BigInt(...)`.
|
||||
|
||||
### When a change needs a test
|
||||
|
||||
Match the change to a layer. Most changes need exactly one.
|
||||
|
||||
| You changed | Write |
|
||||
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| A pure function in `lib/` (validation, a limit, a date rule, a URL or filename check, a permission calculation) | a unit test in `tests/unit/lib/` |
|
||||
| An API route, or any authorization, quota or billing rule behind one | an integration test in `tests/api/` |
|
||||
| A new API route | classify it in `tests/api/auth-matrix.test.ts`, or the suite fails until you do |
|
||||
| Real logic in a React hook (optimistic updates, throttling, retries) | a hook test in `tests/component/hooks/` |
|
||||
| A user-visible flow across more than one page | an end-to-end spec in `tests/e2e/` |
|
||||
| Presentation only (styling, copy, layout, a `components/ui/` wrapper) | nothing |
|
||||
|
||||
Always write a test for a bug fix, at the layer where the bug lived. The test must fail
|
||||
before the fix and pass after it. If it passes before the fix, it is testing the wrong
|
||||
thing.
|
||||
|
||||
For an API route, three cases are the minimum: an unauthenticated caller, a caller who is
|
||||
signed in but not authorized, and the happy path. Assert the database row, not only the
|
||||
status code: a refused DELETE has to leave the row present.
|
||||
|
||||
### Three ways a test can be worthless
|
||||
|
||||
All three have been found in this repo, so they are worth naming.
|
||||
|
||||
1. **A test that cannot fail.** Before you finish, name the specific mutation of the
|
||||
production code your test would catch. If you cannot name one, delete the test. When it
|
||||
matters, prove it: break the code on purpose, watch the test go red, then revert.
|
||||
2. **A test whose input comes from the code under test.** Iterating the same constant the
|
||||
function looks up means deleting an entry from that constant also deletes its own test
|
||||
case. Write expected values by hand as literals.
|
||||
|
||||
3. **A selector, route pattern or filter that matches nothing.** This one is specific to
|
||||
`tests/e2e/`, and it fails in the worst direction: an injected failure that never gets
|
||||
injected leaves the feature working, and the test then waits for an error message that
|
||||
was never going to appear. Both halves need to be asserted, so count what you
|
||||
intercepted and assert the count, the way
|
||||
`tests/e2e/failure-recovery.spec.ts` does with `refusedPuts`. Never hardcode a host in a
|
||||
`page.route()` glob either: CI publishes MinIO on localhost and the compose stack calls
|
||||
it `minio-test`, and the pattern that matched locally silently matched nothing on CI.
|
||||
|
||||
A fourth variant is specific to `tests/api/auth-matrix.test.ts`: a route that refuses a
|
||||
malformed request before it reaches its access check produces an entry that passes whether
|
||||
or not the guard exists. The suite catches it by requiring a 401 or a 403 rather than
|
||||
merely a non-2xx, and a 404 counts as suspicious rather than as a refusal, since a fixture
|
||||
id that stops resolving would otherwise pass forever. `NON_AUTHORIZATION_REFUSALS` and
|
||||
`NOT_FOUND_IS_THE_GUARD` in that file explain the whole trap; both are empty, and adding to
|
||||
either is meant to be a visible diff.
|
||||
|
||||
`bun run test:mutation` automates case 1 across the authorization and validation modules
|
||||
listed in `stryker.config.json`. It is slow, so it is not part of `bun run check`, and CI
|
||||
runs it weekly rather than on a push. Reach for it when you have written a batch of tests
|
||||
and want to know which of them are decorative.
|
||||
|
||||
### A batch of new tests gets an adversarial review, by somebody else
|
||||
|
||||
**Rule: whoever wrote a batch of tests does not get to be the one who signs it off.** When
|
||||
a change adds a meaningful number of tests (a new suite, or a set of them), a second
|
||||
reviewer goes over them with one question in mind: _do these tests deliver what they claim
|
||||
to?_ If the work is being done by agents, that reviewer is a separate agent with no stake
|
||||
in the code it is reading.
|
||||
|
||||
This is not a style pass. The reviewer's job is to find:
|
||||
|
||||
- Tests that pass whether or not the production code works. Verify by mutation, do not take
|
||||
the author's word for it, and prefer a mutation the author did not already try.
|
||||
- Assertions weak enough to survive the bug they were written for: `toBeTruthy()` on an
|
||||
object, a status code checked without checking the database row, a `403` with no `2xx`
|
||||
beside it, a `not.toThrow()` standing in for a real expectation.
|
||||
- A test whose subject is the mock rather than the code. If every dependency is stubbed,
|
||||
ask what is left to be wrong.
|
||||
- Coverage that reads as complete but is not: the happy path tested five ways and the
|
||||
rollback, the concurrent call and the failure branch tested not at all.
|
||||
- Names that promise more than the body checks. The name is what the next person trusts.
|
||||
- Setup so elaborate that the test no longer describes a situation the app can reach.
|
||||
|
||||
Two rounds of this have already been run on this suite and both found real problems, so it
|
||||
is worth the cost. Findings go back to the author to fix; the reviewer does not quietly
|
||||
rewrite the tests.
|
||||
|
||||
## Repo-specific conventions
|
||||
|
||||
|
||||
@@ -29,6 +29,36 @@ bun run db:generate
|
||||
bun run check
|
||||
```
|
||||
|
||||
## Running the tests
|
||||
|
||||
The testing stack, layout, and conventions live in [TESTING.md](TESTING.md). Read it before adding a test, and see the "When a change needs a test" table in [AGENTS.md](AGENTS.md) for which layer your change belongs in. A bug fix always needs a test that fails before the fix.
|
||||
|
||||
| Command | Runs | Needs the test database |
|
||||
| ------------------ | -------------------------------------------------- | ----------------------- |
|
||||
| `bun run test` | unit and component suites | no |
|
||||
| `bun run test:api` | API integration suites | yes |
|
||||
| `bun run test:e2e` | Playwright end-to-end specs | yes |
|
||||
| `bun run verify` | `bun run check` plus the unit and component suites | no |
|
||||
|
||||
`scripts/test.sh mutation` is the other one worth knowing about. It runs StrykerJS over the authorization and validation modules, breaking one line at a time to find tests that pass either way. It takes minutes rather than seconds, so it is not in `all` and CI runs it weekly; reach for it after writing a batch of tests. It needs node rather than bun, which the script handles.
|
||||
|
||||
The test database is a disposable Postgres defined in `docker-compose.test.yml`, on port `55432` so it cannot collide with your dev stack.
|
||||
|
||||
```bash
|
||||
bun run test:db:up
|
||||
bun run test:api
|
||||
bun run test:db:down
|
||||
```
|
||||
|
||||
`scripts/test.sh <unit|api|e2e|all>` does all of that in one command. It runs each suite inside a container, so no package manager runs on your host, and it starts the test database first when the suite needs one.
|
||||
|
||||
```bash
|
||||
./scripts/test.sh unit
|
||||
./scripts/test.sh all
|
||||
```
|
||||
|
||||
The `pre-push` hook runs `bun run verify`, so lint, format, typecheck, and the unit and component suites have to pass before a push leaves your machine. `bun run test:api` is deliberately not in the hook, because it needs the database container. Run it yourself when you change an API route.
|
||||
|
||||
## Contribution workflow
|
||||
|
||||
1. Fork and create a branch from `master`.
|
||||
@@ -56,6 +86,8 @@ type(scope): short summary
|
||||
## Required checks before opening a PR
|
||||
|
||||
- Run `bun run check`.
|
||||
- Run `bun run verify`, or let the `pre-push` hook run it for you.
|
||||
- If you changed an API route, also run `bun run test:api` with the test database up.
|
||||
- If `prisma/schema.prisma` changed, run `bun run db:generate`.
|
||||
- Ensure no unrelated file changes are included.
|
||||
- Ensure no secrets or private keys are committed.
|
||||
|
||||
+14
-1
@@ -6,6 +6,14 @@ COPY package.json bun.lock ./
|
||||
COPY prisma ./prisma
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# The tree the runner ships. The build needs eslint, vitest, playwright and the rest; the
|
||||
# running app does not, and copying the full tree put them all in the image.
|
||||
FROM base AS prod-deps
|
||||
COPY package.json bun.lock ./
|
||||
COPY prisma ./prisma
|
||||
RUN bun install --frozen-lockfile --production
|
||||
RUN bun run db:generate
|
||||
|
||||
FROM deps AS build
|
||||
COPY app ./app
|
||||
COPY components ./components
|
||||
@@ -16,6 +24,7 @@ COPY scripts ./scripts
|
||||
COPY types ./types
|
||||
COPY components.json ./components.json
|
||||
COPY next.config.ts ./next.config.ts
|
||||
COPY proxy.ts ./proxy.ts
|
||||
COPY postcss.config.mjs ./postcss.config.mjs
|
||||
COPY prisma.config.ts ./prisma.config.ts
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
@@ -36,6 +45,7 @@ RUN apt-get update \
|
||||
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/proxy.ts ./proxy.ts
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
COPY --from=build /app/scripts ./scripts
|
||||
@@ -43,7 +53,10 @@ 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=prod-deps /app/node_modules ./node_modules
|
||||
# next.config.ts and prisma.config.ts are TypeScript, and both are loaded at startup, so
|
||||
# the compiler has to be present even though nothing else here needs it.
|
||||
COPY --from=deps /app/node_modules/typescript ./node_modules/typescript
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/tsconfig.json ./tsconfig.json
|
||||
COPY --from=build /app/postcss.config.mjs ./postcss.config.mjs
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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](https://open-frame.net) with a 7-day free trial, then continue on the hosted plan starting at $10.
|
||||
Prefer not to self-host? You can try OpenFrame at [open-frame.net](https://open-frame.net) with a 7-day free trial that needs no card, then continue on the hosted plan starting at $10.
|
||||
|
||||
## Product Screenshot
|
||||
|
||||
@@ -21,7 +21,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
|
||||
- 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
|
||||
- URL-based YouTube video intake plus optional direct uploads (Bunny Stream or self-hosted S3)
|
||||
|
||||
## Core Workflow
|
||||
|
||||
@@ -75,8 +75,8 @@ OpenFrame is built with:
|
||||
- PostgreSQL
|
||||
- NextAuth.js
|
||||
- Tailwind CSS
|
||||
- MinIO or other S3-compatible object storage for self-hosted media
|
||||
- Bunny Stream for optional direct video uploads
|
||||
- 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
|
||||
|
||||
@@ -116,6 +116,54 @@ The Docker template already trusts `localhost:3000` for Auth.js via `AUTH_TRUST_
|
||||
- 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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
```
|
||||
|
||||
With this:
|
||||
|
||||
```yaml
|
||||
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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
@@ -129,13 +177,22 @@ 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 direct-upload entry points. URL-based providers such as YouTube remain available.
|
||||
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available. When enabling it, set `BUNNY_CDN_URL` (not only `NEXT_PUBLIC_BUNNY_CDN_URL`): it is read at request time, so a published image picks up the playback host without a rebuild.
|
||||
- `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.
|
||||
- `OPENFRAME_ENABLE_ANALYTICS=true` records first-touch attribution and funnel events into your own database, readable on `/admin/growth`, or as JSON on `/api/admin/growth` by a script sending `Authorization: Bearer $OPENFRAME_ADMIN_API_TOKEN` (at least 32 characters, unset by default, in which case an admin session is the only way in). Off by default, and nothing leaves the instance either way.
|
||||
|
||||
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
|
||||
- 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
|
||||
@@ -152,6 +209,32 @@ 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](TESTING.md).
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```bash
|
||||
./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](https://fsl.software/) (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](LICENCE) for the full terms.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome.
|
||||
@@ -159,3 +242,4 @@ Contributions are welcome.
|
||||
- Read [CONTRIBUTING.md](CONTRIBUTING.md) for workflow, conventions, and PR requirements.
|
||||
- Use [SECURITY.md](SECURITY.md) for responsible vulnerability reporting.
|
||||
- Follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) in all project interactions.
|
||||
- Contact: [[email protected]](mailto:[email protected])
|
||||
|
||||
+4
-1
@@ -8,7 +8,10 @@ Security fixes are prioritized for the latest code on `master` and recent releas
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please do not report security vulnerabilities in public issues.
|
||||
Report vulnerabilities by email to info@open-frame.net.
|
||||
|
||||
**Preferred:** use [GitHub private vulnerability reporting](https://github.com/yusufipk/OpenFrame/security/advisories/new) on this repository.
|
||||
|
||||
**Alternative:** email info@open-frame.net if you cannot use GitHub.
|
||||
|
||||
Include as much detail as possible:
|
||||
|
||||
|
||||
+816
@@ -0,0 +1,816 @@
|
||||
# Testing Plan
|
||||
|
||||
Status: **all six phases delivered**, and a second round has since closed the coverage gaps
|
||||
the first one left. What actually landed, and where reality differed from the plan, is in
|
||||
Section 12; the gap-closing round is Section 13. The sections below are kept as written so
|
||||
the reasoning behind each decision stays readable.
|
||||
|
||||
| Suite | Command | Tests | Runtime |
|
||||
| ---------------- | ------------------ | -------- | ------- |
|
||||
| Unit + component | `bun run test` | 2079 | 12s |
|
||||
| API integration | `bun run test:api` | 1015 | 92s |
|
||||
| End to end | `bun run test:e2e` | 29 | 66s |
|
||||
| **Total** | `bun run test:all` | **3123** | |
|
||||
|
||||
OpenFrame is ~56k lines across 60 API route handlers, ~90 components and ~50 `lib/`
|
||||
modules. Before this, every change was verified by hand. This document defines the stack,
|
||||
the layout, the priority order, and the exact commands so that verification becomes
|
||||
`bun run test`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Primer
|
||||
|
||||
Short glossary, because this repo has no testing history:
|
||||
|
||||
- **Unit test**: calls one function directly with fixed inputs and asserts the return
|
||||
value. No database, no network, no browser. Runs in milliseconds.
|
||||
- **Integration test**: exercises several real pieces together. Here that means calling an
|
||||
API route handler with a real request object against a real (test) Postgres, with only
|
||||
the session faked.
|
||||
- **E2E test**: drives a real browser against a running app. Verifies what a user sees.
|
||||
- **Mock / stub**: a fake stand-in for a dependency (`auth()`, Stripe, S3).
|
||||
- **Factory**: a helper that inserts a realistic row into the test DB
|
||||
(`createProject({ visibility: 'PUBLIC' })`).
|
||||
- **Fixture**: a fixed input file or dataset a test reads from.
|
||||
- **Flaky test**: passes and fails on the same code. Usually a timing bug in the test.
|
||||
Flaky tests are worse than no tests; fix or delete them, never retry them away.
|
||||
- **AAA**: Arrange, Act, Assert. The shape every test in this repo should have.
|
||||
|
||||
The rule of thumb we follow: **many unit tests, a solid layer of API integration tests,
|
||||
a handful of E2E tests, almost no component tests.** Cost per test rises and stability
|
||||
falls as you go up that list.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stack decisions
|
||||
|
||||
| Layer | Tool | Why |
|
||||
| ---------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Unit + API integration | **Vitest 4** | Native ESM/TS, resolves the `@/*` alias via `vite-tsconfig-paths`, first-class module mocking (`vi.mock`) which we need for `auth()`, and multi-project config so node and jsdom suites live in one runner. |
|
||||
| Component + hooks | **@testing-library/react 16** + **jsdom 29** | Standard for React 19. Gives us `renderHook`, which is what we actually want for the big hooks in `components/video-page/hooks/`. |
|
||||
| E2E | **Playwright 1.62** | Real Chromium/Firefox/WebKit, auto-waiting (kills most flakiness), trace viewer for debugging CI failures, official container image so it runs under podman. |
|
||||
| Coverage | **@vitest/coverage-v8** | Built in, no extra config. |
|
||||
|
||||
### Rejected alternatives, and why
|
||||
|
||||
- **`bun test`**: fast and already in the toolchain, but its jsdom/React story and Next.js
|
||||
module-mocking story are still thinner than Vitest's. We run Vitest _with_ bun
|
||||
(`bun run vitest`), so we keep bun as the only package manager and task runner.
|
||||
- **Jest**: needs `next/jest`, babel config and ESM workarounds. Strictly more setup for
|
||||
strictly less speed.
|
||||
- **Mocked Prisma (`vitest-mock-extended` / `prismock`)**: rejected for API tests. The
|
||||
bugs this repo actually produces are wrong `where` filters and missing `OR` branches
|
||||
(see the `buildBillingAccessWhereInput` usage in `app/api/projects/route.ts`). A mocked
|
||||
client asserts that we called Prisma, not that the query is correct, and the mock setup
|
||||
is more code than the test. A real Postgres in a container costs seconds.
|
||||
- **MSW**: not needed yet. External calls (Stripe, Bunny, R2) are reached through thin
|
||||
wrappers in `lib/`, so `vi.mock('@/lib/stripe')` is simpler than intercepting HTTP.
|
||||
Revisit only if we start testing client-side fetch flows in jsdom.
|
||||
- **Cypress**: Playwright is faster, has better parallelism and better container support.
|
||||
- **Snapshot tests**: deliberately out of scope. They fail on every intentional markup
|
||||
change and assert nothing about behaviour.
|
||||
|
||||
### One structural constraint to be aware of
|
||||
|
||||
There are no `use server` actions in this repo; all mutations go through
|
||||
`app/api/**/route.ts`. That is good news: route handlers are plain exported functions, so
|
||||
they can be imported and called directly in a test without a running server.
|
||||
|
||||
Conversely, **async Server Components cannot be unit tested** with Testing Library. Every
|
||||
`page.tsx` in `app/(dashboard)` is therefore covered by E2E, not by component tests. This
|
||||
is the single biggest reason the component layer stays thin.
|
||||
|
||||
---
|
||||
|
||||
## 2. Directory layout
|
||||
|
||||
```
|
||||
tests/
|
||||
unit/ # node env, no DB, no mocks
|
||||
lib/
|
||||
billing.test.ts
|
||||
project-access.test.ts
|
||||
validation.test.ts
|
||||
...
|
||||
api/ # node env, real test Postgres, auth() mocked
|
||||
auth-matrix.test.ts # data-driven: no route returns 2xx unauthenticated
|
||||
projects.test.ts
|
||||
comments.test.ts
|
||||
...
|
||||
component/ # jsdom env
|
||||
hooks/
|
||||
use-watch-progress.test.ts
|
||||
...
|
||||
comment-rich-text.test.tsx
|
||||
e2e/ # Playwright
|
||||
auth.spec.ts
|
||||
project-lifecycle.spec.ts
|
||||
...
|
||||
factories/ # test-DB row builders
|
||||
index.ts
|
||||
user.ts
|
||||
project.ts
|
||||
video.ts
|
||||
helpers/
|
||||
db.ts # truncate + connect
|
||||
request.ts # NextRequest builders, route invocation
|
||||
session.ts # session mock control
|
||||
setup/
|
||||
api.ts # per-file setup for the api project
|
||||
component.ts # jsdom polyfills + jest-dom matchers
|
||||
db-global.ts # global setup: migrate the test DB once
|
||||
fixtures/
|
||||
sample.mp4 # tiny (<100KB) media for upload paths
|
||||
sample.png
|
||||
```
|
||||
|
||||
Rationale for a top-level `tests/` tree rather than colocated `*.test.ts`: it keeps `app/`
|
||||
free of non-route files, makes the Docker build ignore rules trivial, and lets each layer
|
||||
have its own environment without per-file pragmas.
|
||||
|
||||
**Import style:** no globals. Every test file does
|
||||
`import { describe, it, expect, vi } from 'vitest';`. This keeps `tsconfig.json`
|
||||
untouched and keeps `bun run typecheck` covering the test files, so a broken test is a
|
||||
failed `bun run check`.
|
||||
|
||||
**Naming:** `describe('functionName')` / `it('returns X when Y')`. No "should".
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 0: Foundation
|
||||
|
||||
Goal: `bun run test` runs and reports "no tests found" instead of erroring. Nothing is
|
||||
tested yet; the wiring is done.
|
||||
|
||||
- [x] Add dev dependencies:
|
||||
|
||||
```
|
||||
bun add -d vitest@^4.1.10 @vitest/coverage-v8@^4.1.10 \
|
||||
@vitejs/plugin-react@^6.0.4 vite-tsconfig-paths@^6.1.1 \
|
||||
jsdom@^29.1.1 @testing-library/react@^16.3.2 \
|
||||
@testing-library/jest-dom@^7.0.0 @testing-library/user-event@^14.6.1
|
||||
```
|
||||
|
||||
(Playwright is added in Phase 3 so the browser download does not slow Phase 0.)
|
||||
|
||||
- [x] `vitest.config.ts` at the repo root, using Vitest 4 `projects`:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: 'unit',
|
||||
environment: 'node',
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: 'api',
|
||||
environment: 'node',
|
||||
include: ['tests/api/**/*.test.ts'],
|
||||
setupFiles: ['tests/setup/api.ts'],
|
||||
globalSetup: ['tests/setup/db-global.ts'],
|
||||
// One shared test database; parallel files would fight over TRUNCATE.
|
||||
fileParallelism: false,
|
||||
testTimeout: 20_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
plugins: [tsconfigPaths(), react()],
|
||||
test: {
|
||||
name: 'component',
|
||||
environment: 'jsdom',
|
||||
include: ['tests/component/**/*.test.{ts,tsx}'],
|
||||
setupFiles: ['tests/setup/component.ts'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [x] `package.json` scripts:
|
||||
|
||||
```json
|
||||
"test": "vitest run --project unit --project component",
|
||||
"test:watch": "vitest --project unit --project component",
|
||||
"test:api": "vitest run --project api",
|
||||
"test:e2e": "playwright test",
|
||||
"test:all": "bun run test && bun run test:api && bun run test:e2e",
|
||||
"test:coverage": "vitest run --project unit --coverage",
|
||||
"verify": "bun run check && bun run test",
|
||||
"test:db:up": "podman compose -f docker-compose.test.yml up -d --wait postgres-test",
|
||||
"test:db:down": "podman compose -f docker-compose.test.yml down -v"
|
||||
```
|
||||
|
||||
`test` intentionally excludes the `api` project so the default command needs no
|
||||
infrastructure and stays instant. `test:all` is the full sweep.
|
||||
|
||||
- [x] `.prettierignore`: add `coverage/`, `playwright-report/`, `test-results/`.
|
||||
- [x] `.gitignore`: add `/playwright-report`, `/test-results`, `/.playwright`, and the
|
||||
exception `!.env.test.example` (the existing `.env*` rule would otherwise hide it).
|
||||
- [x] `eslint.config.mjs`: append an override for `tests/**` relaxing
|
||||
`@typescript-eslint/no-explicit-any` and any `no-restricted-imports` that fight
|
||||
test helpers. Keep `--max-warnings=0` intact.
|
||||
- [x] `.dockerignore`: add `tests/`, `vitest.config.ts`, `playwright.config.ts` so the
|
||||
production image does not grow.
|
||||
- [x] `AGENTS.md`: add "run `bun run verify` before finishing" alongside the existing
|
||||
`bun run check` rule, and a line pointing at this file.
|
||||
|
||||
**Definition of done:** `bun run test` exits 0.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 1: Pure unit tests
|
||||
|
||||
Highest value per hour of work in the whole plan. No DB, no mocks, no async. This is also
|
||||
where the authorization logic lives, which is where the repo's real bugs have been.
|
||||
|
||||
Target: **~200 tests across 20 files**, total runtime under 2 seconds.
|
||||
|
||||
Priority order, most valuable first:
|
||||
|
||||
- [x] **`tests/unit/lib/project-access.test.ts`**: `computeProjectAccess()` from
|
||||
`lib/auth.ts`. This is the heart of every permission decision in the product. Build
|
||||
an explicit matrix: anonymous / non-member / project member / project ADMIN /
|
||||
workspace member / workspace ADMIN / workspace OWNER / project owner, crossed with
|
||||
`visibility` PRIVATE|PUBLIC and workspace-owner billing active|expired. Assert all
|
||||
of `hasAccess`, `canEdit`, `canDelete`, `isWorkspaceAdmin`, `ownerBillingActive`.
|
||||
Recent history (`fix/public-project-hides-workspace-admin-actions`) says bugs land
|
||||
exactly here. ~24 tests.
|
||||
- [x] **`tests/unit/lib/billing.test.ts`**: `hasActiveTrial`, `hasActiveSubscription`,
|
||||
`hasRecoverableSubscription`, `hasBillingAccess`, `getBillingAccessEndDate`,
|
||||
`getStorageCleanupEligibleAt`, `getDefaultTrialEndsAt`, `mapStripeSubscriptionStatus`
|
||||
(every Stripe status string), `selectAuthoritativeSubscription`,
|
||||
`getBillingStatusLabel`. All take an injectable `now`, so no fake timers needed.
|
||||
Also snapshot-free shape assertions on `buildBillingAccessWhereInput` and
|
||||
`buildExpiredBillingWhereInput`. ~32 tests.
|
||||
- [x] **`tests/unit/lib/validation.test.ts`**: `validateAnnotationStrokes` (limits: 500
|
||||
strokes, 2000 points, colour regex, stroke width bounds, prototype-pollution
|
||||
payloads, `__proto__` keys, NaN/Infinity coords), `isValidHttpUrl`
|
||||
(`javascript:`, `data:`, `file:`), `isSafeAppRelativePath` (traversal, wrong UUID
|
||||
shape), `validateOptionalUrlOrAppPath`. Security boundaries that are impossible to
|
||||
test by hand. ~24 tests.
|
||||
- [x] **`tests/unit/lib/feature-flags.test.ts`**: env-driven, so use
|
||||
`vi.stubEnv`. Cover `readBooleanEnv` defaults and garbage values, the S3-over-Bunny
|
||||
precedence in `isBunnyUploadsEnabled`, `isDirectFileUploadEnabled`,
|
||||
`getMaxVideoUploadBytes` fallback on invalid/negative input, and the
|
||||
`getR2MultipartPartSizeBytes` 5 MiB clamp. ~20 tests.
|
||||
- [x] **`tests/unit/lib/rate-limit.test.ts`**: `getClientIp` under each
|
||||
`TRUSTED_PROXY_MODE`, spoofed `x-forwarded-for` chains, the `IP_PATTERN` reject
|
||||
path, plus `rateLimitHeaders` and a sanity check that every entry in
|
||||
`RATE_LIMIT_CONFIGS` has positive window and max. ~14 tests.
|
||||
- [x] **`tests/unit/lib/api-response.test.ts`**: each `apiErrors.*` helper returns the
|
||||
right status and `code`, `successResponse` serialises `meta` and BigInt values,
|
||||
`withCacheControl` sets the header. Guards the contract every route depends on.
|
||||
~14 tests.
|
||||
- [x] **`tests/unit/lib/upload-validation.test.ts`**: `lib/video-upload-validation.ts`
|
||||
and `lib/image-upload-validation.ts`: extension/MIME allowlists, size limits,
|
||||
filename sanitisation. ~16 tests.
|
||||
- [x] **`tests/unit/lib/share-links.test.ts`**: token generation shape, expiry logic,
|
||||
permission comparison. ~10 tests.
|
||||
- [x] **`tests/unit/lib/guest-identity.test.ts`**: cookie parse/serialise, name
|
||||
sanitisation, invalid payloads. ~8 tests.
|
||||
- [x] **`tests/unit/lib/content-security-policy.test.ts`**: `buildContentSecurityPolicy`
|
||||
includes runtime storage endpoints when set, omits them when not, and never emits
|
||||
`unsafe-eval` in production mode. ~8 tests.
|
||||
- [x] **`tests/unit/lib/video-providers.test.ts`**: provider resolution in
|
||||
`lib/video-providers/index.ts`, YouTube ID extraction from every URL form
|
||||
(`watch?v=`, `youtu.be`, `shorts/`, with extra params, invalid), `metadata-cache`
|
||||
hit/miss/expiry. ~14 tests.
|
||||
- [x] **`tests/unit/lib/comment-export.test.ts`**: timecode formatting, CSV/text escaping
|
||||
of quotes and newlines, ordering. ~10 tests.
|
||||
- [x] **`tests/unit/lib/approval-workflow.test.ts`**: status transition rules. ~8 tests.
|
||||
- [x] **`tests/unit/lib/async-pool.test.ts`**: concurrency bound is respected, results
|
||||
keep input order, one rejection does not lose the others. ~6 tests.
|
||||
- [x] **`tests/unit/lib/json-serialize.test.ts`**: `bigIntReplacer` on nested structures,
|
||||
`0n`, negative values. ~5 tests.
|
||||
- [x] **`tests/unit/lib/email-validation.test.ts`**: ~6 tests.
|
||||
- [x] **`tests/unit/lib/cleanup-warnings.test.ts`**: warning threshold boundaries. ~6 tests.
|
||||
- [x] **`tests/unit/lib/email-brand.test.ts`**: HTML escaping in email templates. ~5 tests.
|
||||
- [x] **`tests/unit/lib/seo.test.ts`** + `lib/marketing/metadata.ts`: canonical URLs,
|
||||
title/description length bounds. ~6 tests.
|
||||
- [x] **`tests/unit/lib/comment-tags.test.ts`**: `DEFAULT_COMMENT_TAGS` invariants
|
||||
(unique slugs, valid colours). ~4 tests.
|
||||
|
||||
**Definition of done:** `bun run test` runs ~200 assertions in under 2 seconds, and
|
||||
`bun run test:coverage` reports >85% line coverage on the files listed above.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 2: API integration tests
|
||||
|
||||
Goal: for each covered route, prove that an unauthorised caller cannot reach it, that
|
||||
malformed input is rejected with 400, and that the happy path writes the right rows.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- [x] `docker-compose.test.yml`: Postgres only (no MinIO for now; storage is mocked at
|
||||
the `lib/r2.ts` boundary), on port `55432` so it cannot collide with the dev stack,
|
||||
with `tmpfs` for the data directory to keep it fast and disposable:
|
||||
```yaml
|
||||
services:
|
||||
postgres-test:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: openframe
|
||||
POSTGRES_PASSWORD: openframe
|
||||
POSTGRES_DB: openframe_test
|
||||
command: ['postgres', '-c', 'fsync=off', '-c', 'full_page_writes=off']
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U openframe -d openframe_test']
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
ports:
|
||||
- '127.0.0.1:55432:5432'
|
||||
```
|
||||
- [x] `.env.test.example` committed, `.env.test` gitignored. Minimum set:
|
||||
`DATABASE_URL` (pointing at 55432), `NEXTAUTH_URL`, `NEXTAUTH_SECRET`,
|
||||
`NEXT_PUBLIC_APP_URL`, `OPENFRAME_ENABLE_STRIPE=false`,
|
||||
`OPENFRAME_REQUIRE_INVITE_CODE=true`, `INVITE_CODE=test-invite`,
|
||||
`TRUSTED_PROXY_MODE=none`, `NODE_ENV=test`.
|
||||
- [x] `tests/setup/db-global.ts`: global setup, runs once. Loads `.env.test`, waits for
|
||||
Postgres, runs `prisma migrate deploy` against the test DB. Migrations (not
|
||||
`db push`) because `prisma/migrations/*/migration.sql` contains hand-written SQL
|
||||
such as `cleanup_rate_limits()` that the routes depend on.
|
||||
- [x] `tests/setup/api.ts`: per-file setup. Loads `.env.test` **before** any `@/lib/db`
|
||||
import, registers `afterEach(resetDb)`, and installs the `auth()` mock.
|
||||
- [x] `tests/helpers/db.ts`: `resetDb()` truncates every table except
|
||||
`_prisma_migrations`, discovered dynamically from `information_schema.tables` so it
|
||||
never drifts from the schema:
|
||||
`TRUNCATE TABLE <list> RESTART IDENTITY CASCADE`.
|
||||
- [x] `tests/helpers/session.ts`: controls the mock:
|
||||
```ts
|
||||
vi.mock('@/lib/auth', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth')>();
|
||||
return { ...actual, auth: vi.fn() };
|
||||
});
|
||||
```
|
||||
plus `signedInAs(user)` / `signedOut()` wrappers. Partial mock, so the real
|
||||
`checkProjectAccess` / `checkWorkspaceAccess` still run against the real DB. That
|
||||
is the whole point: the authorization code under test is not the code being faked.
|
||||
- [x] `tests/helpers/request.ts`: `apiRequest(url, { method, body, headers, cookies })`
|
||||
returning a `NextRequest`, and `callRoute(handler, request, params)` that wraps
|
||||
params in a resolved promise, matching the `params: Promise<...>` convention from
|
||||
`AGENTS.md`.
|
||||
- [x] `tests/factories/`: `createUser({ trialEndsAt, subscriptionStatus })`,
|
||||
`createWorkspace({ ownerId })`, `addWorkspaceMember`, `createProject({ visibility })`,
|
||||
`addProjectMember({ role })`, `createVideo`, `createVersion`, `createComment`,
|
||||
`createShareLink`, `createApprovalRequest`. Unique values from a module-level
|
||||
counter, no faker dependency.
|
||||
- [x] Module mocks for external services, in `tests/setup/api.ts`:
|
||||
`@/lib/r2` (presign returns a fake URL), `@/lib/stripe`,
|
||||
`@/lib/bunny-upload-token`, and `nodemailer` (assert on captured mail instead of
|
||||
sending).
|
||||
|
||||
### The cheap win: auth matrix
|
||||
|
||||
- [x] `tests/api/auth-matrix.test.ts`: a table of all 60 route modules with their
|
||||
exported methods and a sample params object. For each, assert that an
|
||||
unauthenticated call returns 401 or 403, never 2xx. One file, one afternoon,
|
||||
coverage across every route in the app. Routes that are legitimately public
|
||||
(`/api/watch/[videoId]` with a share token, `/api/stripe/webhook`,
|
||||
`/api/auth/*`) go in an explicit allowlist inside the file, so making a route public
|
||||
becomes a visible diff.
|
||||
|
||||
### Deep coverage, in priority order
|
||||
|
||||
Each of these gets unauthorised / forbidden / invalid-input / happy-path cases:
|
||||
|
||||
- [x] `tests/api/projects.test.ts`: `app/api/projects/route.ts` GET pagination guards
|
||||
(page 0, page 1001, limit 101, offset > 10000), the billing filter (projects of an
|
||||
expired-trial workspace owner are invisible), POST validation and default comment
|
||||
tags; `[projectId]` GET/PATCH/DELETE against the `canEdit` / `canDelete` matrix.
|
||||
- [x] `tests/api/project-members.test.ts` covering `members/route.ts` and
|
||||
`members/[memberId]`. A project ADMIN cannot promote itself past its scope, a VIEWER
|
||||
cannot invite, the owner cannot be removed.
|
||||
- [x] `tests/api/comments.test.ts` covering `versions/[versionId]/comments` POST.
|
||||
Annotation payload validation wired to `validateAnnotationStrokes`, guest identity
|
||||
path, timecode bounds. Plus `comments/[commentId]` DELETE/PATCH, where only the
|
||||
author or an admin may act.
|
||||
- [x] `tests/api/approvals.test.ts`: request creation, `decision` route rejecting a
|
||||
non-candidate approver, `cancel` restricted to the requester, terminal-status
|
||||
transitions rejected.
|
||||
- [x] `tests/api/share-links.test.ts`: creation permissions, password-protected links,
|
||||
expiry, `SharePermission` levels honoured on read.
|
||||
- [x] `tests/api/watch.test.ts` covering `watch/[videoId]` and `progress`. Share-session
|
||||
gate, private video without session, `upload-token` scoping.
|
||||
- [x] `tests/api/videos.test.ts`: `videos/route.ts`, `bulk-delete` (cross-project ids
|
||||
rejected), `move` (target project permission check), `r2-init` / `r2-complete`
|
||||
session lifecycle with `lib/r2.ts` mocked.
|
||||
- [x] `tests/api/stripe-webhook.test.ts`: invalid signature rejected, each handled event
|
||||
type maps to the right user state via `syncStripeSubscriptionToUser`, replayed
|
||||
events are idempotent. Stripe SDK mocked; event payloads as fixtures.
|
||||
- [x] `tests/api/register.test.ts`: invite code required/not required, duplicate email,
|
||||
password hashing (never stored in clear), email normalisation to lowercase,
|
||||
verification-token creation.
|
||||
- [x] `tests/api/workspaces.test.ts`: creation eligibility via
|
||||
`getWorkspaceCreationEligibility`, member add/remove roles.
|
||||
- [x] `tests/api/storage-quota.test.ts`: `reserveStorageQuota` /
|
||||
`releaseStorageReservation` concurrency: two parallel reservations cannot exceed
|
||||
`PLAN_STORAGE_LIMIT_BYTES`. This exercises the advisory-lock SQL, which is exactly
|
||||
the kind of thing that cannot be verified by clicking.
|
||||
- [x] `tests/api/rate-limit.test.ts`: the DB-backed `checkRateLimit` actually blocks
|
||||
after N requests and the window resets.
|
||||
|
||||
**Definition of done:** `bun run test:api` green against a fresh
|
||||
`bun run test:db:up`, total runtime under 90 seconds.
|
||||
|
||||
_Later optimisation, not now:_ give each Vitest worker its own Postgres schema
|
||||
(`?schema=test_w${VITEST_WORKER_ID}`) and re-enable `fileParallelism`. Only worth it if
|
||||
the suite passes ~2 minutes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 3: E2E tests
|
||||
|
||||
Goal: the UI is verified by a browser, not by hand. Keep this suite small and ruthlessly
|
||||
stable. Eight flows, not eighty.
|
||||
|
||||
- [x] `bun add -d @playwright/test@^1.62.0`
|
||||
- [x] `playwright.config.ts`: Chromium as the default project, one Mobile Chrome project
|
||||
for the dashboard smoke test, `retries: 2` on CI and `0` locally,
|
||||
`trace: 'on-first-retry'`, and a `webServer` running `bun run build && bun run start`
|
||||
with `.env.test` and `OPENFRAME_ENABLE_STRIPE=false`.
|
||||
- [x] `tests/e2e/fixtures.ts`: a seeded-user fixture using Playwright `storageState`, so
|
||||
only the auth spec pays the cost of logging in through the form.
|
||||
- [x] Add the app + MinIO to `docker-compose.test.yml` as a separate profile, since E2E
|
||||
needs real storage for the upload flow.
|
||||
|
||||
Flows, in priority order:
|
||||
|
||||
- [x] `auth.spec.ts`: register with invite code, wrong invite code rejected, login,
|
||||
wrong password, logout, protected route redirects to `/login`.
|
||||
- [x] `onboarding.spec.ts`: a fresh user completes onboarding and lands with a workspace.
|
||||
- [x] `project-lifecycle.spec.ts`: create, rename, change visibility, delete a project;
|
||||
the list reflects each change.
|
||||
- [x] `video-upload.spec.ts`: upload `tests/fixtures/sample.mp4` through the drag-drop
|
||||
uploader, wait for the version to appear, add a second version.
|
||||
- [x] `comments.spec.ts`: leave a timestamped comment, verify the timecode links back to
|
||||
the right frame, draw an annotation and confirm it persists after reload, reply and
|
||||
resolve.
|
||||
- [x] `approvals.spec.ts`: request approval, approve as a second user in a second
|
||||
browser context, verify both users' views.
|
||||
- [x] `share-link.spec.ts`: create a share link, open it in a fresh unauthenticated
|
||||
context, verify the guest name gate and the permission level, verify an expired link
|
||||
is refused.
|
||||
- [x] `billing-gate.spec.ts`: a seeded expired-trial user is pushed to `/settings` and
|
||||
cannot open a project.
|
||||
- [x] `dashboard-mobile.spec.ts`: mobile viewport smoke test. Navigation opens, the project
|
||||
list renders, no horizontal scroll.
|
||||
|
||||
**Rules for this suite** (these are what keep E2E from becoming the thing everyone
|
||||
disables): locate by role and accessible name or `data-testid`, never by CSS class; never
|
||||
`waitForTimeout`; every spec creates its own data and cleans up after itself; nothing
|
||||
depends on execution order.
|
||||
|
||||
**Definition of done:** `bun run test:e2e` green twice in a row locally and on CI.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 4: Component and hook tests
|
||||
|
||||
Deliberately last and deliberately narrow. Most components here are presentational
|
||||
wrappers over Radix or are async Server Components (untestable in jsdom, already covered
|
||||
by E2E). The real logic sits in hooks.
|
||||
|
||||
- [x] `tests/setup/component.ts`: `@testing-library/jest-dom/vitest`, plus the jsdom
|
||||
polyfills Radix and the video player need: `matchMedia`,
|
||||
`Element.prototype.scrollIntoView`, `ResizeObserver`, `PointerEvent` methods,
|
||||
`HTMLMediaElement.prototype.play/pause`, `URL.createObjectURL`.
|
||||
|
||||
Worth testing (via `renderHook`):
|
||||
|
||||
- [x] `components/video-page/hooks/use-watch-progress.ts`: throttling, resume position,
|
||||
the boundary where progress counts as "watched".
|
||||
- [x] `components/video-page/hooks/use-version-duration-sync.ts`: small and pure enough
|
||||
to pin exactly.
|
||||
- [x] `components/video-page/hooks/use-comment-export.ts`: pairs with the
|
||||
`lib/comment-export.ts` unit tests.
|
||||
- [x] `components/video-page/hooks/use-comment-actions.ts`: 39k of logic. Test optimistic
|
||||
insert, rollback on failed request, reply threading, resolve toggling. Highest-value
|
||||
item in this phase.
|
||||
- [x] `components/video-page/hooks/use-video-player.ts`: 48k. Do **not** attempt full
|
||||
coverage in jsdom. Pull the pure parts (timecode parsing/formatting, frame stepping
|
||||
arithmetic, keyboard-shortcut mapping) into a sibling module and unit test those in
|
||||
Phase 1 style; leave playback behaviour to E2E.
|
||||
|
||||
Worth testing (via `render`):
|
||||
|
||||
- [x] `components/video-page/comment-rich-text.tsx`: URL linkification and
|
||||
`@[name](asset:id)` mention parsing, including the XSS-shaped inputs
|
||||
(`javascript:` hrefs must not render as links).
|
||||
- [x] `components/linkify.tsx`: same regex, different component.
|
||||
- [x] `components/error-boundary.tsx`: renders the fallback and does not swallow the
|
||||
error.
|
||||
- [x] `components/share-link-unlock.tsx` and `components/guest-gate.tsx`: small forms
|
||||
with real validation branches.
|
||||
|
||||
Explicitly **not** tested here: everything in `components/ui/` (upstream shadcn/Radix),
|
||||
`LandingPage.tsx`, `components/marketing/*`, `assets-pane.tsx`, `comments-pane.tsx`,
|
||||
`video-page-content.tsx`. Those are covered by E2E where they are covered at all.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 5: One-click and CI
|
||||
|
||||
- [x] `.husky/pre-push` (new hook):
|
||||
```sh
|
||||
bun run verify
|
||||
```
|
||||
`pre-commit` stays as-is (`lint-staged`) so committing stays fast. Push is the right
|
||||
gate: it is where work leaves the machine.
|
||||
- [x] Rewrite `.github/workflows/ci.yml` into three jobs:
|
||||
```yaml
|
||||
jobs:
|
||||
check: # existing: lint + format + typecheck
|
||||
test: # unit + component + api, with a postgres:16-alpine service
|
||||
e2e: # playwright, needs: [check], uploads the report on failure
|
||||
```
|
||||
`test` runs `bun run test && bun run test:api` with `DATABASE_URL` pointing at the
|
||||
service container and `prisma migrate deploy` first. `e2e` uses
|
||||
`mcr.microsoft.com/playwright:v1.62.0-noble` as the job container and uploads
|
||||
`playwright-report/` via `actions/upload-artifact` when it fails.
|
||||
- [x] Add a coverage summary comment or a `coverage-summary.json` artifact. No coverage
|
||||
_threshold_ gate initially: a hard gate on a suite this young turns into people
|
||||
writing tests for getters. Revisit once Phase 2 is complete.
|
||||
- [x] `README.md` and `CONTRIBUTING.md`: a "Running the tests" section pointing here.
|
||||
|
||||
### Local commands, all under podman
|
||||
|
||||
Per the project rule, no npm package touches the host filesystem.
|
||||
|
||||
```fish
|
||||
# Unit + component, the everyday loop
|
||||
podman run -it --rm -v "$PWD":/workspace:z -w /workspace docker.io/oven/bun:alpine \
|
||||
sh -c "bun install && bun run test"
|
||||
|
||||
# Watch mode while writing code
|
||||
podman run -it --rm -v "$PWD":/workspace:z -w /workspace docker.io/oven/bun:alpine \
|
||||
sh -c "bun install && bun run test:watch"
|
||||
|
||||
# API integration: start the test DB on the shared network first
|
||||
podman network create openframe-test # once
|
||||
podman compose -f docker-compose.test.yml up -d --wait postgres-test
|
||||
podman run -it --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \
|
||||
docker.io/oven/bun:alpine sh -c "bun install && bun run test:api"
|
||||
|
||||
# E2E: browsers preinstalled in the Playwright image
|
||||
podman run -it --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \
|
||||
mcr.microsoft.com/playwright:v1.62.0-noble sh -c "bun run test:e2e"
|
||||
```
|
||||
|
||||
- [x] Wrap these in `scripts/test.sh <unit|api|e2e|all>` so the everyday invocation is one
|
||||
short command instead of a memorised podman line.
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks and spikes
|
||||
|
||||
Each of these gets a 15-minute spike **before** the phase that depends on it. If a spike
|
||||
fails, the fallback is listed.
|
||||
|
||||
1. **`vi.mock` partial-mocking `@/lib/auth` (Phase 2).** Importing the real module
|
||||
initialises NextAuth v5 beta with `PrismaAdapter(db)` at module load. It should be
|
||||
inert without a request, but beta versions surprise.
|
||||
_Fallback:_ extract `computeProjectAccess`, `checkProjectAccess`,
|
||||
`checkWorkspaceAccess` and `projectAccessInclude` into `lib/access.ts` (a pure
|
||||
re-export from `lib/auth.ts` keeps every call site working). Then tests import
|
||||
`lib/access.ts` and mock `lib/auth.ts` wholesale. This is a better structure anyway.
|
||||
|
||||
2. **`lib/db.ts` env timing (Phase 2).** `db` is a module-level singleton that reads
|
||||
`process.env.DATABASE_URL` at import. Setup files must load `.env.test` before the
|
||||
first `@/lib/db` import in the module graph.
|
||||
_Fallback:_ pass env explicitly on the command line
|
||||
(`DATABASE_URL=... vitest run --project api`) instead of relying on a setup file.
|
||||
|
||||
3. **`process.on('SIGINT'|'SIGTERM')` in `lib/db.ts` (Phase 2).** Every test file that
|
||||
imports `db` adds listeners. With many files this trips Node's
|
||||
`MaxListenersExceededWarning` and, with `--max-warnings` style strictness, noise.
|
||||
_Fallback:_ guard the registration with `if (process.env.NODE_ENV !== 'test')`, or
|
||||
call `process.setMaxListeners(0)` in the api setup file.
|
||||
|
||||
4. **Radix + React 19 under jsdom 29 (Phase 4).** Radix uses pointer-capture APIs jsdom
|
||||
does not implement.
|
||||
_Fallback:_ the polyfill list in `tests/setup/component.ts`; and if a component still
|
||||
resists, it moves to E2E instead. No fighting jsdom for hours.
|
||||
|
||||
5. **Playwright `webServer` build time (Phase 3).** `next build` on a 56k-line app is not
|
||||
fast, so a naive config rebuilds on every local run.
|
||||
_Fallback:_ `reuseExistingServer: !process.env.CI` and a cached `.next` between runs;
|
||||
note that per this repo's worktree recipe, a cold `.next` in a worktree causes Prisma
|
||||
500s, so the E2E setup must seed `.next` or run in the main checkout.
|
||||
|
||||
6. **BigInt in assertions (Phases 1-2).** Storage sizes are `bigint`. `expect(x).toBe(1)`
|
||||
fails against `1n`. Establish the convention early: always compare
|
||||
`BigInt(...)` to `BigInt(...)`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Effort and sequencing
|
||||
|
||||
| Phase | Scope | Rough effort | Value |
|
||||
| ----------------- | ------------------------------------ | -------------- | ------------- |
|
||||
| 0 Foundation | config, scripts, lint/ignore wiring | half a session | enabling |
|
||||
| 1 Unit | ~200 tests, 20 files | 1-2 sessions | **very high** |
|
||||
| 2 API | infra + auth matrix + 12 deep suites | 3-4 sessions | **very high** |
|
||||
| 3 E2E | 9 specs + compose profile | 2-3 sessions | high |
|
||||
| 4 Component/hooks | ~10 targets | 1-2 sessions | medium |
|
||||
| 5 CI + hooks | 3 jobs, pre-push, docs | half a session | high |
|
||||
|
||||
Recommended order of delivery: **0 → 1 → 5 (partial: pre-push + `test` job) → 2 → 3 → 4**.
|
||||
Wiring CI right after Phase 1 means the tests start protecting `master` while they are
|
||||
still cheap, instead of waiting for the whole pyramid.
|
||||
|
||||
---
|
||||
|
||||
## 11. Non-goals
|
||||
|
||||
Written down so they do not get relitigated:
|
||||
|
||||
- No snapshot tests.
|
||||
- No tests for `components/ui/*` (upstream shadcn/Radix).
|
||||
- No mocked Prisma client.
|
||||
- No 100% coverage target. Coverage is a diagnostic, not a goal.
|
||||
- No test for a getter, a re-export, or a constant.
|
||||
- No visual regression testing (Percy/Chromatic) at this stage.
|
||||
- No load or performance testing at this stage.
|
||||
|
||||
---
|
||||
|
||||
## 12. Where the plan was wrong
|
||||
|
||||
Corrections found while implementing it. Recorded so the sections above are read with them
|
||||
in mind, and so nobody "fixes" a deliberate deviation back.
|
||||
|
||||
**Infrastructure**
|
||||
|
||||
- `prisma migrate deploy` **cannot build this database**, which invalidates Section 5's
|
||||
instruction. `prisma/migrations` is a stack of patches on top of a baseline that was
|
||||
never captured, so the second migration runs `ALTER TYPE "VideoAssetKind" ADD VALUE`
|
||||
against a type nothing in the history creates, and dies with P3018 / 42704 on an empty
|
||||
database. The test schema therefore comes from `prisma db push` plus a replay of the
|
||||
hand-written SQL that `schema.prisma` cannot express (`cleanup_rate_limits()`, the
|
||||
`UNLOGGED` rate-limit table, three partial unique indexes on `video_versions`). This is
|
||||
the same approach `scripts/docker-db-bootstrap.ts` already takes in production.
|
||||
`tests/setup/db-global.ts` documents it in full and carries a drift guard that fails the
|
||||
run when a migration is added without review.
|
||||
- **Coverage does not work under bun.** `@vitest/coverage-v8` needs the V8 inspector API,
|
||||
which bun does not implement, so `bun run test:coverage` reports zeros. The suites pass
|
||||
under both runtimes, so CI runs the coverage step under node instead. Getting the unit
|
||||
project to run under node needed `server.deps.inline: [/next-auth/]`, because
|
||||
`next-auth/lib/env.js` imports the extensionless `next/server`, which node's ESM
|
||||
resolver cannot resolve and bun can.
|
||||
- **`@playwright/test` is pinned to 1.61.1, not the newest release.** The container image
|
||||
is what fixes the ceiling: `mcr.microsoft.com/playwright:v1.62.0-noble` is not published,
|
||||
and Playwright refuses a browser build that does not match the package. Bump the package
|
||||
and the image tag together, and check the tag exists first.
|
||||
- **The Playwright image ships no bun**, and `oven-sh/setup-bun` cannot run inside it
|
||||
either, because the image has no `unzip`. Both the CI job and `scripts/test.sh` install
|
||||
bun with `npm install --global bun` first.
|
||||
- **eslint keeps its own ignore list**, so a coverage run used to break `bun run lint` on
|
||||
the reporter's vendored JS. `coverage/**`, `playwright-report/**` and `test-results/**`
|
||||
are now in `globalIgnores`.
|
||||
- **`tsconfig.json` targets ES2017, so `1n` is a compile error** (TS2737) even though the
|
||||
BigInt type resolves. Always `BigInt(1)`. Section 9 framed this as a runtime assertion
|
||||
mismatch and understated it.
|
||||
- **Testing Library's auto-cleanup never installed.** It only registers its own
|
||||
`afterEach(cleanup)` when a global `afterEach` is visible, and Section 2 mandates
|
||||
explicit imports. Components stayed mounted for the rest of each file, with their
|
||||
intervals and listeners live, which produced real cross-test contamination.
|
||||
`tests/setup/component.ts` now calls `cleanup()` itself.
|
||||
- **Risk #1 did not materialize.** `@/lib/auth` imports cleanly in a node test environment,
|
||||
so the `lib/access.ts` extraction was not needed and was not done. Risk #3 was real but
|
||||
`process.setMaxListeners(0)` in the api setup was enough, so `lib/db.ts` stays untouched.
|
||||
|
||||
**Test environment**
|
||||
|
||||
- **`OPENFRAME_ENABLE_STRIPE` must be `true`** in the test environment, not `false` as
|
||||
Sections 5 and 6 suggested. With the flag off, `hasBillingAccess()` short-circuits to
|
||||
`true` and `buildBillingAccessWhereInput()` returns `{}`, which disarms the entire
|
||||
billing gate and makes every access-control assertion meaningless. Dummy Stripe keys are
|
||||
used and no test walks into checkout.
|
||||
- `DISABLE_RATE_LIMIT=true` for the api suite, because every request in it shares one
|
||||
client IP and one file exhausting a window would make the next file's 429 look like a
|
||||
passing authorization check. `rate-limit.test.ts` re-enables it per test.
|
||||
- `vi.stubEnv` does **not** auto-restore. `tests/setup/api.ts` calls `vi.unstubAllEnvs()`
|
||||
in `afterEach` centrally, after four tests were caught passing for the wrong reason.
|
||||
- The E2E suite deliberately does not truncate between tests: several Playwright workers
|
||||
drive one app against one database, so each test seeds uniquely tagged rows and deletes
|
||||
its own users, letting the schema cascade do the rest.
|
||||
|
||||
**Assignments in the plan that did not match the code**
|
||||
|
||||
- `lib/share-links.ts` generates no tokens; it exports `validateShareLinkAccess`.
|
||||
- `lib/approval-workflow.ts` has no status transition rules; it exports
|
||||
`getApprovalCandidatesForProject`.
|
||||
- `runWithConcurrency` returns `Promise<void>`, so "results keep input order" is not a
|
||||
property it can have.
|
||||
- `lib/rate-limit.ts`'s real off-by-one lives in `checkRateLimit`, which Section 4 omitted.
|
||||
- `use-video-player.ts` contains no timecode parser or formatter. `formatTime` is injected
|
||||
as a parameter and is duplicated in four components; hoisting it into `lib/` is a
|
||||
separate change. The extraction covered frame and playhead arithmetic instead.
|
||||
- Section 5 lists `/api/watch/[videoId]` as public. It is not: it 403s anonymously on a
|
||||
private project and needs a share-session cookie.
|
||||
- Section 8's `.dockerignore` item does not achieve its stated goal. Image size comes from
|
||||
a non-production `bun install` whose `node_modules` is copied wholesale into the runner
|
||||
stage, not from source files.
|
||||
- A coverage PR comment needs `pull-requests: write`, which conflicts with keeping
|
||||
`permissions: contents: read`, so CI uploads an artifact instead.
|
||||
|
||||
---
|
||||
|
||||
## 13. Closing the gaps
|
||||
|
||||
The first round left an inventory of what it had not covered. This section records what
|
||||
the second round did about it, so the inventory is not read as still-current.
|
||||
|
||||
Where it ended up:
|
||||
|
||||
| Suite | Before | After |
|
||||
| ---------------- | ------ | ----- |
|
||||
| Unit | 1191 | 1702 |
|
||||
| Component + hook | 167 | 377 |
|
||||
| API integration | 647 | 1015 |
|
||||
| End to end | 18 | 29 |
|
||||
|
||||
**The page-level authorization layer.** `lib/route-access.ts` had zero coverage, which
|
||||
meant the API routes were guarded by tests and the pages were not. It now has 47, with
|
||||
`next/navigation` mocked so that `redirect()` and `notFound()` throw the way they really
|
||||
do. Every redirect target was verified against a second source rather than read off the
|
||||
function under test, and one of the three the plan assumed turned out to be wrong: the
|
||||
project paths have no billing branch at all and reach `/settings` in two hops through
|
||||
`/dashboard`.
|
||||
|
||||
**The media proxies.** Five routes served user media with only anonymous coverage, and the
|
||||
reason they had stayed that way was the positive control: no 2xx is reachable without R2
|
||||
configured, and a 403 with nothing green beside it can pass for the wrong reason. The way
|
||||
in was to stub `r2Client.send()` and leave `lib/r2-media-proxy.ts` itself real, so the
|
||||
object keys, content types and range handling are production code paths. Every one of the
|
||||
five now has a genuine 2xx in the same file as its 403.
|
||||
|
||||
**Tests that could not fail.** The auth matrix used to assert only "not 2xx" for an
|
||||
anonymous caller. Two entries satisfied that without their guard existing at all, because
|
||||
the route refused a malformed body one line further down, and Section 12's predecessor
|
||||
recorded them as unfixable. They were fixable: requiring an authorization status (401, 403
|
||||
or 404) rather than merely a non-2xx one makes both load-bearing, and all 60 routes pass
|
||||
the stricter form, so `NON_AUTHORIZATION_REFUSALS` is empty and exists only as a drift
|
||||
guard.
|
||||
|
||||
**A stub with the wrong shape is worse than no stub.** `tests/setup/api.ts` declared
|
||||
`readVideoObjectBytes` as returning an object wrapping a `Uint8Array` when it really
|
||||
returns the array. The object was truthy but had no `.length`, so `hasKnownVideoMagicBytes()`
|
||||
saw zero bytes and every route reaching `finalizeR2VideoUpload` took the "not a valid
|
||||
video" branch, cancelled the session and deleted both objects. Nothing failed. No test
|
||||
drove that path to success until this round, and the whole api suite had been green over
|
||||
it for weeks.
|
||||
|
||||
**Parallel suites need parallel databases.** Eight agents wrote suites at once, and the
|
||||
api project empties every table between tests, so they cannot share one database. Each run
|
||||
got its own, created by hand in the same container and named `openframe_test_<suffix>`.
|
||||
`tests/api/infrastructure.test.ts` now accepts that shape instead of the exact name; the
|
||||
guard that matters, that the dev database is called `openframe` and does not match, is
|
||||
untouched.
|
||||
|
||||
**Mutation testing.** `bun run test:mutation` runs StrykerJS over the authorization and
|
||||
input-validation modules listed in `stryker.config.json`, against
|
||||
`vitest.mutation.config.ts`, which is the `unit` project alone. Not a merge gate, for the
|
||||
same reason there is no coverage threshold, and CI runs it weekly and on demand rather
|
||||
than on a push, because a full run is minutes. The module list is explicit rather than a
|
||||
`lib/**` glob: a file whose only coverage is an API integration test would report every
|
||||
mutant as survived and bury the real findings.
|
||||
|
||||
**Safari.** `playwright.config.ts` gains a `webkit-player` project behind `E2E_WEBKIT=1`,
|
||||
scoped to `player.spec.ts`. Playback is where a video review tool's Safari risk actually
|
||||
lives; running all fourteen specs under WebKit would mostly re-test React.
|
||||
|
||||
**Reviewed by somebody else.** Every suite in this round was read by a separate agent whose
|
||||
only question was whether the tests deliver what they claim. That is now a standing rule in
|
||||
`AGENTS.md` rather than a one-off.
|
||||
|
||||
What was deliberately left, and why:
|
||||
|
||||
- **OAuth sign-in, Stripe checkout, and email verification end to end.** All three leave
|
||||
the app or need a provider stub. The gate each one guards is covered at the API layer.
|
||||
- **Version comparison end to end.** Two real uploads per test against 51 KB of its own
|
||||
client logic. Three solid specs beat five thin ones.
|
||||
- **`components/ui/*`, the marketing pages, and the three large panes.** Unchanged from
|
||||
Section 11. The panes' real logic is reachable by extraction, which is what
|
||||
`video-player-utils.ts` and `upload-chunking.ts` demonstrate.
|
||||
- **A coverage threshold gate.** Still a non-goal. Mutation testing answers the question a
|
||||
threshold was a proxy for.
|
||||
@@ -9,17 +9,25 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import {
|
||||
getSafeCallbackUrl,
|
||||
isInvitationCallbackUrl,
|
||||
isSafeRelativePath,
|
||||
} from '@/lib/safe-redirect';
|
||||
|
||||
function getSafeCallbackUrl(value: string | null): string {
|
||||
if (!value) return '/dashboard';
|
||||
try {
|
||||
const baseOrigin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin;
|
||||
const parsed = new URL(value, baseOrigin);
|
||||
if (parsed.origin !== baseOrigin) return '/dashboard';
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return '/dashboard';
|
||||
/**
|
||||
* Sign-up link that carries the pending destination — and, when that destination is an
|
||||
* invitation, the invitation token itself so the new account is bound to the invite.
|
||||
*/
|
||||
function buildRegisterHref(callbackUrl: string): string {
|
||||
if (callbackUrl === '/dashboard') return '/register';
|
||||
|
||||
const params = new URLSearchParams({ callbackUrl });
|
||||
if (isInvitationCallbackUrl(callbackUrl)) {
|
||||
const token = new URLSearchParams(callbackUrl.split('?')[1] ?? '').get('token');
|
||||
if (token) params.set('invitationToken', token);
|
||||
}
|
||||
return `/register?${params.toString()}`;
|
||||
}
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
@@ -50,6 +58,8 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showVerifiedSuccess, setShowVerifiedSuccess] = useState(false);
|
||||
const callbackUrl = getSafeCallbackUrl(searchParams.get('callbackUrl'));
|
||||
const isInvitationFlow = isInvitationCallbackUrl(callbackUrl);
|
||||
const registerHref = buildRegisterHref(callbackUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('registered') === 'true') {
|
||||
@@ -82,8 +92,10 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `result.url` is whatever next-auth resolved, so it is sanitized again here — and
|
||||
// re-checked at the sink, because `router.push` happily leaves the origin.
|
||||
const destination = getSafeCallbackUrl(result?.url || callbackUrl);
|
||||
router.push(destination);
|
||||
router.push(isSafeRelativePath(destination) ? destination : '/dashboard');
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.');
|
||||
@@ -105,13 +117,22 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Welcome back</CardTitle>
|
||||
<CardDescription>Sign in to your account to continue</CardDescription>
|
||||
<CardDescription>
|
||||
{isInvitationFlow
|
||||
? 'Sign in to accept your invitation'
|
||||
: 'Sign in to your account to continue'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/*
|
||||
`?registered=true` is only ever reached when email verification is off: the
|
||||
register page sends a user who has to verify to /verify-email instead. Telling
|
||||
this one to go and check a mailbox pointed them at a message that never arrives,
|
||||
on a self-hosted deployment without SMTP, which is the documented default.
|
||||
*/}
|
||||
{showSuccess && (
|
||||
<div className="p-3 rounded-md bg-green-500/10 text-green-600 text-sm mb-4">
|
||||
Account created successfully! Please check your email to verify your address before
|
||||
signing in.
|
||||
Account created successfully! You can sign in now.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -242,7 +263,7 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="text-primary hover:underline">
|
||||
<Link href={registerHref} className="text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,15 +1,50 @@
|
||||
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
||||
import { after } from 'next/server';
|
||||
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
|
||||
import { isInviteCodeRequired, isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { getInvitationPreviewByToken } from '@/lib/invitations';
|
||||
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
|
||||
import RegisterPageClient from './register-page-client';
|
||||
|
||||
export default function RegisterPage() {
|
||||
interface RegisterPageProps {
|
||||
searchParams: Promise<{ invitationToken?: string }>;
|
||||
}
|
||||
|
||||
export default async function RegisterPage({ searchParams }: RegisterPageProps) {
|
||||
// Reaching this page is the funnel step. Recording it here rather than from the
|
||||
// browser also keeps it honest: a prefetch of this route is filtered out by
|
||||
// readPageVisitor, so signup starts can never outnumber the landing views
|
||||
// above them.
|
||||
const visitor = await readPageVisitor();
|
||||
after(() => recordVisitorEvent('SIGNUP_STARTED', visitor));
|
||||
|
||||
const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||
|
||||
const token = (await searchParams)?.invitationToken?.trim();
|
||||
|
||||
// Unauthenticated invitation lookup — throttled per IP (and per token) before it can
|
||||
// touch the database. When throttled we skip the lookup instead of guessing at a verdict.
|
||||
const previewAllowed = token ? await isInvitationPreviewAllowed(token) : false;
|
||||
const preview = token && previewAllowed ? await getInvitationPreviewByToken(token) : null;
|
||||
const invitation =
|
||||
preview && preview.status === 'PENDING' && !preview.isExpired
|
||||
? {
|
||||
email: preview.email,
|
||||
inviterName: preview.inviterName,
|
||||
roleLabel: preview.roleLabel,
|
||||
scopeLabel: preview.scopeLabel,
|
||||
targetName: preview.targetName,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<RegisterPageClient
|
||||
requireInviteCode={isInviteCodeRequired()}
|
||||
trialOnSignup={isStripeFeatureEnabled()}
|
||||
googleEnabled={googleEnabled}
|
||||
githubEnabled={githubEnabled}
|
||||
invitation={invitation}
|
||||
invitationLookupThrottled={Boolean(token) && !previewAllowed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,24 +9,58 @@ 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 { getSafeCallbackUrl } from '@/lib/safe-redirect';
|
||||
|
||||
export interface RegisterInvitation {
|
||||
email: string;
|
||||
inviterName: string;
|
||||
roleLabel: string;
|
||||
scopeLabel: string;
|
||||
targetName: string | null;
|
||||
}
|
||||
|
||||
interface RegisterPageClientProps {
|
||||
requireInviteCode: boolean;
|
||||
/**
|
||||
* Billing is on, so this account starts a free trial. False on a self-hosted
|
||||
* instance, where there is nothing to trial and the promise would be a lie.
|
||||
*/
|
||||
trialOnSignup?: boolean;
|
||||
googleEnabled: boolean;
|
||||
githubEnabled: boolean;
|
||||
invitation?: RegisterInvitation | null;
|
||||
/** Preview lookup was rate-limited, so `invitation` says nothing about its validity. */
|
||||
invitationLookupThrottled?: boolean;
|
||||
}
|
||||
|
||||
export default function RegisterPageClient({
|
||||
requireInviteCode,
|
||||
trialOnSignup = false,
|
||||
googleEnabled,
|
||||
githubEnabled,
|
||||
invitation = null,
|
||||
invitationLookupThrottled = false,
|
||||
}: RegisterPageClientProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]);
|
||||
const invitedEmail = useMemo(() => searchParams.get('email') || '', [searchParams]);
|
||||
const invitedEmail = useMemo(
|
||||
() => invitation?.email || searchParams.get('email') || '',
|
||||
[invitation, searchParams]
|
||||
);
|
||||
// Where to send the user once they are signed in — for invitations this points back
|
||||
// at /invitations/accept so they land on the workspace/project they were invited to
|
||||
// instead of the onboarding wizard.
|
||||
const callbackUrl = useMemo(
|
||||
() => getSafeCallbackUrl(searchParams.get('callbackUrl')),
|
||||
[searchParams]
|
||||
);
|
||||
const isInvitationFlow = invitationToken.length > 0;
|
||||
const shouldShowInviteCode = requireInviteCode && !isInvitationFlow;
|
||||
const loginHref =
|
||||
callbackUrl === '/dashboard'
|
||||
? '/login'
|
||||
: `/login?callbackUrl=${encodeURIComponent(callbackUrl)}`;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
@@ -91,10 +125,11 @@ export default function RegisterPageClient({
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackParam = `&callbackUrl=${encodeURIComponent(callbackUrl)}`;
|
||||
if (data.data?.emailVerificationRequired) {
|
||||
router.push(`/verify-email?email=${encodeURIComponent(formData.email)}`);
|
||||
router.push(`/verify-email?email=${encodeURIComponent(formData.email)}${callbackParam}`);
|
||||
} else {
|
||||
router.push('/login?registered=true');
|
||||
router.push(`/login?registered=true${callbackParam}`);
|
||||
}
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.');
|
||||
@@ -106,7 +141,7 @@ export default function RegisterPageClient({
|
||||
const handleOAuthSignUp = async (provider: string) => {
|
||||
setOauthLoading(provider);
|
||||
setError('');
|
||||
await signIn(provider, { callbackUrl: '/dashboard' });
|
||||
await signIn(provider, { callbackUrl });
|
||||
};
|
||||
|
||||
const hasOAuth = googleEnabled || githubEnabled;
|
||||
@@ -127,6 +162,15 @@ export default function RegisterPageClient({
|
||||
Create Account
|
||||
</CardTitle>
|
||||
<CardDescription>Join OpenFrame to collaborate on video projects</CardDescription>
|
||||
{/* The landing page CTA promises a trial and no card. Say it again here,
|
||||
where the promise is actually kept, rather than making people take the
|
||||
previous page's word for it. Invitees are joining someone else's
|
||||
workspace, so the trial is not what brought them. */}
|
||||
{trialOnSignup && !isInvitationFlow && (
|
||||
<CardDescription>
|
||||
Your 7-day free trial starts as soon as you create the account. No credit card.
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* OAuth Buttons */}
|
||||
@@ -204,9 +248,29 @@ export default function RegisterPageClient({
|
||||
)}
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
{isInvitationFlow ? (
|
||||
<div className="p-3 rounded-md bg-primary/10 text-sm">
|
||||
You are registering via an invitation link.
|
||||
{isInvitationFlow && invitation ? (
|
||||
<div className="p-3 rounded-md bg-primary/10 text-sm space-y-1">
|
||||
<p>
|
||||
{invitation.inviterName} invited you to{' '}
|
||||
<strong>
|
||||
{invitation.targetName
|
||||
? `${invitation.targetName} (${invitation.scopeLabel})`
|
||||
: `a ${invitation.scopeLabel}`}
|
||||
</strong>{' '}
|
||||
as {invitation.roleLabel}.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Create your account below — you'll be taken straight to it.
|
||||
</p>
|
||||
</div>
|
||||
) : isInvitationFlow && invitationLookupThrottled ? (
|
||||
<div className="p-3 rounded-md bg-amber-500/10 text-sm">
|
||||
We couldn't check this invitation right now. Please wait a few minutes and
|
||||
open the link again.
|
||||
</div>
|
||||
) : isInvitationFlow ? (
|
||||
<div className="p-3 rounded-md bg-amber-500/10 text-sm">
|
||||
This invitation link is no longer valid. Ask whoever invited you for a new one.
|
||||
</div>
|
||||
) : shouldShowInviteCode ? (
|
||||
<>
|
||||
@@ -261,7 +325,14 @@ export default function RegisterPageClient({
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={isLoading}
|
||||
readOnly={Boolean(invitation)}
|
||||
className={invitation ? 'bg-muted text-muted-foreground' : undefined}
|
||||
/>
|
||||
{invitation && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The invitation is tied to this address.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -307,7 +378,7 @@ export default function RegisterPageClient({
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-primary hover:underline">
|
||||
<Link href={loginHref} className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
@@ -316,13 +387,13 @@ export default function RegisterPageClient({
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||
By continuing, you agree to our{' '}
|
||||
<a href="/terms" className="underline hover:text-foreground">
|
||||
<Link href="/terms" className="underline hover:text-foreground">
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<a href="/privacy" className="underline hover:text-foreground">
|
||||
<Link href="/privacy" className="underline hover:text-foreground">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,16 @@ import { Video, Mail, Loader2 } 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 { getSafeCallbackUrl } from '@/lib/safe-redirect';
|
||||
|
||||
function VerifyEmailContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const emailParam = searchParams.get('email') || '';
|
||||
const callbackUrl = getSafeCallbackUrl(searchParams.get('callbackUrl'));
|
||||
const loginHref =
|
||||
callbackUrl === '/dashboard'
|
||||
? '/login'
|
||||
: `/login?callbackUrl=${encodeURIComponent(callbackUrl)}`;
|
||||
const [resendEmail, setResendEmail] = useState(emailParam);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
@@ -107,7 +113,7 @@ function VerifyEmailContent() {
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already verified?{' '}
|
||||
<Link href="/login" className="text-primary hover:underline">
|
||||
<Link href={loginHref} className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ProjectFilter } from './project-filter';
|
||||
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
|
||||
interface SerializedProject {
|
||||
id: string;
|
||||
@@ -21,7 +22,8 @@ interface DashboardClientProps {
|
||||
totalPages: number;
|
||||
canCreateProjects: boolean;
|
||||
canUploadVideos: boolean;
|
||||
bunnyUploadsEnabled: boolean;
|
||||
directUploadsEnabled: boolean;
|
||||
directUploadProvider: DirectUploadProvider;
|
||||
}
|
||||
|
||||
export function DashboardClient({
|
||||
@@ -30,11 +32,15 @@ export function DashboardClient({
|
||||
totalPages,
|
||||
canCreateProjects,
|
||||
canUploadVideos,
|
||||
bunnyUploadsEnabled,
|
||||
directUploadsEnabled,
|
||||
directUploadProvider,
|
||||
}: DashboardClientProps) {
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-8 w-full">
|
||||
<VideoDragDropUploader canUpload={canUploadVideos && bunnyUploadsEnabled} />
|
||||
<VideoDragDropUploader
|
||||
canUpload={canUploadVideos && directUploadsEnabled}
|
||||
directUploadProvider={directUploadProvider}
|
||||
/>
|
||||
<ProjectFilter
|
||||
projects={serializedProjects}
|
||||
workspaces={workspaces}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/lib/route-access';
|
||||
import { DashboardClient } from './dashboard-client';
|
||||
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
@@ -157,7 +157,8 @@ export default async function DashboardPage({
|
||||
totalPages={totalPages}
|
||||
canCreateProjects={canCreateProjects}
|
||||
canUploadVideos={canUploadVideos}
|
||||
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
|
||||
directUploadsEnabled={isDirectFileUploadEnabled()}
|
||||
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Header } from '@/components/layout';
|
||||
import { Header, TrialBanner } from '@/components/layout';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { hasAppNavigationAccess } from '@/lib/route-access';
|
||||
import { getTrialNotice } from '@/lib/billing';
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
const showAppNavigation = session?.user?.id
|
||||
? await hasAppNavigationAccess(session.user.id)
|
||||
: false;
|
||||
const userId = session?.user?.id;
|
||||
const [showAppNavigation, trialNotice] = await Promise.all([
|
||||
userId ? hasAppNavigationAccess(userId) : false,
|
||||
userId ? getTrialNotice(userId) : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<Header user={session?.user ?? null} showAppNavigation={showAppNavigation} />
|
||||
{trialNotice ? <TrialBanner notice={trialNotice} /> : null}
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { GuestGate } from '@/components/guest-gate';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { ProjectContentClient } from './project-content-client';
|
||||
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { canDownloadProjectMedia } from '@/lib/project-download';
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (!seconds) return '0:00';
|
||||
@@ -101,7 +103,7 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
}
|
||||
|
||||
// Fetch videos separately utilizing bounds
|
||||
const [paginatedVideos, totalVideos] = await Promise.all([
|
||||
const [paginatedVideos, totalVideos, allVideoIds] = await Promise.all([
|
||||
db.video.findMany({
|
||||
where: { projectId: project.id },
|
||||
skip,
|
||||
@@ -121,6 +123,11 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
db.video.count({
|
||||
where: { projectId: project.id },
|
||||
}),
|
||||
db.video.findMany({
|
||||
where: { projectId: project.id },
|
||||
select: { id: true },
|
||||
orderBy: [{ position: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(totalVideos / pageSize);
|
||||
@@ -141,6 +148,9 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
};
|
||||
});
|
||||
|
||||
const directUploadsEnabled = isDirectFileUploadEnabled();
|
||||
const directUploadProvider = isS3VideoUploadsEnabled() ? 'r2' : 'bunny';
|
||||
|
||||
const canEdit =
|
||||
access.canEdit &&
|
||||
(isOwner ||
|
||||
@@ -149,10 +159,13 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
workspaceRole === 'ADMIN');
|
||||
const isAuthenticated = !!session?.user?.id;
|
||||
|
||||
const canDownloadProject = canDownloadProjectMedia(project, access);
|
||||
|
||||
const projectData = {
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
visibility: project.visibility,
|
||||
allowDownloads: project.allowDownloads,
|
||||
workspace: project.workspace,
|
||||
members: project.members,
|
||||
};
|
||||
@@ -176,11 +189,16 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
project={projectData}
|
||||
projectId={projectId}
|
||||
videos={videos}
|
||||
allVideoIds={allVideoIds.map((video) => video.id)}
|
||||
canEdit={false}
|
||||
canDownloadProject={canDownloadProject}
|
||||
isOwner={false}
|
||||
workspaceRole={null}
|
||||
totalPages={totalPages}
|
||||
currentPage={page}
|
||||
pageSize={pageSize}
|
||||
directUploadsEnabled={directUploadsEnabled}
|
||||
directUploadProvider={directUploadProvider}
|
||||
/>
|
||||
</div>
|
||||
</GuestGate>
|
||||
@@ -203,11 +221,16 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
|
||||
project={projectData}
|
||||
projectId={projectId}
|
||||
videos={videos}
|
||||
allVideoIds={allVideoIds.map((video) => video.id)}
|
||||
canEdit={canEdit}
|
||||
canDownloadProject={canDownloadProject}
|
||||
isOwner={isOwner}
|
||||
workspaceRole={workspaceRole}
|
||||
totalPages={totalPages}
|
||||
currentPage={page}
|
||||
pageSize={pageSize}
|
||||
directUploadsEnabled={directUploadsEnabled}
|
||||
directUploadProvider={directUploadProvider}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
@@ -15,12 +15,47 @@ import {
|
||||
Globe,
|
||||
UserPlus,
|
||||
Lock,
|
||||
Download,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
FolderInput,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { VideoCard } from '@/components/video-card';
|
||||
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
|
||||
import { MoveVideosDialog } from '@/components/move-videos-dialog';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
import {
|
||||
runProjectDownloadManifest,
|
||||
type ProjectDownloadManifest,
|
||||
} from '@/lib/client/project-download';
|
||||
import { downloadProgressPercent } from '@/lib/client/download-file';
|
||||
import {
|
||||
createDownloadProgressToast,
|
||||
type DownloadProgressToastHandle,
|
||||
} from '@/components/download-progress-toast';
|
||||
|
||||
interface SerializedVideo {
|
||||
id: string;
|
||||
@@ -38,36 +73,63 @@ interface ProjectContentClientProps {
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: string;
|
||||
allowDownloads: boolean;
|
||||
workspace: { id: string; name: string } | null;
|
||||
members: { role: string }[];
|
||||
};
|
||||
projectId: string;
|
||||
videos: SerializedVideo[];
|
||||
allVideoIds: string[];
|
||||
canEdit: boolean;
|
||||
canDownloadProject: boolean;
|
||||
isOwner: boolean;
|
||||
workspaceRole: string | null;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
directUploadsEnabled: boolean;
|
||||
directUploadProvider: DirectUploadProvider;
|
||||
}
|
||||
|
||||
export function ProjectContentClient({
|
||||
project,
|
||||
projectId,
|
||||
videos,
|
||||
allVideoIds,
|
||||
canEdit,
|
||||
canDownloadProject,
|
||||
isOwner,
|
||||
totalPages,
|
||||
currentPage,
|
||||
pageSize,
|
||||
directUploadsEnabled,
|
||||
directUploadProvider,
|
||||
}: ProjectContentClientProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const sortOrder = searchParams.get('sort') || 'desc';
|
||||
const [localVideos, setLocalVideos] = useState<SerializedVideo[]>(videos);
|
||||
const [selectedVideoIds, setSelectedVideoIds] = useState<string[]>([]);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [includeAssetsInDownload, setIncludeAssetsInDownload] = useState(false);
|
||||
const [isDeletingSelected, setIsDeletingSelected] = useState(false);
|
||||
const [showDeleteSelectedDialog, setShowDeleteSelectedDialog] = useState(false);
|
||||
const [showMoveSelectedDialog, setShowMoveSelectedDialog] = useState(false);
|
||||
|
||||
const canSelectVideos = canDownloadProject || canEdit;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalVideos(videos);
|
||||
}, [videos]);
|
||||
|
||||
const selectedCount = selectedVideoIds.length;
|
||||
const pageVideoIds = useMemo(() => localVideos.map((video) => video.id), [localVideos]);
|
||||
const allSelected = useMemo(
|
||||
() => pageVideoIds.length > 0 && pageVideoIds.every((id) => selectedVideoIds.includes(id)),
|
||||
[pageVideoIds, selectedVideoIds]
|
||||
);
|
||||
|
||||
const createQueryString = useCallback(
|
||||
(name: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -84,14 +146,179 @@ export function ProjectContentClient({
|
||||
|
||||
const handleVideoDeleted = useCallback((videoId: string) => {
|
||||
setLocalVideos((prev) => prev.filter((video) => video.id !== videoId));
|
||||
setSelectedVideoIds((prev) => prev.filter((id) => id !== videoId));
|
||||
}, []);
|
||||
|
||||
const handleVideosMoved = useCallback((movedIds: string[]) => {
|
||||
const moved = new Set(movedIds);
|
||||
setLocalVideos((prev) => prev.filter((video) => !moved.has(video.id)));
|
||||
setSelectedVideoIds([]);
|
||||
setSelectionMode(false);
|
||||
}, []);
|
||||
|
||||
const toggleVideoSelection = useCallback((videoId: string, selected: boolean) => {
|
||||
setSelectedVideoIds((prev) => {
|
||||
if (selected) {
|
||||
if (prev.includes(videoId)) return prev;
|
||||
return [...prev, videoId];
|
||||
}
|
||||
return prev.filter((id) => id !== videoId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
// Scope selection to the current page only. Selecting every video across
|
||||
// every page from a single button is too easy to trigger by accident when
|
||||
// the user only meant the videos they can see.
|
||||
setSelectedVideoIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
pageVideoIds.forEach((id) => next.add(id));
|
||||
return Array.from(next);
|
||||
});
|
||||
}, [pageVideoIds]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
const pageIds = new Set(pageVideoIds);
|
||||
setSelectedVideoIds((prev) => prev.filter((id) => !pageIds.has(id)));
|
||||
}, [pageVideoIds]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelectedVideoIds([]);
|
||||
setSelectionMode(false);
|
||||
}, []);
|
||||
|
||||
const handleEnterSelectionMode = useCallback(() => {
|
||||
setSelectionMode(true);
|
||||
}, []);
|
||||
|
||||
const startProjectDownload = useCallback(
|
||||
async (videoIds?: string[], options?: { allVersions?: boolean; includeAssets?: boolean }) => {
|
||||
if (!canDownloadProject || isDownloading) return;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
if (videoIds && videoIds.length > 0) {
|
||||
searchParams.set('videoIds', videoIds.join(','));
|
||||
}
|
||||
if (options?.allVersions) {
|
||||
searchParams.set('versions', 'all');
|
||||
}
|
||||
if (options?.includeAssets) {
|
||||
searchParams.set('assets', '1');
|
||||
}
|
||||
const query = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
||||
|
||||
setIsDownloading(true);
|
||||
let progressToast: DownloadProgressToastHandle | null = null;
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/download${query}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof body?.error === 'string' ? body.error : 'Failed to prepare project download';
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = body?.data as ProjectDownloadManifest | undefined;
|
||||
if (!manifest?.files?.length) {
|
||||
toast.error('No downloadable files found');
|
||||
return;
|
||||
}
|
||||
|
||||
progressToast = createDownloadProgressToast(`project-download-${projectId}`, {
|
||||
title: `Downloading ${manifest.totalFiles} files`,
|
||||
description: 'Starting…',
|
||||
});
|
||||
await runProjectDownloadManifest(manifest, (p) => {
|
||||
const percent = downloadProgressPercent({
|
||||
receivedBytes: p.receivedBytes,
|
||||
totalBytes: p.totalBytes,
|
||||
});
|
||||
progressToast?.update({
|
||||
title: `Downloading file ${p.index}/${p.total}`,
|
||||
description: `${p.fileName}${percent !== null ? ` · ${percent}%` : ''}`,
|
||||
percent,
|
||||
});
|
||||
});
|
||||
progressToast.success(`Downloaded ${manifest.totalFiles} files`);
|
||||
} catch {
|
||||
// The progress panel never expires on its own, so clear it before the
|
||||
// error toast replaces it.
|
||||
progressToast?.dismiss();
|
||||
toast.error('Failed to start project download');
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
},
|
||||
[canDownloadProject, isDownloading, projectId]
|
||||
);
|
||||
|
||||
const handleDeleteSelected = useCallback(async () => {
|
||||
if (!canEdit || selectedCount === 0 || isDeletingSelected) return;
|
||||
|
||||
setIsDeletingSelected(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/videos/bulk-delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoIds: selectedVideoIds }),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof body?.error === 'string' ? body.error : 'Failed to delete selected videos';
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedIds = new Set(selectedVideoIds);
|
||||
setLocalVideos((prev) => prev.filter((video) => !deletedIds.has(video.id)));
|
||||
setSelectedVideoIds([]);
|
||||
setSelectionMode(false);
|
||||
setShowDeleteSelectedDialog(false);
|
||||
toast.success(
|
||||
typeof body?.data?.message === 'string' ? body.data.message : 'Selected videos deleted'
|
||||
);
|
||||
|
||||
// The current page may now be out of range (e.g. we deleted every video
|
||||
// on it). Clamp to the last valid page so the refresh lands on a page
|
||||
// that still has videos instead of showing "No videos yet".
|
||||
const remainingTotal = allVideoIds.filter((id) => !deletedIds.has(id)).length;
|
||||
const newTotalPages = Math.max(1, Math.ceil(remainingTotal / pageSize));
|
||||
if (currentPage > newTotalPages) {
|
||||
router.push(`?${createQueryString('page', newTotalPages.toString())}`);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete selected videos');
|
||||
} finally {
|
||||
setIsDeletingSelected(false);
|
||||
}
|
||||
}, [
|
||||
allVideoIds,
|
||||
canEdit,
|
||||
createQueryString,
|
||||
currentPage,
|
||||
isDeletingSelected,
|
||||
pageSize,
|
||||
projectId,
|
||||
router,
|
||||
selectedCount,
|
||||
selectedVideoIds,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VideoDragDropUploader
|
||||
fixedProjectId={projectId}
|
||||
fixedProjectName={project.name}
|
||||
canUpload={canEdit}
|
||||
canUpload={canEdit && directUploadsEnabled}
|
||||
directUploadProvider={directUploadProvider}
|
||||
/>
|
||||
|
||||
{/* Project Header */}
|
||||
@@ -125,7 +352,6 @@ export function ProjectContentClient({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4 sm:mt-0">
|
||||
{/* Sort Button - Left of Share */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -147,6 +373,48 @@ export function ProjectContentClient({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{canDownloadProject && localVideos.length > 0 && !selectionMode && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isDownloading}>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download project
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={includeAssetsInDownload}
|
||||
onCheckedChange={(checked) => setIncludeAssetsInDownload(checked === true)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
Include assets
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
startProjectDownload(undefined, { includeAssets: includeAssetsInDownload })
|
||||
}
|
||||
>
|
||||
Latest version only
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
startProjectDownload(undefined, {
|
||||
allVersions: true,
|
||||
includeAssets: includeAssetsInDownload,
|
||||
})
|
||||
}
|
||||
>
|
||||
All versions
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/projects/${projectId}/share`}>
|
||||
@@ -182,6 +450,107 @@ export function ProjectContentClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectionMode && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
|
||||
<span className="text-sm font-medium">Selection mode</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedCount > 0 ? `${selectedCount} selected` : 'None selected'}
|
||||
</span>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={allSelected ? handleDeselectAll : handleSelectAll}
|
||||
>
|
||||
{totalPages > 1
|
||||
? allSelected
|
||||
? 'Deselect page'
|
||||
: 'Select page'
|
||||
: allSelected
|
||||
? 'Deselect all'
|
||||
: 'Select all'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleClearSelection}>
|
||||
Cancel
|
||||
</Button>
|
||||
{canDownloadProject && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isDownloading || selectedCount === 0}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download selected
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={includeAssetsInDownload}
|
||||
onCheckedChange={(checked) => setIncludeAssetsInDownload(checked === true)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
Include assets
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
startProjectDownload(selectedVideoIds, {
|
||||
includeAssets: includeAssetsInDownload,
|
||||
})
|
||||
}
|
||||
>
|
||||
Latest version only
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
startProjectDownload(selectedVideoIds, {
|
||||
allVersions: true,
|
||||
includeAssets: includeAssetsInDownload,
|
||||
})
|
||||
}
|
||||
>
|
||||
All versions
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowMoveSelectedDialog(true)}
|
||||
disabled={selectedCount === 0 || isDeletingSelected}
|
||||
>
|
||||
<FolderInput className="h-4 w-4 mr-2" />
|
||||
Move to project
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteSelectedDialog(true)}
|
||||
disabled={selectedCount === 0 || isDeletingSelected}
|
||||
>
|
||||
{isDeletingSelected ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Delete selected
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Videos Grid */}
|
||||
{localVideos.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -191,6 +560,11 @@ export function ProjectContentClient({
|
||||
video={video}
|
||||
projectId={projectId}
|
||||
canManage={canEdit}
|
||||
canSelect={canSelectVideos}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedVideoIds.includes(video.id)}
|
||||
onEnterSelectionMode={handleEnterSelectionMode}
|
||||
onSelectedChange={(selected) => toggleVideoSelection(video.id, selected)}
|
||||
onDeleted={handleVideoDeleted}
|
||||
/>
|
||||
))}
|
||||
@@ -244,6 +618,42 @@ export function ProjectContentClient({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={showDeleteSelectedDialog} onOpenChange={setShowDeleteSelectedDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete {selectedCount} video{selectedCount === 1 ? '' : 's'}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the selected videos, all of their versions, comments, and
|
||||
stored media from Bunny and Cloudflare R2. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingSelected}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleDeleteSelected();
|
||||
}}
|
||||
disabled={isDeletingSelected || selectedCount === 0}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeletingSelected && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Delete selected
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<MoveVideosDialog
|
||||
open={showMoveSelectedDialog}
|
||||
onOpenChange={setShowMoveSelectedDialog}
|
||||
projectId={projectId}
|
||||
videoIds={selectedVideoIds}
|
||||
onMoved={handleVideosMoved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
|
||||
name: '',
|
||||
description: '',
|
||||
visibility: 'PRIVATE' as Visibility,
|
||||
allowDownloads: false,
|
||||
});
|
||||
|
||||
// Tag management state
|
||||
@@ -109,6 +110,7 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
|
||||
name: project.name || '',
|
||||
description: project.description || '',
|
||||
visibility: project.visibility || 'PRIVATE',
|
||||
allowDownloads: project.allowDownloads ?? false,
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -351,6 +353,48 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border p-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Project downloads</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Allow viewers to download project files. Project admins can always download.
|
||||
When enabled on a public project, anyone with the link can download files
|
||||
without signing in.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({ ...prev, allowDownloads: !prev.allowDownloads }))
|
||||
}
|
||||
disabled={isSaving}
|
||||
className={`w-full flex items-center justify-between gap-4 p-4 rounded-xl border-2 text-left transition-all ${
|
||||
formData.allowDownloads
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-border/80 hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">Allow viewer downloads</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Public and invited viewers can download files when enabled. On public
|
||||
projects this includes unauthenticated visitors.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
formData.allowDownloads
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
{formData.allowDownloads && (
|
||||
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||
{error}
|
||||
@@ -394,20 +438,32 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
|
||||
<input
|
||||
type="color"
|
||||
value={editTagColor}
|
||||
aria-label={`Tag colour for ${tag.name}`}
|
||||
onChange={(e) => setEditTagColor(e.target.value)}
|
||||
className="w-8 h-8 rounded cursor-pointer border-0"
|
||||
/>
|
||||
<Input
|
||||
value={editTagName}
|
||||
aria-label={`Tag name for ${tag.name}`}
|
||||
onChange={(e) => setEditTagName(e.target.value)}
|
||||
className="flex-1 h-8"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleUpdateTag(tag.id)}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleUpdateTag(tag.id)}>
|
||||
<Save className="h-4 w-4" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Save tag ${tag.name}`}
|
||||
onClick={() => handleUpdateTag(tag.id)}
|
||||
>
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingTagId(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Cancel editing tag ${tag.name}`}
|
||||
onClick={() => setEditingTagId(null)}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -432,9 +488,10 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
aria-label={`Delete tag ${tag.name}`}
|
||||
onClick={() => handleDeleteTag(tag.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ProjectSharePageProps {
|
||||
export default function ProjectSharePageClient({ projectId }: ProjectSharePageProps) {
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [projectVisibility, setProjectVisibility] = useState('');
|
||||
const [allowDownloads, setAllowDownloads] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [members, setMembers] = useState<ProjectMember[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -56,6 +57,7 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
|
||||
const project = data.data;
|
||||
setProjectName(project.name || '');
|
||||
setProjectVisibility(project.visibility || 'PRIVATE');
|
||||
setAllowDownloads(project.allowDownloads ?? false);
|
||||
setMembers(project.members || []);
|
||||
}
|
||||
})
|
||||
@@ -174,6 +176,14 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{visibilityInfo.title}</div>
|
||||
<div className="text-sm opacity-80">{visibilityInfo.description}</div>
|
||||
{(projectVisibility === 'PUBLIC' || projectVisibility === 'INVITE') && (
|
||||
<div className="text-sm opacity-80 mt-1">
|
||||
Viewer downloads:{' '}
|
||||
{allowDownloads
|
||||
? 'enabled (includes anonymous visitors on public links)'
|
||||
: 'disabled'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link href={`/projects/${projectId}/settings`}>
|
||||
<Button variant="ghost" size="sm" className="text-current hover:bg-current/10">
|
||||
|
||||
+206
-3
@@ -29,6 +29,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { isPlayableVideoUrl, resolveR2PlaybackUrl } from '@/lib/video-upload-validation';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Version {
|
||||
@@ -91,6 +92,11 @@ function formatTime(seconds: number): string {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Panels drifting past this from the source player read as out-of-sync playback.
|
||||
const MAX_PANEL_DRIFT_SECONDS = 0.35;
|
||||
// Minimum gap between two corrective seeks of the same panel.
|
||||
const RESYNC_COOLDOWN_MS = 4000;
|
||||
|
||||
const isSafeUrl = (url: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
@@ -142,6 +148,8 @@ export default function CompareVersionsPageClient({
|
||||
const currentTimeRef = useRef(0);
|
||||
const durationRef = useRef(0);
|
||||
const lastCommitRef = useRef(0);
|
||||
const lastSyncRef = useRef(0);
|
||||
const resyncCooldownRef = useRef(new WeakMap<YT.Player | PlayerAdapter, number>());
|
||||
|
||||
// Direct DOM refs for progress bar / playhead / timecode — updated in the RAF loop
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
@@ -241,7 +249,11 @@ export default function CompareVersionsPageClient({
|
||||
try {
|
||||
const t = sourcePlayer.getCurrentTime();
|
||||
const d = sourcePlayer.getDuration();
|
||||
const playing = sourcePlayer.getPlayerState() === window.YT?.PlayerState?.PLAYING;
|
||||
// PLAYING is 1 in the YouTube API; the numeric fallback keeps
|
||||
// state detection working when the YT script never loads
|
||||
// (bunny/r2-only comparisons, ad blockers).
|
||||
const playing =
|
||||
sourcePlayer.getPlayerState() === (window.YT?.PlayerState?.PLAYING ?? 1);
|
||||
|
||||
// Update refs immediately — zero React overhead
|
||||
if (t !== undefined) currentTimeRef.current = t;
|
||||
@@ -257,6 +269,28 @@ export default function CompareVersionsPageClient({
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sync followers that drift from the source player — providers
|
||||
// buffer at different speeds and drift past ~350ms reads as
|
||||
// out-of-sync playback. The per-player cooldown keeps a follower
|
||||
// that simply cannot keep up (slow network, HLS rebuffering) from
|
||||
// being seeked every second, which would stutter rather than correct.
|
||||
if (playing && t !== undefined && timestamp - lastSyncRef.current >= 1000) {
|
||||
lastSyncRef.current = timestamp;
|
||||
const cooldowns = resyncCooldownRef.current;
|
||||
for (let i = 1; i < players.length; i += 1) {
|
||||
const follower = players[i];
|
||||
if (timestamp - (cooldowns.get(follower) ?? 0) < RESYNC_COOLDOWN_MS) continue;
|
||||
try {
|
||||
if (Math.abs(follower.getCurrentTime() - t) > MAX_PANEL_DRIFT_SECONDS) {
|
||||
cooldowns.set(follower, timestamp);
|
||||
follower.seekTo(t, true);
|
||||
}
|
||||
} catch {
|
||||
// Player not ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle React state commits to ~4 updates/sec
|
||||
if (timestamp - lastCommitRef.current >= 250) {
|
||||
lastCommitRef.current = timestamp;
|
||||
@@ -296,7 +330,7 @@ export default function CompareVersionsPageClient({
|
||||
try {
|
||||
const firstPlayer = players[0];
|
||||
const state = firstPlayer.getPlayerState();
|
||||
const playing = state === window.YT?.PlayerState?.PLAYING;
|
||||
const playing = state === (window.YT?.PlayerState?.PLAYING ?? 1);
|
||||
|
||||
if (playing) {
|
||||
players.forEach((p) => {
|
||||
@@ -728,6 +762,13 @@ export default function CompareVersionsPageClient({
|
||||
onRegister={registerPlayer}
|
||||
onUnregister={unregisterPlayer}
|
||||
/>
|
||||
) : version.providerId === 'r2' ? (
|
||||
<R2Panel
|
||||
key={versionId}
|
||||
version={version}
|
||||
onRegister={registerPlayer}
|
||||
onUnregister={unregisterPlayer}
|
||||
/>
|
||||
) : isSafeUrl(version.originalUrl) ? (
|
||||
<iframe
|
||||
src={version.originalUrl}
|
||||
@@ -822,7 +863,7 @@ export default function CompareVersionsPageClient({
|
||||
</button>
|
||||
</div>
|
||||
{comment.content && (
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap break-words">
|
||||
{comment.content}
|
||||
</p>
|
||||
)}
|
||||
@@ -977,6 +1018,168 @@ function YouTubePanel({
|
||||
return <div ref={containerRef} className="w-full h-full pointer-events-none" />;
|
||||
}
|
||||
|
||||
// Isolated R2 direct-upload panel: a plain <video> over the app's upload
|
||||
// route, mapped to the shared adapter interface. Kept separate from
|
||||
// BunnyPanel, whose HLS processing-retry logic does not apply to R2 files.
|
||||
function R2Panel({
|
||||
version,
|
||||
onRegister,
|
||||
onUnregister,
|
||||
}: {
|
||||
version: Version;
|
||||
onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void;
|
||||
onUnregister: (versionId: string) => void;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(0);
|
||||
const [isPortraitSource, setIsPortraitSource] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const panelEl = panelRef.current;
|
||||
if (!panelEl || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const updateFrameWidth = () => {
|
||||
const panelWidth = panelEl.clientWidth;
|
||||
const panelHeight = panelEl.clientHeight;
|
||||
if (panelWidth <= 0 || panelHeight <= 0) return;
|
||||
setPortraitFrameWidth(Math.min(panelWidth, panelHeight * (9 / 16)));
|
||||
};
|
||||
|
||||
updateFrameWidth();
|
||||
const observer = new ResizeObserver(updateFrameWidth);
|
||||
observer.observe(panelEl);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl) return;
|
||||
|
||||
// Same guard the rest of the page applies before putting a URL in the DOM:
|
||||
// proxy paths must be a well-formed upload route, anything else http(s).
|
||||
const playbackUrl = resolveR2PlaybackUrl(version);
|
||||
if (!isPlayableVideoUrl(playbackUrl)) {
|
||||
console.error('Unsafe R2 playback URL, panel not registered:', playbackUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
let cachedTime = 0;
|
||||
let cachedDuration = 0;
|
||||
let isPlaying = false;
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
}
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
||||
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||
}
|
||||
};
|
||||
const onTimeUpdate = () => {
|
||||
cachedTime = videoEl.currentTime || 0;
|
||||
};
|
||||
const onPlay = () => {
|
||||
isPlaying = true;
|
||||
};
|
||||
const onPause = () => {
|
||||
isPlaying = false;
|
||||
};
|
||||
const onEnded = () => {
|
||||
isPlaying = false;
|
||||
};
|
||||
|
||||
const adapter: PlayerAdapter = {
|
||||
playVideo: () => {
|
||||
videoEl.play().catch((err) => console.error('Error playing R2 panel video:', err));
|
||||
},
|
||||
pauseVideo: () => videoEl.pause(),
|
||||
seekTo: (time: number) => {
|
||||
cachedTime = time;
|
||||
videoEl.currentTime = time;
|
||||
},
|
||||
mute: () => {
|
||||
videoEl.muted = true;
|
||||
},
|
||||
unMute: () => {
|
||||
videoEl.muted = false;
|
||||
},
|
||||
isMuted: () => videoEl.muted,
|
||||
getCurrentTime: () => videoEl.currentTime || cachedTime,
|
||||
getDuration: () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
}
|
||||
return cachedDuration;
|
||||
},
|
||||
getPlayerState: () =>
|
||||
isPlaying ? (window.YT?.PlayerState?.PLAYING ?? 1) : (window.YT?.PlayerState?.PAUSED ?? 2),
|
||||
setPlaybackRate: (rate: number) => {
|
||||
videoEl.playbackRate = rate;
|
||||
},
|
||||
destroy: () => {
|
||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
videoEl.removeEventListener('pause', onPause);
|
||||
videoEl.removeEventListener('ended', onEnded);
|
||||
videoEl.removeAttribute('src');
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
|
||||
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.addEventListener('play', onPlay);
|
||||
videoEl.addEventListener('pause', onPause);
|
||||
videoEl.addEventListener('ended', onEnded);
|
||||
|
||||
videoEl.src = playbackUrl;
|
||||
videoEl.load();
|
||||
|
||||
onRegister(version.id, adapter);
|
||||
|
||||
return () => {
|
||||
onUnregister(version.id);
|
||||
adapter.destroy();
|
||||
};
|
||||
}, [version, onRegister, onUnregister]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="relative w-full h-full group flex items-center justify-center bg-black"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center bg-black',
|
||||
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
|
||||
)}
|
||||
style={
|
||||
isPortraitSource && portraitFrameWidth > 0
|
||||
? { width: `${portraitFrameWidth}px` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain pointer-events-none border-0 bg-black"
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
backgroundColor: 'black',
|
||||
}}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Isolated Bunny Stream player component per panel mapped to the shared adapter interface
|
||||
function BunnyPanel({
|
||||
version,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { VideoPageContent } from '@/components/video-page-content';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { requireVideoProjectAccessOrRedirect } from '@/lib/route-access';
|
||||
|
||||
interface VideoPageProps {
|
||||
@@ -24,7 +24,8 @@ export default async function VideoPage({ params }: VideoPageProps) {
|
||||
mode="dashboard"
|
||||
videoId={videoId}
|
||||
projectId={projectId}
|
||||
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
|
||||
directUploadsEnabled={isDirectFileUploadEnabled()}
|
||||
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CheckCircle2,
|
||||
UploadCloud,
|
||||
FileVideo,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -26,22 +27,25 @@ import {
|
||||
type VideoSource,
|
||||
} from '@/lib/video-providers';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import * as tus from 'tus-js-client';
|
||||
|
||||
const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
||||
|
||||
function isVideoFile(file: File): boolean {
|
||||
if (file.type.startsWith('video/')) return true;
|
||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||
return !!extension && VIDEO_FILE_EXTENSIONS.includes(extension);
|
||||
}
|
||||
import { isTrialStorageError } from '@/lib/client/api-error';
|
||||
import {
|
||||
cleanupPendingProjectUpload,
|
||||
getDefaultTitleFromFile,
|
||||
isVideoFile,
|
||||
uploadProjectVideo,
|
||||
type ActiveTusUpload,
|
||||
type PendingProjectUploadCleanup,
|
||||
} from '@/lib/client/project-video-upload';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
|
||||
export default function NewVideoPageClient({
|
||||
projectId,
|
||||
bunnyUploadsEnabled,
|
||||
directUploadsEnabled,
|
||||
directUploadProvider,
|
||||
}: {
|
||||
projectId: string;
|
||||
bunnyUploadsEnabled: boolean;
|
||||
directUploadsEnabled: boolean;
|
||||
directUploadProvider: DirectUploadProvider;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||
@@ -49,85 +53,66 @@ export default function NewVideoPageClient({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
|
||||
|
||||
// URL Mode State
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
|
||||
const [urlError, setUrlError] = useState('');
|
||||
|
||||
// Upload Mode State
|
||||
const [uploadMode, setUploadMode] = useState<'url' | 'file'>('url');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadStatus, setUploadStatus] = useState('');
|
||||
const [currentUploadIndex, setCurrentUploadIndex] = useState(0);
|
||||
const [isFileDragOver, setIsFileDragOver] = useState(false);
|
||||
const [pendingBunnyVideoId, setPendingBunnyVideoId] = useState<string | null>(null);
|
||||
const [pendingBunnyUploadToken, setPendingBunnyUploadToken] = useState<string | null>(null);
|
||||
const pendingBunnyVideoIdRef = useRef<string | null>(null);
|
||||
const pendingBunnyUploadTokenRef = useRef<string | null>(null);
|
||||
const activeTusUploadRef = useRef<tus.Upload | null>(null);
|
||||
const activeTusUploadRef = useRef<ActiveTusUpload | null>(null);
|
||||
const pendingUploadRef = useRef<PendingProjectUploadCleanup | null>(null);
|
||||
const cancelRequestedRef = useRef(false);
|
||||
const fileDragDepthRef = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [submitError, setSubmitErrorText] = useState('');
|
||||
const [submitErrorIsTrialLimit, setSubmitErrorIsTrialLimit] = useState(false);
|
||||
|
||||
/**
|
||||
* The message and whether it is the trial ceiling, set together.
|
||||
*
|
||||
* The second half is what draws the upgrade link, so it must not outlive the
|
||||
* error it belongs to. Every caller goes through here and hands over the
|
||||
* failure it caught rather than keeping a flag of its own.
|
||||
*/
|
||||
const setSubmitError = useCallback((message: string, source?: unknown) => {
|
||||
setSubmitErrorText(message);
|
||||
setSubmitErrorIsTrialLimit(Boolean(message) && isTrialStorageError(source));
|
||||
}, []);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
});
|
||||
const isUploadingFile = isLoading && uploadMode === 'file';
|
||||
const isMultiFileUpload = selectedFiles.length > 1;
|
||||
const leaveWarningMessage =
|
||||
'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
|
||||
|
||||
useEffect(() => {
|
||||
pendingBunnyVideoIdRef.current = pendingBunnyVideoId;
|
||||
}, [pendingBunnyVideoId]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingBunnyUploadTokenRef.current = pendingBunnyUploadToken;
|
||||
}, [pendingBunnyUploadToken]);
|
||||
|
||||
const cleanupPendingBunnyVideo = useCallback(
|
||||
async (videoId: string, uploadToken: string, keepalive = false) => {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId, uploadToken }),
|
||||
keepalive,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup pending Bunny upload:', error);
|
||||
} finally {
|
||||
if (pendingBunnyVideoIdRef.current === videoId) {
|
||||
pendingBunnyVideoIdRef.current = null;
|
||||
setPendingBunnyVideoId(null);
|
||||
}
|
||||
if (pendingBunnyUploadTokenRef.current === uploadToken) {
|
||||
pendingBunnyUploadTokenRef.current = null;
|
||||
setPendingBunnyUploadToken(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const abortAndCleanupPendingUpload = useCallback(
|
||||
(keepalive = false) => {
|
||||
const pendingVideoId = pendingBunnyVideoIdRef.current;
|
||||
const pendingUploadToken = pendingBunnyUploadTokenRef.current;
|
||||
if (!pendingVideoId || !pendingUploadToken) return;
|
||||
cancelRequestedRef.current = true;
|
||||
|
||||
if (activeTusUploadRef.current) {
|
||||
try {
|
||||
activeTusUploadRef.current.abort(true);
|
||||
void Promise.resolve(activeTusUploadRef.current.abort(false));
|
||||
} catch {
|
||||
// Ignore abort failures; we'll still attempt cleanup.
|
||||
// Ignore abort failures.
|
||||
} finally {
|
||||
activeTusUploadRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive);
|
||||
const pending = pendingUploadRef.current;
|
||||
if (pending) {
|
||||
void cleanupPendingProjectUpload(projectId, pending, keepalive);
|
||||
pendingUploadRef.current = null;
|
||||
}
|
||||
},
|
||||
[cleanupPendingBunnyVideo]
|
||||
[projectId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -161,9 +146,8 @@ export default function NewVideoPageClient({
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [abortAndCleanupPendingUpload, isUploadingFile]);
|
||||
}, [abortAndCleanupPendingUpload, isUploadingFile, leaveWarningMessage]);
|
||||
|
||||
// Auto-fetch metadata when a valid video source is detected
|
||||
useEffect(() => {
|
||||
if (!videoSource) return;
|
||||
|
||||
@@ -209,40 +193,69 @@ export default function NewVideoPageClient({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const addSelectedFiles = useCallback(
|
||||
(incoming: File[]) => {
|
||||
const validFiles: File[] = [];
|
||||
let invalidCount = 0;
|
||||
|
||||
for (const file of incoming) {
|
||||
if (!isVideoFile(file)) {
|
||||
setSubmitError('Please select a valid video file.');
|
||||
invalidCount += 1;
|
||||
continue;
|
||||
}
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
setSubmitError('Please select valid video files.');
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
|
||||
if (invalidCount > 0) {
|
||||
setSubmitError(
|
||||
`${invalidCount} file${invalidCount === 1 ? '' : 's'} skipped (not a video).`
|
||||
);
|
||||
} else {
|
||||
setSubmitError('');
|
||||
if (!formData.title) {
|
||||
// Strip extension from filename for default title
|
||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||
setFormData((prev) => ({ ...prev, title: nameWithoutExt }));
|
||||
}
|
||||
|
||||
setSelectedFiles((prev) => {
|
||||
const next = [...prev];
|
||||
for (const file of validFiles) {
|
||||
const duplicate = next.some(
|
||||
(existing) =>
|
||||
existing.name === file.name &&
|
||||
existing.size === file.size &&
|
||||
existing.lastModified === file.lastModified
|
||||
);
|
||||
if (!duplicate) next.push(file);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (validFiles.length === 1 && !formData.title) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
title: getDefaultTitleFromFile(validFiles[0]),
|
||||
}));
|
||||
}
|
||||
},
|
||||
[formData.title, setSubmitError]
|
||||
);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length > 0) {
|
||||
addSelectedFiles(files);
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const setSelectedVideoFile = useCallback(
|
||||
(file: File) => {
|
||||
if (!isVideoFile(file)) {
|
||||
setSubmitError('Please select a valid video file.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
setSubmitError('');
|
||||
|
||||
if (!formData.title) {
|
||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||
setFormData((prev) => ({ ...prev, title: nameWithoutExt }));
|
||||
}
|
||||
},
|
||||
[formData.title]
|
||||
);
|
||||
const removeSelectedFile = (index: number) => {
|
||||
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleFileDragEnter = useCallback(
|
||||
(event: React.DragEvent<HTMLLabelElement>) => {
|
||||
@@ -283,83 +296,106 @@ export default function NewVideoPageClient({
|
||||
setIsFileDragOver(false);
|
||||
if (isLoading) return;
|
||||
|
||||
const file = Array.from(event.dataTransfer.files)[0];
|
||||
if (!file) return;
|
||||
setSelectedVideoFile(file);
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
addSelectedFiles(files);
|
||||
},
|
||||
[isLoading, setSelectedVideoFile]
|
||||
[addSelectedFiles, isLoading]
|
||||
);
|
||||
|
||||
const uploadToBunny = async (
|
||||
file: File
|
||||
): Promise<{
|
||||
videoId: string;
|
||||
libraryId: string;
|
||||
providerId: string;
|
||||
url: string;
|
||||
uploadToken: string;
|
||||
}> => {
|
||||
// 1. Initialize Bunny Stream upload (creates video & gets signature)
|
||||
setUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: formData.title || file.name }),
|
||||
const uploadSingleFileWithForm = async (file: File) => {
|
||||
cancelRequestedRef.current = false;
|
||||
pendingUploadRef.current = null;
|
||||
|
||||
const title = formData.title.trim() || getDefaultTitleFromFile(file);
|
||||
const description = formData.description.trim() || null;
|
||||
|
||||
await uploadProjectVideo(projectId, file, {
|
||||
provider: directUploadProvider,
|
||||
title,
|
||||
description,
|
||||
bunnyCdnHostname,
|
||||
onProgress: (progress) => {
|
||||
setUploadProgress(progress);
|
||||
setUploadStatus(`Uploading... ${progress}%`);
|
||||
},
|
||||
onStatus: setUploadStatus,
|
||||
onTusUploadReady: (upload) => {
|
||||
activeTusUploadRef.current = upload;
|
||||
},
|
||||
onPendingUpload: (pending) => {
|
||||
pendingUploadRef.current = pending;
|
||||
},
|
||||
isCancelled: () => cancelRequestedRef.current,
|
||||
});
|
||||
|
||||
if (!initRes.ok) {
|
||||
const data = await initRes.json();
|
||||
throw new Error(data.error || 'Failed to initialize upload');
|
||||
pendingUploadRef.current = null;
|
||||
activeTusUploadRef.current = null;
|
||||
};
|
||||
|
||||
const uploadMultipleFiles = async (files: File[]) => {
|
||||
cancelRequestedRef.current = false;
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
if (cancelRequestedRef.current) break;
|
||||
|
||||
const file = files[index];
|
||||
setCurrentUploadIndex(index + 1);
|
||||
setUploadProgress(0);
|
||||
setUploadStatus(`Uploading ${index + 1} of ${files.length}: ${file.name}`);
|
||||
|
||||
try {
|
||||
await uploadProjectVideo(projectId, file, {
|
||||
provider: directUploadProvider,
|
||||
bunnyCdnHostname,
|
||||
onProgress: (progress) => {
|
||||
setUploadProgress(progress);
|
||||
setUploadStatus(
|
||||
`Uploading ${index + 1} of ${files.length}: ${file.name} (${progress}%)`
|
||||
);
|
||||
},
|
||||
onStatus: (status) => {
|
||||
setUploadStatus(`Uploading ${index + 1} of ${files.length}: ${status}`);
|
||||
},
|
||||
onTusUploadReady: (upload) => {
|
||||
activeTusUploadRef.current = upload;
|
||||
},
|
||||
onPendingUpload: (pending) => {
|
||||
pendingUploadRef.current = pending;
|
||||
},
|
||||
isCancelled: () => cancelRequestedRef.current,
|
||||
});
|
||||
|
||||
pendingUploadRef.current = null;
|
||||
activeTusUploadRef.current = null;
|
||||
successCount += 1;
|
||||
} catch (error) {
|
||||
pendingUploadRef.current = null;
|
||||
activeTusUploadRef.current = null;
|
||||
failCount += 1;
|
||||
const message = error instanceof Error ? error.message : 'Upload failed';
|
||||
setSubmitError(`${file.name}: ${message}`, error);
|
||||
setUploadStatus('');
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
data: { videoId, libraryId, signature, expirationTime, uploadToken },
|
||||
} = await initRes.json();
|
||||
setPendingBunnyVideoId(videoId);
|
||||
setPendingBunnyUploadToken(uploadToken);
|
||||
pendingBunnyVideoIdRef.current = videoId;
|
||||
pendingBunnyUploadTokenRef.current = uploadToken;
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
router.push(`/projects/${projectId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Upload via TUS
|
||||
return new Promise((resolve, reject) => {
|
||||
setUploadStatus('Uploading video...');
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
AuthorizationSignature: signature,
|
||||
AuthorizationExpire: expirationTime.toString(),
|
||||
VideoId: videoId,
|
||||
LibraryId: libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: file.type,
|
||||
title: formData.title || file.name,
|
||||
},
|
||||
onError: (error) => {
|
||||
activeTusUploadRef.current = null;
|
||||
reject(new Error('Upload failed: ' + error.message));
|
||||
},
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
||||
setUploadProgress(Number(percentage));
|
||||
setUploadStatus(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
activeTusUploadRef.current = null;
|
||||
setUploadStatus('Processing video...');
|
||||
resolve({
|
||||
videoId,
|
||||
libraryId,
|
||||
providerId: 'bunny',
|
||||
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`,
|
||||
uploadToken,
|
||||
});
|
||||
},
|
||||
});
|
||||
activeTusUploadRef.current = upload;
|
||||
upload.start();
|
||||
});
|
||||
if (successCount > 0 && failCount > 0) {
|
||||
setSubmitError(
|
||||
`${successCount} uploaded, ${failCount} failed. Remove failed files and retry.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (failCount > 0 && successCount === 0) {
|
||||
throw new Error('All uploads failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -369,98 +405,72 @@ export default function NewVideoPageClient({
|
||||
setSubmitError('');
|
||||
setUploadStatus('');
|
||||
setUploadProgress(0);
|
||||
setCurrentUploadIndex(0);
|
||||
|
||||
try {
|
||||
let uploadedBunnyVideoId: string | null = null;
|
||||
let uploadedBunnyUploadToken: string | null = null;
|
||||
let finalTitle = formData.title.trim();
|
||||
const finalDescription = formData.description.trim() || null;
|
||||
let finalVideoUrl = '';
|
||||
let finalProviderId = '';
|
||||
let finalVideoId = '';
|
||||
let finalThumbnailUrl: string | null = null;
|
||||
let finalDuration: number | null = null;
|
||||
|
||||
if (uploadMode === 'url') {
|
||||
if (!videoSource) {
|
||||
setUrlError('Please enter a valid video URL');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
finalTitle = finalTitle || videoSource.metadata?.title || 'Untitled Video';
|
||||
finalVideoUrl = videoSource.originalUrl;
|
||||
finalProviderId = videoSource.providerId;
|
||||
finalVideoId = videoSource.videoId;
|
||||
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);
|
||||
return;
|
||||
}
|
||||
finalTitle = finalTitle || selectedFile.name;
|
||||
const finalTitle = formData.title.trim() || videoSource.metadata?.title || 'Untitled Video';
|
||||
const finalDescription = formData.description.trim() || null;
|
||||
|
||||
// Handle TUS Upload
|
||||
const bunnyData = await uploadToBunny(selectedFile);
|
||||
uploadedBunnyVideoId = bunnyData.videoId;
|
||||
uploadedBunnyUploadToken = bunnyData.uploadToken;
|
||||
|
||||
finalVideoUrl = bunnyData.url;
|
||||
finalProviderId = bunnyData.providerId;
|
||||
finalVideoId = bunnyData.videoId;
|
||||
// Bunny will generate thumbnails automatically after processing.
|
||||
// We'll just provide the standard CDN thumbnail URL format as fallback.
|
||||
finalThumbnailUrl = bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyData.videoId}/thumbnail.jpg`
|
||||
: null;
|
||||
}
|
||||
|
||||
// Final POST to our database
|
||||
const response = await fetch(`/api/projects/${projectId}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: finalTitle,
|
||||
description: finalDescription,
|
||||
videoUrl: finalVideoUrl,
|
||||
providerId: finalProviderId,
|
||||
videoId: finalVideoId,
|
||||
thumbnailUrl: finalThumbnailUrl,
|
||||
duration: finalDuration,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
videoUrl: videoSource.originalUrl,
|
||||
providerId: videoSource.providerId,
|
||||
videoId: videoSource.videoId,
|
||||
thumbnailUrl: getThumbnailUrl(videoSource, 'large'),
|
||||
duration: videoSource.metadata?.duration || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setSubmitError(data.error || 'Failed to add video');
|
||||
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
|
||||
await cleanupPendingBunnyVideo(uploadedBunnyVideoId, uploadedBunnyUploadToken);
|
||||
}
|
||||
setSubmitError(data.error || 'Failed to add video', data);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingBunnyVideoIdRef.current = null;
|
||||
pendingBunnyUploadTokenRef.current = null;
|
||||
setPendingBunnyVideoId(null);
|
||||
setPendingBunnyUploadToken(null);
|
||||
router.push(`/projects/${projectId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!directUploadsEnabled) {
|
||||
throw new Error('Direct uploads are disabled by this host');
|
||||
}
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
setSubmitError('Please select at least one video file to upload');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFiles.length === 1) {
|
||||
await uploadSingleFileWithForm(selectedFiles[0]);
|
||||
router.push(`/projects/${projectId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await uploadMultipleFiles(selectedFiles);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to add video:', error);
|
||||
setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||
if (pendingBunnyVideoIdRef.current && pendingBunnyUploadTokenRef.current) {
|
||||
await cleanupPendingBunnyVideo(
|
||||
pendingBunnyVideoIdRef.current,
|
||||
pendingBunnyUploadTokenRef.current
|
||||
setSubmitError(
|
||||
error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||
error
|
||||
);
|
||||
}
|
||||
// Cleared on the failure path too. Leaving it set showed the error above a stale
|
||||
// "Initializing upload...", so the form claimed to be doing both at once.
|
||||
setUploadStatus('');
|
||||
} finally {
|
||||
activeTusUploadRef.current = null;
|
||||
pendingUploadRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -492,8 +502,8 @@ export default function NewVideoPageClient({
|
||||
<CardHeader>
|
||||
<CardTitle>Add Video</CardTitle>
|
||||
<CardDescription>
|
||||
{bunnyUploadsEnabled
|
||||
? 'Paste a video link or upload a file directly to add it to your project. Currently supports YouTube.'
|
||||
{directUploadsEnabled
|
||||
? 'Paste a video link or upload one or more files directly to add them to your project.'
|
||||
: 'Paste a video link to add it to your project. Direct uploads are disabled on this host.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -504,12 +514,12 @@ export default function NewVideoPageClient({
|
||||
className="mb-6"
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
className={`grid w-full ${directUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
<TabsTrigger value="url" disabled={isLoading}>
|
||||
Paste URL
|
||||
</TabsTrigger>
|
||||
{bunnyUploadsEnabled ? (
|
||||
{directUploadsEnabled ? (
|
||||
<TabsTrigger value="file" disabled={isLoading}>
|
||||
Direct Upload
|
||||
</TabsTrigger>
|
||||
@@ -553,7 +563,7 @@ export default function NewVideoPageClient({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file">Video File</Label>
|
||||
<Label htmlFor="file">Video Files</Label>
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<label
|
||||
htmlFor="file"
|
||||
@@ -561,49 +571,92 @@ export default function NewVideoPageClient({
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
className={`flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
|
||||
className={`flex flex-col items-center justify-center w-full min-h-40 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
|
||||
isFileDragOver
|
||||
? 'border-primary bg-primary/10'
|
||||
: selectedFile
|
||||
: selectedFiles.length > 0
|
||||
? 'border-primary bg-muted/30 hover:bg-muted/50'
|
||||
: 'border-border bg-muted/30 hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
{selectedFile ? (
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6 px-4 w-full">
|
||||
{selectedFiles.length === 0 ? (
|
||||
<>
|
||||
<UploadCloud className="w-10 h-10 mb-3 text-muted-foreground" />
|
||||
<p className="mb-2 text-sm text-muted-foreground text-center">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Multiple videos supported · MP4, WebM, MOV, and more
|
||||
</p>
|
||||
</>
|
||||
) : selectedFiles.length === 1 ? (
|
||||
<>
|
||||
<FileVideo className="w-10 h-10 mb-3 text-primary" />
|
||||
<p className="mb-2 text-sm text-foreground font-medium">
|
||||
{selectedFile.name}
|
||||
{selectedFiles[0].name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
|
||||
{(selectedFiles[0].size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="w-10 h-10 mb-3 text-muted-foreground" />
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
<FileVideo className="w-10 h-10 mb-3 text-primary" />
|
||||
<p className="mb-2 text-sm text-foreground font-medium">
|
||||
{selectedFiles.length} videos selected
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click or drop to add more files
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="file"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedFiles.length > 1 && (
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
|
||||
className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<FileVideo className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{file.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{getDefaultTitleFromFile(file)} ·{' '}
|
||||
{(file.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
disabled={isLoading}
|
||||
onClick={() => removeSelectedFile(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Preview (Only for URL mode) */}
|
||||
{uploadMode === 'url' && thumbnailUrl && videoSource && (
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
@@ -619,7 +672,8 @@ export default function NewVideoPageClient({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{uploadMode === 'url' || selectedFiles.length <= 1 ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
@@ -627,34 +681,60 @@ export default function NewVideoPageClient({
|
||||
placeholder={
|
||||
isFetchingMeta
|
||||
? 'Fetching title...'
|
||||
: uploadMode === 'file' && isMultiFileUpload
|
||||
? 'Not used for multi-file uploads'
|
||||
: 'Video title (will auto-fill from video if empty)'
|
||||
}
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || (uploadMode === 'file' && isMultiFileUpload)}
|
||||
/>
|
||||
{uploadMode === 'file' && isMultiFileUpload ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Each file will use its filename as the title.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the original video title
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Add context about this video..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || (uploadMode === 'file' && isMultiFileUpload)}
|
||||
/>
|
||||
{uploadMode === 'file' && isMultiFileUpload ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Descriptions are not applied in bulk upload mode.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{submitError && (
|
||||
<p className="text-sm text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<p className="text-sm text-destructive flex items-start gap-1">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{submitError}
|
||||
{submitErrorIsTrialLimit && (
|
||||
<Link
|
||||
href="/settings"
|
||||
className="ml-1 font-medium underline underline-offset-2"
|
||||
>
|
||||
Upgrade
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -671,7 +751,10 @@ export default function NewVideoPageClient({
|
||||
)}
|
||||
{isUploadingFile && (
|
||||
<p className="text-xs text-amber-500">
|
||||
Do not close, refresh, or navigate away while the upload is in progress.
|
||||
Do not close, refresh, or navigate away while uploads are in progress.
|
||||
{isMultiFileUpload && currentUploadIndex > 0
|
||||
? ` (${currentUploadIndex} of ${selectedFiles.length})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -683,11 +766,13 @@ export default function NewVideoPageClient({
|
||||
disabled={
|
||||
isLoading ||
|
||||
(uploadMode === 'url' && !videoSource) ||
|
||||
(uploadMode === 'file' && !selectedFile)
|
||||
(uploadMode === 'file' && selectedFiles.length === 0)
|
||||
}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Add Video
|
||||
{uploadMode === 'file' && selectedFiles.length > 1
|
||||
? `Upload ${selectedFiles.length} Videos`
|
||||
: 'Add Video'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requireProjectAccessOrRedirect } from '@/lib/route-access';
|
||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
import NewVideoPageClient from './new-video-page-client';
|
||||
|
||||
interface NewVideoPageProps {
|
||||
@@ -14,5 +14,11 @@ export default async function NewVideoPage({ params }: NewVideoPageProps) {
|
||||
intent: 'manage',
|
||||
});
|
||||
|
||||
return <NewVideoPageClient projectId={projectId} bunnyUploadsEnabled={isBunnyUploadsEnabled()} />;
|
||||
return (
|
||||
<NewVideoPageClient
|
||||
projectId={projectId}
|
||||
directUploadsEnabled={isDirectFileUploadEnabled()}
|
||||
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,9 +53,10 @@ interface BillingOverview {
|
||||
status: string;
|
||||
label: string;
|
||||
hasActiveSubscription: boolean;
|
||||
hasRecoverableSubscription: boolean;
|
||||
hasActiveTrial: boolean;
|
||||
hasBillingAccess: boolean;
|
||||
isTrialEligible: boolean;
|
||||
isPaid: boolean;
|
||||
priceId: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
@@ -76,6 +77,8 @@ interface StorageInfo {
|
||||
usedBytes: string;
|
||||
limitBytes: string;
|
||||
percentage: number;
|
||||
/** False on the free trial, where the way out is subscribing rather than deleting. */
|
||||
isPaid: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytesStr: string): string {
|
||||
@@ -364,14 +367,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
) : (
|
||||
<>
|
||||
{!billing.subscription.hasActiveSubscription &&
|
||||
!billing.subscription.hasActiveTrial &&
|
||||
billing.subscription.isTrialEligible &&
|
||||
billing.checkoutAvailable ? (
|
||||
billing.subscription.hasActiveTrial &&
|
||||
billing.subscription.trialEndsAt ? (
|
||||
<div className="rounded-md border border-primary/30 bg-primary/5 p-4 space-y-2">
|
||||
<p className="text-sm font-semibold">Start your 7-day free trial</p>
|
||||
<p className="text-sm font-semibold">
|
||||
Your free trial runs until{' '}
|
||||
{new Date(billing.subscription.trialEndsAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get full access to all features — no charge until the trial ends. Cancel
|
||||
anytime.
|
||||
Every feature is on and no card is on file. The trial covers one workspace and
|
||||
one project. Subscribing starts your paid month straight away, so there is no
|
||||
reason to do it before you are ready.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -387,9 +393,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
: 'Subscription canceled. Access remains active until the end of the current billing period.'
|
||||
: 'Paid account with workspace creation unlocked.'
|
||||
: billing.subscription.hasActiveTrial
|
||||
? 'Trial access is active.'
|
||||
: billing.subscription.isTrialEligible
|
||||
? "You haven't started your free trial yet."
|
||||
? 'Free trial, no card required.'
|
||||
: 'Billing access has ended.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -400,6 +404,14 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{billing.subscription.hasRecoverableSubscription &&
|
||||
!billing.subscription.hasActiveSubscription ? (
|
||||
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
||||
Your latest payment didn't go through. Update your payment method to keep
|
||||
your subscription — starting a new one would create a duplicate.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{billing.subscription.hasActiveTrial &&
|
||||
billing.subscription.trialEndsAt &&
|
||||
hasScheduledCancellation ? (
|
||||
@@ -424,13 +436,15 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Deliberately not conditioned on `billingAccessEndedAt`: an account that
|
||||
only ever had the cardless trial never gets one written, and it is exactly
|
||||
that account which most needs to be told its work is still recoverable. */}
|
||||
{!billing.subscription.hasBillingAccess &&
|
||||
billing.subscription.billingAccessEndedAt &&
|
||||
billing.subscription.storageCleanupEligibleAt ? (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Stored media cleanup is scheduled after{' '}
|
||||
{new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()}{' '}
|
||||
unless billing is restored first.
|
||||
Nothing has been deleted. Your projects and media are kept until{' '}
|
||||
{new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()};
|
||||
subscribe before then and everything is where you left it.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -444,7 +458,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{billing.subscription.hasActiveSubscription && billing.portalAvailable ? (
|
||||
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (
|
||||
<Button
|
||||
onClick={() => handleBillingRedirect('/api/billing/portal')}
|
||||
disabled={billingAction !== null}
|
||||
@@ -454,8 +468,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Opening Portal...
|
||||
</>
|
||||
) : (
|
||||
) : billing.subscription.hasActiveSubscription ? (
|
||||
'Manage Subscription'
|
||||
) : (
|
||||
'Update Payment Method'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -468,8 +484,6 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Redirecting...
|
||||
</>
|
||||
) : billing.subscription.isTrialEligible ? (
|
||||
'Start Free Trial'
|
||||
) : (
|
||||
'Upgrade with Stripe'
|
||||
)}
|
||||
@@ -489,7 +503,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
Storage
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Combined usage across video files and media attachments (200 GB limit)
|
||||
Combined usage across video files and media attachments
|
||||
{storageInfo ? ` (${formatBytes(storageInfo.limitBytes)} limit)` : ''}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@@ -529,11 +544,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
{storageInfo.percentage >= 90 && (
|
||||
{storageInfo.percentage >= 90 &&
|
||||
(storageInfo.isPaid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Storage is almost full. Delete unused files or contact support.
|
||||
</p>
|
||||
)}
|
||||
) : (
|
||||
<p className="text-xs text-destructive">
|
||||
Your free trial storage is almost full. Subscribe above for more room, or
|
||||
delete unused files.
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { auth, checkWorkspaceAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
|
||||
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
function VisibilityIcon({ visibility }: { visibility: string }) {
|
||||
switch (visibility) {
|
||||
@@ -108,7 +109,8 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
|
||||
<div className="px-6 lg:px-8 py-8 w-full">
|
||||
<VideoDragDropUploader
|
||||
workspaceId={workspaceId}
|
||||
canUpload={isAdmin && workspace._count.projects > 0}
|
||||
canUpload={isAdmin && workspace._count.projects > 0 && isDirectFileUploadEnabled()}
|
||||
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
|
||||
/>
|
||||
{/* Back & Header */}
|
||||
<div className="mb-6">
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { after } from 'next/server';
|
||||
import { ComparisonPage } from '@/components/marketing/comparison-page';
|
||||
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { comparisonPages, getComparisonPage } from '@/lib/marketing/comparison-pages';
|
||||
import { buildComparisonJsonLd, buildComparisonMetadata } from '@/lib/marketing/metadata';
|
||||
|
||||
interface MarketingSlugPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return comparisonPages.map((page) => ({ slug: page.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: MarketingSlugPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const page = getComparisonPage(slug);
|
||||
|
||||
if (!page) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return buildComparisonMetadata({
|
||||
title: page.title,
|
||||
description: page.metaDescription,
|
||||
path: `/${page.slug}`,
|
||||
keywords: page.keywords,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function MarketingSlugPage({ params }: MarketingSlugPageProps) {
|
||||
const { slug } = await params;
|
||||
const page = getComparisonPage(slug);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const isLoggedIn = Boolean(session?.user);
|
||||
|
||||
// A comparison page is a landing page: for most of these visitors it is the
|
||||
// first thing they see, so it belongs in the same visitor count as `/`.
|
||||
if (!isLoggedIn) {
|
||||
const visitor = await readPageVisitor();
|
||||
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
|
||||
}
|
||||
|
||||
const structuredData = buildComparisonJsonLd({
|
||||
title: page.title,
|
||||
description: page.metaDescription,
|
||||
path: `/${page.slug}`,
|
||||
faq: page.faq,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{/* One script per object: single-object payloads with a top-level
|
||||
@context survive naive JSON-LD consumers that choke on arrays. */}
|
||||
{structuredData.map((data, index) => (
|
||||
<script
|
||||
key={`${String(data['@type'])}-${index}`}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(data).replace(/</g, '\\u003c'),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<ComparisonPage page={page} isLoggedIn={isLoggedIn} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
AT_RISK_SILENT_DAYS,
|
||||
conversionRates,
|
||||
getScoreboard,
|
||||
type FunnelRates,
|
||||
} from '@/lib/analytics/scoreboard';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
import { AlertTriangle, CreditCard, TrendingUp, Users } from 'lucide-react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Growth | OpenFrame',
|
||||
description: 'Acquisition funnel and retention scoreboard',
|
||||
};
|
||||
|
||||
function formatMoney(cents: number | null, currency: string) {
|
||||
if (cents === null) return '—';
|
||||
const safeCurrency = /^[a-zA-Z]{3}$/.test(currency) ? currency.toUpperCase() : 'USD';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: safeCurrency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatWeek(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
return date ? date.toISOString().slice(0, 10) : 'never';
|
||||
}
|
||||
|
||||
/** A percentage with the count it was computed from, because n matters here. */
|
||||
function Rate({ rate, of }: { rate: number | null; of: number }) {
|
||||
if (rate === null) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span>
|
||||
{Math.round(rate * 100)}%<span className="text-muted-foreground"> /{of}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const WEEK_COLUMNS: Array<{ key: string; label: string }> = [
|
||||
{ key: 'visitors', label: 'Visitors' },
|
||||
{ key: 'signups', label: 'Signup' },
|
||||
{ key: 'firstVideo', label: 'Video' },
|
||||
{ key: 'shareLinks', label: 'Share link' },
|
||||
{ key: 'externalFeedback', label: 'Ext. feedback' },
|
||||
{ key: 'trials', label: 'Trial' },
|
||||
{ key: 'newPaid', label: 'New paid' },
|
||||
{ key: 'canceled', label: 'Canceled' },
|
||||
{ key: 'activePaid', label: 'Active paid' },
|
||||
];
|
||||
|
||||
export default async function AdminGrowthPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
if (!isProductAnalyticsEnabled()) {
|
||||
return (
|
||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Growth</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
Acquisition tracking is off on this deployment. Set{' '}
|
||||
<code className="font-mono">OPENFRAME_ENABLE_ANALYTICS=true</code> to start recording
|
||||
the funnel. Nothing is collected until you do, and nothing is ever sent anywhere but
|
||||
this instance's own database.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const scoreboard = await getScoreboard();
|
||||
const latest = scoreboard.weeks[scoreboard.weeks.length - 1];
|
||||
const window = scoreboard.weeks.reduce(
|
||||
(sum, week) => ({
|
||||
visitors: sum.visitors + week.visitors,
|
||||
signups: sum.signups + week.signups,
|
||||
firstVideo: sum.firstVideo + week.firstVideo,
|
||||
shareLinks: sum.shareLinks + week.shareLinks,
|
||||
externalFeedback: sum.externalFeedback + week.externalFeedback,
|
||||
trials: sum.trials + week.trials,
|
||||
newPaid: sum.newPaid + week.newPaid,
|
||||
}),
|
||||
{
|
||||
visitors: 0,
|
||||
signups: 0,
|
||||
firstVideo: 0,
|
||||
shareLinks: 0,
|
||||
externalFeedback: 0,
|
||||
trials: 0,
|
||||
newPaid: 0,
|
||||
}
|
||||
);
|
||||
const overall: FunnelRates = conversionRates(window);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Growth</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active paid</CardTitle>
|
||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{scoreboard.currentActivePaid ?? '—'}</div>
|
||||
<p className="text-xs text-muted-foreground">from Stripe, right now</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">MRR</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatMoney(scoreboard.currentMrrCents, scoreboard.currency)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">from Stripe, right now</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Visitors this week</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{latest?.visitors ?? 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{latest ? `week of ${formatWeek(latest.weekStart)}` : 'no data yet'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">At risk</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{scoreboard.atRisk.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
paid, silent for {AT_RISK_SILENT_DAYS}+ days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Weekly funnel</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Weeks start Monday, UTC. Active paid is the running net of subscriptions started minus
|
||||
canceled, so it can drift from the Stripe figure above; the difference is the drift.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">Week</th>
|
||||
{WEEK_COLUMNS.map((column) => (
|
||||
<th key={column.key} className="py-2 pr-4 text-right font-medium">
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 pr-4 text-right font-medium">MRR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scoreboard.weeks.map((week) => (
|
||||
<tr key={week.weekStart.toISOString()} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 font-mono text-xs">{formatWeek(week.weekStart)}</td>
|
||||
{WEEK_COLUMNS.map((column) => (
|
||||
<td key={column.key} className="py-2 pr-4 text-right tabular-nums">
|
||||
{week[column.key as keyof typeof week] as number}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-2 pr-4 text-right tabular-nums">
|
||||
{formatMoney(week.mrrCents, scoreboard.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Where it narrows</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every step over the whole {scoreboard.weeks.length}-week window, with the denominator
|
||||
beside it. The lowest rate is the step to work on.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-3 lg:grid-cols-5 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Visitor to signup</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Rate rate={overall.visitorToSignup} of={window.visitors} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Signup to first video</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Rate rate={overall.signupToFirstVideo} of={window.signups} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Video to share link</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Rate rate={overall.firstVideoToShare} of={window.firstVideo} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Share to outside feedback</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Rate rate={overall.shareToFeedback} of={window.shareLinks} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Trial to paid</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Rate rate={overall.trialToPaid} of={window.trials} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{scoreboard.cohorts ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Card-first against cardless trial</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Accounts created in the {scoreboard.cohorts.windowDays} days either side of{' '}
|
||||
{formatDate(scoreboard.cohorts.cutover)}, each given{' '}
|
||||
{scoreboard.cohorts.observationDays} days from signup to convert. The rate to read is
|
||||
signup to paid: dropping the card requirement multiplies trials, so trial to paid can
|
||||
fall while more people pay.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">Cohort</th>
|
||||
<th className="py-2 pr-4 font-medium">Window</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Signup</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Trial</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Paid</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Signup to paid</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Trial to paid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scoreboard.cohorts.rows.map((row) => (
|
||||
<tr key={row.cohort} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4">
|
||||
{row.cohort === 'CARDLESS' ? 'cardless' : 'card first'}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
||||
{formatDate(row.windowStart)} to {formatDate(row.windowEnd)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.signups}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.trials}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.paid}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">
|
||||
<Rate
|
||||
rate={row.signups > 0 ? row.paid / row.signups : null}
|
||||
of={row.signups}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">
|
||||
<Rate rate={row.trials > 0 ? row.paid / row.trials : null} of={row.trials} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{scoreboard.cohorts.windowDays === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Nothing to compare yet. The first cardless signups reach the end of their{' '}
|
||||
{scoreboard.cohorts.observationDays}-day window {scoreboard.cohorts.observationDays}{' '}
|
||||
days after the switchover.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">By source</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rolling {scoreboard.channelWindowDays} days rather than one week: a weekly per-source
|
||||
cell holds single digits at this volume, and a percentage computed from three visits
|
||||
reads exactly as confidently as one computed from three hundred.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">Source</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Visitors</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Signup</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Trial</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Paid</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Visitor to signup</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scoreboard.channels.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-4 text-muted-foreground">
|
||||
Nothing recorded in this window yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{scoreboard.channels.map((row) => (
|
||||
<tr key={row.channel} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4">{row.channel.toLowerCase()}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.visitors}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.signups}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.trials}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.paid}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">
|
||||
<Rate
|
||||
rate={row.visitors > 0 ? row.signups / row.visitors : null}
|
||||
of={row.visitors}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Paid accounts</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Value events are videos, share links, outside feedback, approvals and projects. Rows
|
||||
marked at risk have produced none for {AT_RISK_SILENT_DAYS} days.
|
||||
{scoreboard.paidAccountsTruncated && (
|
||||
<>
|
||||
{' '}
|
||||
Quietest {scoreboard.paidAccountLimit} only; there are more paid accounts than this
|
||||
table shows.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">Account</th>
|
||||
<th className="py-2 pr-4 font-medium">Status</th>
|
||||
<th className="py-2 pr-4 font-medium">Source</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">7d</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">30d</th>
|
||||
<th className="py-2 pr-4 font-medium">Last activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scoreboard.paidAccounts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-4 text-muted-foreground">
|
||||
No active or trialing accounts.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{scoreboard.paidAccounts.map((account) => {
|
||||
const atRisk = scoreboard.atRisk.some((row) => row.userId === account.userId);
|
||||
return (
|
||||
<tr key={account.userId} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4">
|
||||
{account.name || account.email || account.userId}
|
||||
{atRisk && (
|
||||
<span className="ml-2 rounded bg-destructive/10 px-1.5 py-0.5 text-xs text-destructive">
|
||||
at risk
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">
|
||||
{account.status.toLowerCase()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">
|
||||
{account.channel?.toLowerCase() ?? '—'}
|
||||
{account.selfReported && account.selfReported !== account.channel && (
|
||||
<span className="text-xs">
|
||||
{' '}
|
||||
(said {account.selfReported.toLowerCase()})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{account.valueEvents7}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{account.valueEvents30}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
||||
{formatDate(account.lastValueEventAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+15
-1
@@ -2,7 +2,7 @@ import { redirect } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { Header } from '@/components/layout';
|
||||
import Link from 'next/link';
|
||||
import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react';
|
||||
import { LayoutDashboard, MessageSquareQuote, TrendingUp, Users } from 'lucide-react';
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
@@ -39,6 +39,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<MessageSquareQuote className="h-4 w-4" />
|
||||
Feedback
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/growth"
|
||||
className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"
|
||||
>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Growth
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
{/* Desktop Nav */}
|
||||
@@ -66,6 +73,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<MessageSquareQuote className="h-4 w-4" />
|
||||
Feedback
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/growth"
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Growth
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
+12
-1
@@ -82,7 +82,7 @@ export default async function AdminDashboardPage() {
|
||||
where: { voiceUrl: { not: null } },
|
||||
}),
|
||||
db.comment.count({
|
||||
where: { imageUrl: { not: null } },
|
||||
where: { images: { some: {} } },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -275,6 +275,17 @@ export default async function AdminDashboardPage() {
|
||||
<div className="text-2xl font-bold">{stripeStats.canceledUsers}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED, which belonged to none of the
|
||||
buckets above and so were counted nowhere. */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Unpaid or Incomplete</CardTitle>
|
||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stripeStats.otherStatusUsers}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
+384
-12
@@ -1,8 +1,15 @@
|
||||
import { Metadata } from 'next';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma, BillingSubscriptionStatus } from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
|
||||
import {
|
||||
buildBillingAccessWhereInput,
|
||||
buildEffectiveBillingStatusWhereInput,
|
||||
getBillingStatusLabel,
|
||||
getEffectiveBillingStatus,
|
||||
hasBillingAccess,
|
||||
} from '@/lib/billing';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
getCachedBunnyStorageStats,
|
||||
@@ -13,6 +20,8 @@ import {
|
||||
import { Film, HardDrive } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -24,12 +33,64 @@ import {
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
function getBillingStatusVariant(
|
||||
status: BillingSubscriptionStatus
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' {
|
||||
switch (status) {
|
||||
case BillingSubscriptionStatus.ACTIVE:
|
||||
return 'default';
|
||||
case BillingSubscriptionStatus.TRIALING:
|
||||
case BillingSubscriptionStatus.INCOMPLETE:
|
||||
return 'secondary';
|
||||
case BillingSubscriptionStatus.PAST_DUE:
|
||||
case BillingSubscriptionStatus.UNPAID:
|
||||
return 'destructive';
|
||||
case BillingSubscriptionStatus.CANCELED:
|
||||
case BillingSubscriptionStatus.INCOMPLETE_EXPIRED:
|
||||
return 'outline';
|
||||
case BillingSubscriptionStatus.FREE:
|
||||
default:
|
||||
return 'ghost';
|
||||
}
|
||||
}
|
||||
|
||||
function getOwnBillingAccess(
|
||||
user: {
|
||||
subscriptionStatus: BillingSubscriptionStatus;
|
||||
trialEndsAt: Date | null;
|
||||
stripeCurrentPeriodEnd: Date | null;
|
||||
stripeCancelAtPeriodEnd: boolean;
|
||||
stripeCancelAt: Date | null;
|
||||
billingAccessEndedAt: Date | null;
|
||||
},
|
||||
now: Date
|
||||
): { ownAccess: boolean; endsAt: Date | null } {
|
||||
if (!hasBillingAccess(user, now)) {
|
||||
return { ownAccess: false, endsAt: null };
|
||||
}
|
||||
|
||||
const isEnding =
|
||||
user.stripeCancelAtPeriodEnd || user.subscriptionStatus === BillingSubscriptionStatus.CANCELED;
|
||||
|
||||
let endsAt: Date | null = null;
|
||||
// The effective status, so a cardless trial (stored as FREE) still shows the
|
||||
// date its access runs out instead of an open-ended "Active access".
|
||||
if (getEffectiveBillingStatus(user, now) === BillingSubscriptionStatus.TRIALING) {
|
||||
endsAt = user.trialEndsAt;
|
||||
} else if (isEnding) {
|
||||
endsAt = user.stripeCurrentPeriodEnd ?? user.stripeCancelAt;
|
||||
}
|
||||
|
||||
return { ownAccess: true, endsAt };
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Manage Users | Admin',
|
||||
};
|
||||
|
||||
type SortBy =
|
||||
| 'user'
|
||||
| 'subscription'
|
||||
| 'joinedDate'
|
||||
| 'workspacesOwned'
|
||||
| 'invitedMembers'
|
||||
@@ -41,8 +102,47 @@ type SortBy =
|
||||
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
type StatusFilter = 'ALL' | BillingSubscriptionStatus;
|
||||
type AccessFilter = 'ALL' | 'ACTIVE' | 'NONE';
|
||||
|
||||
// Order the badges the way an admin scans them: paying first, problems next,
|
||||
// churned last.
|
||||
const STATUS_FILTERS: BillingSubscriptionStatus[] = [
|
||||
BillingSubscriptionStatus.ACTIVE,
|
||||
BillingSubscriptionStatus.TRIALING,
|
||||
BillingSubscriptionStatus.PAST_DUE,
|
||||
BillingSubscriptionStatus.UNPAID,
|
||||
BillingSubscriptionStatus.CANCELED,
|
||||
BillingSubscriptionStatus.INCOMPLETE,
|
||||
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
|
||||
BillingSubscriptionStatus.FREE,
|
||||
];
|
||||
|
||||
// Sorting by subscription happens in memory (see canSortInDb), so the order the
|
||||
// database would have used for the enum has to be spelled out. Same order as the
|
||||
// enum is declared in the schema.
|
||||
const STATUS_SORT_ORDER: BillingSubscriptionStatus[] = [
|
||||
BillingSubscriptionStatus.FREE,
|
||||
BillingSubscriptionStatus.TRIALING,
|
||||
BillingSubscriptionStatus.ACTIVE,
|
||||
BillingSubscriptionStatus.PAST_DUE,
|
||||
BillingSubscriptionStatus.CANCELED,
|
||||
BillingSubscriptionStatus.UNPAID,
|
||||
BillingSubscriptionStatus.INCOMPLETE,
|
||||
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
|
||||
];
|
||||
|
||||
const ACCESS_FILTERS: Array<{ value: AccessFilter; label: string }> = [
|
||||
{ value: 'ALL', label: 'All Access' },
|
||||
{ value: 'ACTIVE', label: 'Has Access' },
|
||||
{ value: 'NONE', label: 'No Access' },
|
||||
];
|
||||
|
||||
const MAX_QUERY_LENGTH = 120;
|
||||
|
||||
const SORTABLE_COLUMNS: SortBy[] = [
|
||||
'user',
|
||||
'subscription',
|
||||
'joinedDate',
|
||||
'workspacesOwned',
|
||||
'invitedMembers',
|
||||
@@ -71,6 +171,60 @@ function getDefaultSortDirection(sortBy: SortBy): SortDirection {
|
||||
return sortBy === 'user' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
function parseQuery(value: string | undefined): string {
|
||||
return (value ?? '').trim().slice(0, MAX_QUERY_LENGTH);
|
||||
}
|
||||
|
||||
function parseStatusFilter(value: string | undefined): StatusFilter {
|
||||
return STATUS_FILTERS.includes(value as BillingSubscriptionStatus)
|
||||
? (value as BillingSubscriptionStatus)
|
||||
: 'ALL';
|
||||
}
|
||||
|
||||
function parseAccessFilter(value: string | undefined): AccessFilter {
|
||||
return value === 'ACTIVE' || value === 'NONE' ? value : 'ALL';
|
||||
}
|
||||
|
||||
/**
|
||||
* User ids that have in-app access through somebody else's paid workspace or
|
||||
* project. Mirrors hasCollaboratorBillingBackedAccess in lib/route-access.ts,
|
||||
* but resolves every user in two queries instead of two queries per user, so
|
||||
* the same set can back both the table column and the access filter.
|
||||
*/
|
||||
async function getCollaboratorAccessUserIds(now: Date): Promise<Set<string>> {
|
||||
const payingOwner = buildBillingAccessWhereInput(now);
|
||||
|
||||
const [workspaces, projects] = await Promise.all([
|
||||
db.workspace.findMany({
|
||||
where: { owner: payingOwner },
|
||||
select: { ownerId: true, members: { select: { userId: true } } },
|
||||
}),
|
||||
db.project.findMany({
|
||||
where: { workspace: { owner: payingOwner } },
|
||||
select: {
|
||||
ownerId: true,
|
||||
members: { select: { userId: true } },
|
||||
workspace: { select: { members: { select: { userId: true } } } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const userIds = new Set<string>();
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
userIds.add(workspace.ownerId);
|
||||
for (const member of workspace.members) userIds.add(member.userId);
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
userIds.add(project.ownerId);
|
||||
for (const member of project.members) userIds.add(member.userId);
|
||||
for (const member of project.workspace.members) userIds.add(member.userId);
|
||||
}
|
||||
|
||||
return userIds;
|
||||
}
|
||||
|
||||
function getSortIndicator(
|
||||
column: SortBy,
|
||||
activeSortBy: SortBy,
|
||||
@@ -83,6 +237,9 @@ function getSortIndicator(
|
||||
function canSortInDb(sortBy: SortBy): boolean {
|
||||
return (
|
||||
sortBy === 'user' ||
|
||||
// 'subscription' is deliberately absent: it sorts on the effective status,
|
||||
// which lives on trialEndsAt as much as on the stored column, so a cardless
|
||||
// trial would otherwise sort among the free accounts it is not shown with.
|
||||
sortBy === 'joinedDate' ||
|
||||
sortBy === 'workspacesOwned' ||
|
||||
sortBy === 'projectsOwned' ||
|
||||
@@ -122,7 +279,14 @@ function getUsersOrderBy(
|
||||
export default async function AdminUsersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string; sortBy?: string; sortDirection?: string }>;
|
||||
searchParams: Promise<{
|
||||
page?: string;
|
||||
sortBy?: string;
|
||||
sortDirection?: string;
|
||||
q?: string;
|
||||
status?: string;
|
||||
access?: string;
|
||||
}>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) {
|
||||
@@ -139,15 +303,69 @@ export default async function AdminUsersPage({
|
||||
? resolvedSearchParams.sortDirection
|
||||
: getDefaultSortDirection(sortBy);
|
||||
const pageSize = 20;
|
||||
const [totalUsers, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats] =
|
||||
await Promise.all([
|
||||
const stripeBillingEnabled = isStripeBillingEnabled();
|
||||
const now = new Date();
|
||||
|
||||
const query = parseQuery(resolvedSearchParams?.q);
|
||||
// Subscription/access are only meaningful (and only rendered) when billing is on.
|
||||
const statusFilter: StatusFilter = stripeBillingEnabled
|
||||
? parseStatusFilter(resolvedSearchParams?.status)
|
||||
: 'ALL';
|
||||
const accessFilter: AccessFilter = stripeBillingEnabled
|
||||
? parseAccessFilter(resolvedSearchParams?.access)
|
||||
: 'ALL';
|
||||
const hasActiveFilters = Boolean(query) || statusFilter !== 'ALL' || accessFilter !== 'ALL';
|
||||
|
||||
// Access is not just the user's own subscription: a user with no billing of
|
||||
// their own still has access as a collaborator on a paying owner's workspace
|
||||
// or project (mirrors hasAppNavigationAccess in lib/route-access.ts).
|
||||
const collaboratorAccessUserIds = stripeBillingEnabled
|
||||
? await getCollaboratorAccessUserIds(now)
|
||||
: new Set<string>();
|
||||
|
||||
const filters: Prisma.UserWhereInput[] = [];
|
||||
|
||||
if (query) {
|
||||
filters.push({
|
||||
OR: [
|
||||
{ name: { contains: query, mode: 'insensitive' } },
|
||||
{ email: { contains: query, mode: 'insensitive' } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (statusFilter !== 'ALL') {
|
||||
filters.push(buildEffectiveBillingStatusWhereInput(statusFilter, now));
|
||||
}
|
||||
|
||||
if (accessFilter !== 'ALL') {
|
||||
const hasAccess: Prisma.UserWhereInput = {
|
||||
OR: [
|
||||
buildBillingAccessWhereInput(now),
|
||||
{ id: { in: Array.from(collaboratorAccessUserIds) } },
|
||||
],
|
||||
};
|
||||
filters.push(accessFilter === 'ACTIVE' ? hasAccess : { NOT: hasAccess });
|
||||
}
|
||||
|
||||
const where: Prisma.UserWhereInput = filters.length > 0 ? { AND: filters } : {};
|
||||
|
||||
const [
|
||||
totalUsers,
|
||||
matchingUsers,
|
||||
userStorage,
|
||||
userBunnyStorage,
|
||||
userDownloadEgress,
|
||||
bunnyStorageStats,
|
||||
] = await Promise.all([
|
||||
db.user.count(),
|
||||
db.user.count({ where }),
|
||||
getCachedUserMediaStorage(),
|
||||
getCachedUserBunnyStorage(),
|
||||
getCachedUserDownloadEgress(),
|
||||
getCachedBunnyStorageStats(),
|
||||
]);
|
||||
const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize));
|
||||
const totalPages = Math.max(1, Math.ceil(matchingUsers / pageSize));
|
||||
const page = Math.min(Math.max(1, requestedPage), totalPages);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
@@ -156,6 +374,12 @@ export default async function AdminUsersPage({
|
||||
name: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
subscriptionStatus: true,
|
||||
trialEndsAt: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
stripeCancelAtPeriodEnd: true,
|
||||
stripeCancelAt: true,
|
||||
billingAccessEndedAt: true,
|
||||
ownedWorkspaces: {
|
||||
select: {
|
||||
_count: {
|
||||
@@ -179,6 +403,13 @@ export default async function AdminUsersPage({
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
createdAt: Date;
|
||||
subscriptionStatus: BillingSubscriptionStatus;
|
||||
effectiveStatus: BillingSubscriptionStatus;
|
||||
trialEndsAt: Date | null;
|
||||
stripeCurrentPeriodEnd: Date | null;
|
||||
stripeCancelAtPeriodEnd: boolean;
|
||||
stripeCancelAt: Date | null;
|
||||
billingAccessEndedAt: Date | null;
|
||||
ownedWorkspaces: Array<{ _count: { members: number } }>;
|
||||
_count: { ownedWorkspaces: number; projects: number; comments: number };
|
||||
invitedMembersCount: number;
|
||||
@@ -189,6 +420,7 @@ export default async function AdminUsersPage({
|
||||
|
||||
if (canSortInDb(sortBy)) {
|
||||
const users = await db.user.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: getUsersOrderBy(sortBy, sortDirection),
|
||||
@@ -197,6 +429,7 @@ export default async function AdminUsersPage({
|
||||
|
||||
paginatedUsers = users.map((user) => ({
|
||||
...user,
|
||||
effectiveStatus: getEffectiveBillingStatus(user, now),
|
||||
invitedMembersCount: user.ownedWorkspaces.reduce(
|
||||
(total, workspace) => total + workspace._count.members,
|
||||
0
|
||||
@@ -206,10 +439,11 @@ export default async function AdminUsersPage({
|
||||
mediaStorageBytes: userStorage[user.id]?.total || 0,
|
||||
}));
|
||||
} else {
|
||||
const users = await db.user.findMany({ select });
|
||||
const users = await db.user.findMany({ where, select });
|
||||
|
||||
const usersWithMetrics = users.map((user) => ({
|
||||
...user,
|
||||
effectiveStatus: getEffectiveBillingStatus(user, now),
|
||||
invitedMembersCount: user.ownedWorkspaces.reduce(
|
||||
(total, workspace) => total + workspace._count.members,
|
||||
0
|
||||
@@ -222,7 +456,11 @@ export default async function AdminUsersPage({
|
||||
const sortedUsers = usersWithMetrics.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortBy === 'invitedMembers') {
|
||||
if (sortBy === 'subscription') {
|
||||
comparison =
|
||||
STATUS_SORT_ORDER.indexOf(a.effectiveStatus) -
|
||||
STATUS_SORT_ORDER.indexOf(b.effectiveStatus);
|
||||
} else if (sortBy === 'invitedMembers') {
|
||||
comparison = a.invitedMembersCount - b.invitedMembersCount;
|
||||
} else if (sortBy === 'bunnyUpload') {
|
||||
comparison = a.bunnyUploadBytes - b.bunnyUploadBytes;
|
||||
@@ -242,10 +480,26 @@ export default async function AdminUsersPage({
|
||||
paginatedUsers = sortedUsers.slice(skip, skip + pageSize);
|
||||
}
|
||||
|
||||
// Resolve the real in-app access for each user on the current page.
|
||||
const accessByUserId = new Map<string, { hasAppAccess: boolean; viaCollaboration: boolean }>();
|
||||
if (stripeBillingEnabled) {
|
||||
for (const user of paginatedUsers) {
|
||||
const { ownAccess } = getOwnBillingAccess(user, now);
|
||||
const hasAppAccess = ownAccess || collaboratorAccessUserIds.has(user.id);
|
||||
accessByUserId.set(user.id, {
|
||||
hasAppAccess,
|
||||
viaCollaboration: hasAppAccess && !ownAccess,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const buildUsersPageHref = (
|
||||
targetPage: number,
|
||||
targetSortBy: SortBy = sortBy,
|
||||
targetSortDirection: SortDirection = sortDirection
|
||||
targetSortDirection: SortDirection = sortDirection,
|
||||
targetQuery: string = query,
|
||||
targetStatus: StatusFilter = statusFilter,
|
||||
targetAccess: AccessFilter = accessFilter
|
||||
): string => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(targetPage),
|
||||
@@ -253,6 +507,10 @@ export default async function AdminUsersPage({
|
||||
sortDirection: targetSortDirection,
|
||||
});
|
||||
|
||||
if (targetQuery) params.set('q', targetQuery);
|
||||
if (targetStatus !== 'ALL') params.set('status', targetStatus);
|
||||
if (targetAccess !== 'ALL') params.set('access', targetAccess);
|
||||
|
||||
return `/admin/users?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -267,6 +525,14 @@ export default async function AdminUsersPage({
|
||||
return buildUsersPageHref(1, column, nextDirection);
|
||||
};
|
||||
|
||||
const buildStatusHref = (targetStatus: StatusFilter): string =>
|
||||
buildUsersPageHref(1, sortBy, sortDirection, query, targetStatus, accessFilter);
|
||||
|
||||
const buildAccessHref = (targetAccess: AccessFilter): string =>
|
||||
buildUsersPageHref(1, sortBy, sortDirection, query, statusFilter, targetAccess);
|
||||
|
||||
const clearFiltersHref = buildUsersPageHref(1, sortBy, sortDirection, '', 'ALL', 'ALL');
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
@@ -300,11 +566,74 @@ export default async function AdminUsersPage({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<form method="get" action="/admin/users" className="flex items-center gap-2">
|
||||
<input type="hidden" name="sortBy" value={sortBy} />
|
||||
<input type="hidden" name="sortDirection" value={sortDirection} />
|
||||
{statusFilter !== 'ALL' && <input type="hidden" name="status" value={statusFilter} />}
|
||||
{accessFilter !== 'ALL' && <input type="hidden" name="access" value={accessFilter} />}
|
||||
<Input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
maxLength={MAX_QUERY_LENGTH}
|
||||
placeholder="Search by name or email…"
|
||||
aria-label="Search users by name or email"
|
||||
className="w-72"
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={clearFiltersHref}>Clear filters</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stripeBillingEnabled && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant={statusFilter === 'ALL' ? 'default' : 'outline'} size="sm" asChild>
|
||||
<Link href={buildStatusHref('ALL')}>All Statuses</Link>
|
||||
</Button>
|
||||
{STATUS_FILTERS.map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={statusFilter === status ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href={buildStatusHref(status)}>{getBillingStatusLabel(status)}</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ACCESS_FILTERS.map((filter) => (
|
||||
<Button
|
||||
key={filter.value}
|
||||
variant={accessFilter === filter.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href={buildAccessHref(filter.value)}>{filter.label}</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Users</CardTitle>
|
||||
<CardDescription>
|
||||
A comprehensive list of all {totalUsers} users registered on the platform.
|
||||
{hasActiveFilters
|
||||
? `${matchingUsers} of ${totalUsers} users match the current filters.`
|
||||
: `A comprehensive list of all ${totalUsers} users registered on the platform.`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -323,6 +652,19 @@ export default async function AdminUsersPage({
|
||||
</span>
|
||||
</Link>
|
||||
</TableHead>
|
||||
{stripeBillingEnabled && (
|
||||
<TableHead>
|
||||
<Link
|
||||
href={buildSortHref('subscription')}
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
Subscription
|
||||
<span className="text-xs">
|
||||
{getSortIndicator('subscription', sortBy, sortDirection)}
|
||||
</span>
|
||||
</Link>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead>
|
||||
<Link
|
||||
href={buildSortHref('joinedDate')}
|
||||
@@ -416,8 +758,8 @@ export default async function AdminUsersPage({
|
||||
<TableBody>
|
||||
{paginatedUsers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="h-24 text-center">
|
||||
No users found.
|
||||
<TableCell colSpan={stripeBillingEnabled ? 10 : 9} className="h-24 text-center">
|
||||
{hasActiveFilters ? 'No users match these filters.' : 'No users found.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -429,6 +771,36 @@ export default async function AdminUsersPage({
|
||||
<span className="text-xs text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{stripeBillingEnabled &&
|
||||
(() => {
|
||||
const { endsAt } = getOwnBillingAccess(user, now);
|
||||
const access = accessByUserId.get(user.id) ?? {
|
||||
hasAppAccess: false,
|
||||
viaCollaboration: false,
|
||||
};
|
||||
return (
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Badge variant={getBillingStatusVariant(user.effectiveStatus)}>
|
||||
{getBillingStatusLabel(user.effectiveStatus)}
|
||||
</Badge>
|
||||
{access.hasAppAccess ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
Active access
|
||||
{access.viaCollaboration
|
||||
? ' · via team'
|
||||
: endsAt
|
||||
? ` · until ${format(new Date(endsAt), 'MMM dd')}`
|
||||
: ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No access</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})()}
|
||||
<TableCell>{format(new Date(user.createdAt), 'MMM dd, yyyy')}</TableCell>
|
||||
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
|
||||
<TableCell className="text-center">{user.invitedMembersCount}</TableCell>
|
||||
|
||||
@@ -105,8 +105,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] =
|
||||
await Promise.all([
|
||||
db.comment.findFirst({
|
||||
where: { imageUrl: url },
|
||||
db.commentImage.findFirst({
|
||||
where: { url },
|
||||
select: { id: true },
|
||||
}),
|
||||
userFeedbackDelegate.findFirst({
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { isAdminApiTokenRequest } from '@/lib/admin-api-token';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { conversionRates, getScoreboard } from '@/lib/analytics/scoreboard';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
// The same numbers /admin/growth renders, as JSON, so the Monday digest can pull
|
||||
// the scoreboard instead of somebody retyping it into a table.
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// A bearer token stands in for the admin session so the weekly digest can
|
||||
// read this without a browser. Unset by default, in which case the only way
|
||||
// in is still an admin session.
|
||||
if (!isAdminApiTokenRequest(request)) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
if (!session.user.isAdmin) {
|
||||
return apiErrors.forbidden('Admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
if (!isProductAnalyticsEnabled()) {
|
||||
return apiErrors.badRequest('Analytics are disabled by this host');
|
||||
}
|
||||
|
||||
const weeksParam = Number(request.nextUrl.searchParams.get('weeks'));
|
||||
const scoreboard = await getScoreboard({
|
||||
weeks: Number.isSafeInteger(weeksParam) && weeksParam > 0 ? weeksParam : undefined,
|
||||
});
|
||||
|
||||
const response = successResponse({
|
||||
...scoreboard,
|
||||
weeks: scoreboard.weeks.map((week) => ({ ...week, rates: conversionRates(week) })),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error building the growth scoreboard:', error);
|
||||
return apiErrors.internalError('Failed to build the scoreboard');
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
if (!approvalRequest) return apiErrors.notFound('Approval request');
|
||||
|
||||
const access = await checkProjectAccess(
|
||||
approvalRequest.version.video.project,
|
||||
session.user.id,
|
||||
{ intent: 'manage' }
|
||||
);
|
||||
const access = await checkProjectAccess(approvalRequest.version.video.project, session.user.id);
|
||||
const canCancel = approvalRequest.requestedById === session.user.id || access.canEdit;
|
||||
if (!canCancel) return apiErrors.forbidden('Access denied');
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { notifyUsers } from '@/lib/notifications';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
type RouteParams = { params: Promise<{ requestId: string }> };
|
||||
|
||||
@@ -157,10 +158,17 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
approver: { select: { id: true, name: true, email: true, image: true } },
|
||||
},
|
||||
},
|
||||
// Scalar fields only: the full version row carries BigInt sizeBytes,
|
||||
// which JSON.stringify rejects when serializing the response.
|
||||
version: {
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
versionLabel: true,
|
||||
video: {
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
project: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
@@ -195,6 +203,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
|
||||
if (updated.status === 'APPROVED') {
|
||||
await recordEvent({
|
||||
name: 'APPROVAL_COMPLETED',
|
||||
dedupeKey: eventKey('APPROVAL_COMPLETED', requestId),
|
||||
userId: approvalRequest.version.video.project.ownerId,
|
||||
});
|
||||
|
||||
notifyUsers([updated.requestedById], {
|
||||
type: 'approval_completed',
|
||||
projectName: updated.version.video.project.name,
|
||||
|
||||
@@ -15,7 +15,16 @@ import {
|
||||
createVerificationToken,
|
||||
isEmailVerificationEnabled,
|
||||
sendVerificationEmail,
|
||||
warnIfTrialsSkipVerification,
|
||||
} from '@/lib/email-verification';
|
||||
import {
|
||||
isDisposableEmailDomain,
|
||||
isValidEmailAddress,
|
||||
normalizeEmail,
|
||||
} from '@/lib/email-validation';
|
||||
import { startCardlessTrial } from '@/lib/billing';
|
||||
import { recordSignupCompleted } from '@/lib/analytics/signup';
|
||||
import { readRequestVisitor } from '@/lib/analytics/visitor';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -39,14 +48,19 @@ export async function POST(request: NextRequest) {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return apiErrors.badRequest('Email is required');
|
||||
}
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(normalizedEmail)) {
|
||||
if (!isValidEmailAddress(normalizedEmail)) {
|
||||
return apiErrors.validationError('Invalid email format');
|
||||
}
|
||||
|
||||
// Checked before the invitation branch reads its token, but only applied to
|
||||
// people signing themselves up: an invited collaborator was vouched for by a
|
||||
// paying customer, and refusing their address breaks that customer's review
|
||||
// rather than stopping anyone from farming trials.
|
||||
const isDisposableAddress = isDisposableEmailDomain(normalizedEmail);
|
||||
|
||||
// Allow registration via a valid invitation token OR global invite code.
|
||||
let invitationIsValid = false;
|
||||
let validatedInvitationToken: string | null = null;
|
||||
@@ -83,6 +97,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!invitationIsValid && isDisposableAddress) {
|
||||
return apiErrors.badRequest(
|
||||
'Please sign up with a permanent email address. Disposable mailboxes are not accepted.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!password || typeof password !== 'string' || password.length < 8 || password.length > 128) {
|
||||
return apiErrors.badRequest('Password must be between 8 and 128 characters');
|
||||
}
|
||||
@@ -132,10 +152,33 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ties the account to the first touch stored in this browser's cookie and
|
||||
// claims the visitor events that led here. Recorded after the invitation has
|
||||
// been accepted, so an account that gets rolled back never leaves a signup.
|
||||
await recordSignupCompleted({
|
||||
userId: user.id,
|
||||
visitor: await readRequestVisitor(request),
|
||||
});
|
||||
|
||||
// With SMTP configured the trial starts when the address is proven, not here.
|
||||
// Without it there is no verification step to hang the trial on and the
|
||||
// account is already marked verified above, so withholding the trial would
|
||||
// just lock the user out of an instance that has billing switched on.
|
||||
if (!emailVerificationRequired) {
|
||||
warnIfTrialsSkipVerification();
|
||||
await startCardlessTrial(user.id);
|
||||
}
|
||||
|
||||
// Send verification email if SMTP is configured
|
||||
if (emailVerificationRequired) {
|
||||
const verificationToken = await createVerificationToken(normalizedEmail);
|
||||
await sendVerificationEmail(normalizedEmail, verificationToken);
|
||||
// Invited users are sent back to the invitation after verifying, which forwards them
|
||||
// to the workspace/project they joined instead of the generic dashboard.
|
||||
await sendVerificationEmail(normalizedEmail, verificationToken, {
|
||||
next: validatedInvitationToken
|
||||
? `/invitations/accept?token=${encodeURIComponent(validatedInvitationToken)}`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const message = emailVerificationRequired
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sendVerificationEmail,
|
||||
} from '@/lib/email-verification';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -28,14 +29,13 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const { email } = body;
|
||||
|
||||
if (!email || typeof email !== 'string' || email.length > 254 || !email.includes('@')) {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return apiErrors.badRequest('Valid email is required');
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(normalizedEmail)) {
|
||||
if (!isValidEmailAddress(normalizedEmail)) {
|
||||
return apiErrors.badRequest('Valid email is required');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,19 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { consumeVerificationToken } from '@/lib/email-verification';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { getPublicOrigin } from '@/lib/request-origin';
|
||||
import { getSafeCallbackUrl } from '@/lib/safe-redirect';
|
||||
|
||||
// A raw 32-byte hex token is exactly 64 characters.
|
||||
const TOKEN_REGEX = /^[0-9a-f]{64}$/;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Redirect targets must be built from the public origin, not `request.url`:
|
||||
// behind a reverse proxy the latter is the container-internal address and the
|
||||
// user lands on a dead host even though verification succeeded.
|
||||
const origin = getPublicOrigin(request);
|
||||
const redirectTo = (path: string) => NextResponse.redirect(new URL(path, origin));
|
||||
|
||||
try {
|
||||
// Rate-limit by IP to prevent token enumeration attacks.
|
||||
const limited = await rateLimit(request, 'verify-email');
|
||||
@@ -15,18 +23,26 @@ export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get('token');
|
||||
|
||||
if (!token || !TOKEN_REGEX.test(token.trim())) {
|
||||
return NextResponse.redirect(new URL('/login?error=InvalidVerificationToken', request.url));
|
||||
return redirectTo('/login?error=InvalidVerificationToken');
|
||||
}
|
||||
|
||||
const email = await consumeVerificationToken(token.trim());
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.redirect(new URL('/login?error=InvalidVerificationToken', request.url));
|
||||
return redirectTo('/login?error=InvalidVerificationToken');
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL('/login?verified=true', request.url));
|
||||
// Keep the post-verification destination (e.g. an invitation) if one was carried along.
|
||||
const next = getSafeCallbackUrl(request.nextUrl.searchParams.get('next'), {
|
||||
origin,
|
||||
fallback: '',
|
||||
});
|
||||
|
||||
return redirectTo(
|
||||
next ? `/login?verified=true&callbackUrl=${encodeURIComponent(next)}` : '/login?verified=true'
|
||||
);
|
||||
} catch (err) {
|
||||
logError('Email verification error:', err);
|
||||
return NextResponse.redirect(new URL('/login?error=VerificationFailed', request.url));
|
||||
return redirectTo('/login?error=VerificationFailed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import {
|
||||
DEFAULT_TRIAL_PERIOD_DAYS,
|
||||
getOrCreateStripeCustomerId,
|
||||
getStripeCheckoutState,
|
||||
} from '@/lib/billing';
|
||||
import { getOrCreateStripeCustomerId, 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';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
function getAppOrigin(request: NextRequest) {
|
||||
if (isTrustedSameOriginRequest(request)) {
|
||||
@@ -46,8 +43,15 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const checkoutState = await getStripeCheckoutState(session.user.id);
|
||||
if (checkoutState.hasActiveSubscription) {
|
||||
return apiErrors.badRequest('An active subscription already exists for this account');
|
||||
// Block a fresh checkout whenever the customer already has a live subscription
|
||||
// (active/trialing OR a recoverable one like past_due/unpaid/incomplete).
|
||||
// Stripe Checkout in subscription mode always creates a NEW subscription, so
|
||||
// letting a past_due user through here duplicates their subscription instead
|
||||
// of recovering it. They should manage the existing one via the billing portal.
|
||||
if (checkoutState.hasRecoverableSubscription) {
|
||||
return apiErrors.badRequest(
|
||||
'A subscription already exists for this account. Manage it from the billing portal.'
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripe();
|
||||
@@ -65,11 +69,13 @@ export async function POST(request: NextRequest) {
|
||||
metadata: {
|
||||
userId: session.user.id,
|
||||
},
|
||||
// No trial here. The free trial is granted in the product when the email
|
||||
// address is verified, so by the time anyone reaches checkout they have
|
||||
// already had it and this subscription bills immediately.
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
userId: session.user.id,
|
||||
},
|
||||
...(checkoutState.isTrialEligible ? { trial_period_days: DEFAULT_TRIAL_PERIOD_DAYS } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -77,6 +83,15 @@ export async function POST(request: NextRequest) {
|
||||
throw new Error('Stripe did not return a checkout URL');
|
||||
}
|
||||
|
||||
// Keyed on the Stripe session, so an abandoned checkout followed by a second
|
||||
// attempt counts twice. That is the intent: the gap between checkouts started
|
||||
// and subscriptions started is the number worth watching.
|
||||
await recordEvent({
|
||||
name: 'CHECKOUT_STARTED',
|
||||
dedupeKey: eventKey('CHECKOUT_STARTED', checkoutSession.id),
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
const response = successResponse({ url: checkoutSession.url });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,15 +19,16 @@ export async function GET() {
|
||||
isEnabled,
|
||||
isConfigured,
|
||||
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
|
||||
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasActiveSubscription,
|
||||
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription,
|
||||
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
|
||||
subscription: {
|
||||
status: billing.subscription.status,
|
||||
label: billing.subscription.label,
|
||||
hasActiveSubscription: billing.subscription.hasActiveSubscription,
|
||||
hasRecoverableSubscription: billing.subscription.hasRecoverableSubscription,
|
||||
hasActiveTrial: billing.subscription.hasActiveTrial,
|
||||
hasBillingAccess: billing.subscription.hasBillingAccess,
|
||||
isTrialEligible: billing.subscription.isTrialEligible,
|
||||
isPaid: billing.subscription.isPaid,
|
||||
priceId: billing.subscription.stripePriceId,
|
||||
currentPeriodEnd: billing.subscription.currentPeriodEnd?.toISOString() ?? null,
|
||||
cancelAtPeriodEnd: billing.subscription.cancelAtPeriodEnd ?? false,
|
||||
|
||||
@@ -10,6 +10,14 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
||||
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
import { runWithConcurrency } from '@/lib/async-pool';
|
||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||
import { parseCommentImageUrls } from '@/lib/comment-images';
|
||||
import { isFreshAttachment } from '@/lib/upload-freshness';
|
||||
import { extractImageFileNameFromProxyUrl, sanitizeAssetDisplayName } from '@/lib/video-assets';
|
||||
import {
|
||||
reserveStorageQuota,
|
||||
releaseStorageReservation,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
} from '@/lib/storage-quota';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const CLEANUP_DELETE_CONCURRENCY = 5;
|
||||
@@ -36,6 +44,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
@@ -57,6 +66,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
tagId: true,
|
||||
@@ -103,6 +113,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
// PATCH /api/comments/[commentId]
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
// Carried out of the try so the catch below can scope the release to the
|
||||
// account the hold was opened against.
|
||||
let attachmentReservationId: string | null = null;
|
||||
let attachmentBilledUserId: string | null = null;
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
@@ -115,11 +129,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const comment = await db.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
include: {
|
||||
images: { select: { url: true }, orderBy: { position: 'asc' } },
|
||||
version: {
|
||||
include: {
|
||||
video: {
|
||||
include: {
|
||||
project: true,
|
||||
project: { include: { workspace: { select: { ownerId: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -133,7 +148,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const project = comment.version.video.project;
|
||||
const userId = session?.user?.id ?? null;
|
||||
const access = await checkProjectAccess(project, userId ?? undefined, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, userId ?? undefined);
|
||||
const isOwner = userId === project.ownerId;
|
||||
const isAuthor = !!userId && comment.authorId === userId;
|
||||
const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null;
|
||||
@@ -169,14 +184,69 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
// Only author can edit content or tag
|
||||
// `imageUrls` (or the legacy `imageUrl`) is the full list the comment should
|
||||
// end up with, so anything the caller left out is detached.
|
||||
const wantsImageUpdate = body.imageUrls !== undefined || body.imageUrl !== undefined;
|
||||
|
||||
// Only author can edit content, tag or attachments
|
||||
if (
|
||||
(content !== undefined || tagId !== undefined || annotationData !== undefined) &&
|
||||
(content !== undefined ||
|
||||
tagId !== undefined ||
|
||||
annotationData !== undefined ||
|
||||
wantsImageUpdate) &&
|
||||
!canEditOwnContent
|
||||
) {
|
||||
return apiErrors.forbidden('Only the author can edit comment content');
|
||||
}
|
||||
|
||||
let desiredImageUrls: string[] = [];
|
||||
let removedImageUrls: string[] = [];
|
||||
let addedImages: { url: string; sizeBytes: bigint }[] = [];
|
||||
|
||||
if (wantsImageUpdate) {
|
||||
const parsedImageUrls = parseCommentImageUrls(body);
|
||||
if ('error' in parsedImageUrls) {
|
||||
return apiErrors.badRequest(parsedImageUrls.error);
|
||||
}
|
||||
desiredImageUrls = parsedImageUrls.urls;
|
||||
|
||||
const existingUrls = comment.images.map((image) => image.url);
|
||||
const addedUrls = desiredImageUrls.filter((url) => !existingUrls.includes(url));
|
||||
removedImageUrls = existingUrls.filter((url) => !desiredImageUrls.includes(url));
|
||||
|
||||
if (addedUrls.length > 0) {
|
||||
// A file that already hangs off another comment would trip the unique
|
||||
// index mid-transaction, so refuse it here and answer with a 400.
|
||||
const alreadyClaimed = await db.commentImage.findFirst({
|
||||
where: { url: { in: addedUrls } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadyClaimed) {
|
||||
return apiErrors.badRequest('Image is already attached to another comment');
|
||||
}
|
||||
|
||||
const checks = await Promise.all(
|
||||
addedUrls.map(async (url) => ({ url, ...(await isFreshAttachment(url, 'image')) }))
|
||||
);
|
||||
if (checks.some((check) => !check.isFresh)) {
|
||||
return apiErrors.badRequest('Image upload expired. Please upload again.');
|
||||
}
|
||||
addedImages = checks;
|
||||
}
|
||||
|
||||
const addedBytes = addedImages.reduce((total, image) => total + image.sizeBytes, BigInt(0));
|
||||
if (addedBytes > BigInt(0)) {
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
project.workspace.ownerId,
|
||||
addedBytes,
|
||||
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
attachmentReservationId = reserveResult.reservationId;
|
||||
attachmentBilledUserId = project.workspace.ownerId;
|
||||
}
|
||||
}
|
||||
|
||||
// Owner, author, members, or workspace members can resolve/unresolve
|
||||
if (isResolved !== undefined && !canResolveComment) {
|
||||
return apiErrors.forbidden('Only admins can resolve comments');
|
||||
@@ -214,21 +284,80 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
updateData.isResolved = isResolved;
|
||||
updateData.resolvedAt = isResolved ? new Date() : null;
|
||||
}
|
||||
if (wantsImageUpdate) {
|
||||
// The legacy column keeps pointing at the first image.
|
||||
updateData.imageUrl = desiredImageUrls[0] ?? null;
|
||||
}
|
||||
|
||||
const updatedComment = await db.comment.update({
|
||||
const updatedComment = await db.$transaction(async (tx) => {
|
||||
// Consume the hold inside the transaction so quota is never double-counted.
|
||||
if (attachmentReservationId) {
|
||||
await tx.uploadReservation.deleteMany({
|
||||
where: {
|
||||
id: attachmentReservationId,
|
||||
billedUserId: project.workspace.ownerId,
|
||||
purpose: UPLOAD_RESERVATION_PURPOSES.ATTACHMENT,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (wantsImageUpdate) {
|
||||
if (removedImageUrls.length > 0) {
|
||||
// Only the link is dropped. The file stays in R2 and in the assets pane,
|
||||
// which is where a detached upload is deleted from and where its storage
|
||||
// is already accounted for.
|
||||
await tx.commentImage.deleteMany({
|
||||
where: { commentId, url: { in: removedImageUrls } },
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, url] of desiredImageUrls.entries()) {
|
||||
const added = addedImages.find((image) => image.url === url);
|
||||
if (!added) {
|
||||
await tx.commentImage.update({ where: { url }, data: { position: index } });
|
||||
continue;
|
||||
}
|
||||
|
||||
await tx.commentImage.create({ data: { commentId, url, position: index } });
|
||||
|
||||
const fileName = extractImageFileNameFromProxyUrl(url);
|
||||
await tx.videoAsset.create({
|
||||
data: {
|
||||
videoId: comment.version.video.id,
|
||||
kind: 'IMAGE',
|
||||
provider: 'R2_IMAGE',
|
||||
displayName: sanitizeAssetDisplayName(null, fileName || 'Comment Image'),
|
||||
sourceUrl: url,
|
||||
thumbnailUrl: url,
|
||||
sizeBytes: added.sizeBytes,
|
||||
uploadedByUserId: userId,
|
||||
uploadedByGuestIdentityId: userId ? null : guestIdentityId,
|
||||
uploadedByGuestName: userId
|
||||
? null
|
||||
: sanitizeAssetDisplayName(comment.guestName, 'Guest').slice(0, 80),
|
||||
billedUserId: project.workspace.ownerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.comment.update({
|
||||
where: { id: commentId },
|
||||
data: updateData,
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
replies: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const updatedCommentData = Object.fromEntries(
|
||||
Object.entries(updatedComment).filter(([key]) => key !== 'guestIdentityId')
|
||||
@@ -256,6 +385,11 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
await releaseStorageReservation(
|
||||
attachmentReservationId,
|
||||
attachmentBilledUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
|
||||
);
|
||||
logError('Error updating comment:', error);
|
||||
return apiErrors.internalError('Failed to update comment');
|
||||
}
|
||||
@@ -282,7 +416,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
},
|
||||
},
|
||||
},
|
||||
replies: { select: { voiceUrl: true, imageUrl: true } },
|
||||
images: { select: { url: true } },
|
||||
replies: {
|
||||
select: { voiceUrl: true, images: { select: { url: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -295,7 +432,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const isAuthor = !!userId && comment.authorId === userId;
|
||||
|
||||
// Project owners/admins and workspace admins can delete any comment
|
||||
const access = userId ? await checkProjectAccess(project, userId, { intent: 'manage' }) : null;
|
||||
const access = userId ? await checkProjectAccess(project, userId) : null;
|
||||
const isPrivilegedUser = !!access?.canEdit;
|
||||
|
||||
let canDelete = isAuthor || isPrivilegedUser;
|
||||
@@ -339,10 +476,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
// Collect all media URLs to delete from R2 (comment + its replies)
|
||||
const mediaUrls: string[] = [];
|
||||
if (comment.voiceUrl) mediaUrls.push(comment.voiceUrl);
|
||||
if (comment.imageUrl) mediaUrls.push(comment.imageUrl);
|
||||
for (const image of comment.images) mediaUrls.push(image.url);
|
||||
for (const reply of comment.replies) {
|
||||
if (reply.voiceUrl) mediaUrls.push(reply.voiceUrl);
|
||||
if (reply.imageUrl) mediaUrls.push(reply.imageUrl);
|
||||
for (const image of reply.images) mediaUrls.push(image.url);
|
||||
}
|
||||
|
||||
await db.comment.delete({ where: { id: commentId } });
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
|
||||
import { readRequestVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
// The one funnel event that cannot be observed from the server: a click on a
|
||||
// call to action, which never reaches us as a request of its own.
|
||||
//
|
||||
// Everything else in the funnel is recorded where it actually happens, so this
|
||||
// endpoint accepts exactly one event name. An anonymous caller must not be able
|
||||
// to post `SUBSCRIPTION_STARTED` into the scoreboard, and the cheapest way to
|
||||
// guarantee that is to make the allowed set a single literal.
|
||||
const ALLOWED_EVENTS = new Set(['cta_clicked']);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Answers 204 whatever happens. This endpoint reports nothing back to the page
|
||||
// that called it, so there is no reason to tell a caller which of their
|
||||
// attempts landed.
|
||||
const noContent = new Response(null, {
|
||||
status: 204,
|
||||
headers: { 'Cache-Control': 'private, no-store' },
|
||||
});
|
||||
|
||||
// Both cheap and both free of side effects, so they come before the limiter.
|
||||
// Checking the flag here rather than only inside the recorder keeps a host who
|
||||
// never turned analytics on from paying a rate-limit write for every anonymous
|
||||
// POST to an endpoint they are not using.
|
||||
if (!isProductAnalyticsEnabled()) return noContent;
|
||||
if (!isTrustedSameOriginRequest(request)) return noContent;
|
||||
|
||||
// 204 rather than the limiter's 429: a beacon has nobody to tell, and a
|
||||
// flooder should not be handed a signal for when the window resets.
|
||||
const limited = await rateLimit(request, 'analytics-beacon');
|
||||
if (limited) return noContent;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const name = typeof body?.name === 'string' ? body.name : '';
|
||||
if (!ALLOWED_EVENTS.has(name)) return noContent;
|
||||
|
||||
await recordVisitorEvent('CTA_CLICKED', await readRequestVisitor(request));
|
||||
|
||||
return noContent;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimitHeaders } from '@/lib/rate-limit';
|
||||
import { setSelfReportedSource } from '@/lib/analytics/record';
|
||||
import { isAcquisitionChannel } from '@/lib/analytics/cookies';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
// "How did you hear about us?", answered on the first onboarding screen.
|
||||
//
|
||||
// It is stored beside the cookie-derived channel rather than instead of it. The
|
||||
// cookie is precise but loses cross-device visits and cleared browsers; the
|
||||
// answer survives both, and it is the only thing that can name a channel no UTM
|
||||
// tag ever carries, like being told about it by a friend.
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
// Keyed by account, like /api/onboarding/complete beside it. An IP key would
|
||||
// be the wrong bucket twice over: without TRUSTED_PROXY_MODE every caller
|
||||
// resolves to 127.0.0.1, so five answers an hour would be five for the whole
|
||||
// deployment, and with it a shared office address would lock out everyone
|
||||
// after one colleague answered.
|
||||
const config = RATE_LIMIT_CONFIGS['onboarding-source'];
|
||||
const limit = await checkRateLimit(session.user.id, 'onboarding-source', config);
|
||||
if (!limit.allowed) {
|
||||
return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...rateLimitHeaders(limit, config.maxRequests),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!isProductAnalyticsEnabled()) {
|
||||
return apiErrors.badRequest('Analytics are disabled by this host');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const source = body?.source;
|
||||
if (!isAcquisitionChannel(source)) {
|
||||
return apiErrors.badRequest('Unknown source');
|
||||
}
|
||||
|
||||
const note = typeof body?.note === 'string' ? body.note : null;
|
||||
|
||||
await setSelfReportedSource({ userId: session.user.id, selfReported: source, note });
|
||||
|
||||
const response = successResponse({ recorded: true });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
if (!project) return apiErrors.notFound('Project');
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
if (!access.canEdit) return apiErrors.forbidden('Access denied');
|
||||
|
||||
const candidates = await getApprovalCandidatesForProject(projectId);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { db } from '@/lib/db';
|
||||
import { logError } from '@/lib/logger';
|
||||
import {
|
||||
buildProjectDownloadManifest,
|
||||
canDownloadProjectMedia,
|
||||
parseRequestedVideoIds,
|
||||
validateProjectDownloadManifest,
|
||||
} from '@/lib/project-download';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
// GET /api/projects/[projectId]/download
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'project-download');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
const requestedVideoIds = parseRequestedVideoIds(request.nextUrl.searchParams.get('videoIds'));
|
||||
const includeAllVersions = request.nextUrl.searchParams.get('versions') === 'all';
|
||||
const includeAssets = request.nextUrl.searchParams.get('assets') === '1';
|
||||
|
||||
if (requestedVideoIds && requestedVideoIds.length === 0) {
|
||||
return apiErrors.badRequest('At least one video must be selected for download');
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ownerId: true,
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
allowDownloads: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session?.user?.id);
|
||||
if (!canDownloadProjectMedia(project, access)) {
|
||||
return apiErrors.forbidden('Project downloads are disabled for viewers');
|
||||
}
|
||||
|
||||
const videos = await db.video.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
...(requestedVideoIds ? { id: { in: requestedVideoIds } } : {}),
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { id: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
position: true,
|
||||
versions: {
|
||||
orderBy: { versionNumber: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
versionLabel: true,
|
||||
providerId: true,
|
||||
videoId: true,
|
||||
originalUrl: true,
|
||||
sizeBytes: true,
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
provider: true,
|
||||
displayName: true,
|
||||
sourceUrl: true,
|
||||
providerVideoId: true,
|
||||
sizeBytes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (requestedVideoIds) {
|
||||
const foundIds = new Set(videos.map((video) => video.id));
|
||||
const missing = requestedVideoIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
return apiErrors.badRequest('One or more selected videos do not belong to this project');
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = buildProjectDownloadManifest(project.name, videos, {
|
||||
includeAllVersions,
|
||||
includeAssets,
|
||||
});
|
||||
const validationError = validateProjectDownloadManifest(manifest);
|
||||
if (validationError) {
|
||||
return apiErrors.badRequest(validationError);
|
||||
}
|
||||
|
||||
const response = successResponse(manifest);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error creating project download manifest:', error);
|
||||
return apiErrors.internalError('Failed to prepare project download');
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/lib/invitations';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
@@ -111,7 +112,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN;
|
||||
|
||||
@@ -126,9 +127,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.badRequest('Email is required');
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(normalizedEmail)) {
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
if (!isValidEmailAddress(normalizedEmail)) {
|
||||
return apiErrors.validationError('Invalid email format');
|
||||
}
|
||||
|
||||
|
||||
@@ -103,14 +103,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||
});
|
||||
const access = projectAccessTarget
|
||||
? await checkProjectAccess(projectAccessTarget, session.user.id, { intent: 'manage' })
|
||||
? await checkProjectAccess(projectAccessTarget, session.user.id)
|
||||
: null;
|
||||
if (!access?.canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description, visibility } = body;
|
||||
const { name, description, visibility, allowDownloads } = body;
|
||||
|
||||
if (name !== undefined) {
|
||||
if (typeof name !== 'string' || name.trim().length === 0) {
|
||||
@@ -133,11 +133,15 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
if (visibility !== undefined && !VALID_VISIBILITY.includes(visibility)) {
|
||||
return apiErrors.badRequest('Invalid visibility value');
|
||||
}
|
||||
if (allowDownloads !== undefined && typeof allowDownloads !== 'boolean') {
|
||||
return apiErrors.badRequest('allowDownloads must be a boolean');
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (name !== undefined) updateData.name = name.trim();
|
||||
if (description !== undefined) updateData.description = description?.trim() || null;
|
||||
if (visibility !== undefined) updateData.visibility = visibility;
|
||||
if (allowDownloads !== undefined) updateData.allowDownloads = allowDownloads;
|
||||
|
||||
const project = await db.project.update({
|
||||
where: { id: projectId },
|
||||
@@ -177,7 +181,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'delete' });
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
if (!access.canDelete) {
|
||||
return apiErrors.forbidden('Only the project owner can delete it');
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { canDownloadProjectMedia } from '@/lib/project-download';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
|
||||
@@ -49,6 +50,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -74,6 +76,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: {
|
||||
select: { id: true, url: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -123,18 +129,19 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const canDownload = canDownloadProjectMedia(video.project, access);
|
||||
const response = successResponse({
|
||||
...video,
|
||||
isAuthenticated: !!session?.user?.id,
|
||||
currentUserId: session?.user?.id || null,
|
||||
currentUserName: session?.user?.name || null,
|
||||
canDownload: access.hasAccess,
|
||||
canDownload,
|
||||
canManageTags: access.canEdit,
|
||||
canResolveComments: access.canEdit,
|
||||
canRequestApproval: access.canEdit,
|
||||
canShareVideo: access.canEdit,
|
||||
canUploadAssets: access.hasAccess,
|
||||
canDownloadAssets: !!session?.user?.id && access.hasAccess,
|
||||
canDownloadAssets: canDownload,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-cache');
|
||||
@@ -168,7 +175,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(video.project, session.user.id);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
@@ -189,12 +196,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
if (typeof description === 'string') updateData.description = description.trim() || null;
|
||||
if (position !== undefined) updateData.position = position;
|
||||
|
||||
// Keep the response to scalar video fields: including versions would pull
|
||||
// in BigInt columns (sizeBytes) that JSON.stringify cannot serialize, and
|
||||
// no caller consumes the success payload beyond these fields.
|
||||
const updatedVideo = await db.video.update({
|
||||
where: { id: videoId },
|
||||
data: updateData,
|
||||
include: {
|
||||
versions: { orderBy: { versionNumber: 'desc' } },
|
||||
_count: { select: { versions: true } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
position: true,
|
||||
projectId: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -242,7 +256,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(video.project, session.user.id);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Only project owner or admin can delete videos');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { db } from '@/lib/db';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { MAX_SHARE_PASSWORD_LENGTH } from '@/lib/share-links';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
|
||||
@@ -141,7 +142,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const { projectId, videoId } = await params;
|
||||
const { error } = await requireShareManagementAccess(projectId, videoId, session.user.id);
|
||||
const { error, video } = await requireShareManagementAccess(
|
||||
projectId,
|
||||
videoId,
|
||||
session.user.id
|
||||
);
|
||||
if (error) return error;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
@@ -244,6 +249,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.internalError('Failed to create video share link');
|
||||
}
|
||||
|
||||
// Keyed on the link id, so re-issuing the token for a link that already
|
||||
// exists updates the row and records nothing: the share was created once.
|
||||
await recordEvent({
|
||||
name: 'SHARE_LINK_CREATED',
|
||||
dedupeKey: eventKey('SHARE_LINK_CREATED', link.id),
|
||||
userId: video?.project.ownerId ?? null,
|
||||
});
|
||||
|
||||
const response = successResponse(serializeShareLink(request, videoId, link));
|
||||
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { logError } from '@/lib/logger';
|
||||
@@ -31,7 +32,7 @@ async function getVersionWithAccess(
|
||||
}
|
||||
|
||||
const project = version.video.project;
|
||||
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, userId);
|
||||
|
||||
return { version, canEdit: access.canEdit, isOwner: access.isOwner };
|
||||
}
|
||||
@@ -148,8 +149,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
});
|
||||
|
||||
const bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([bunnyRef]);
|
||||
const cleanupInput = { bunny: bunnyCleanupResult };
|
||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||
cleanupBunnyStreamVideosBestEffort([bunnyRef]),
|
||||
result.version.providerId === 'r2'
|
||||
? deleteMediaFilesBestEffort(
|
||||
[result.version.originalUrl, result.version.thumbnailUrl].filter((url): url is string =>
|
||||
Boolean(url)
|
||||
)
|
||||
)
|
||||
: Promise.resolve({ attempted: 0, failed: 0, failedKeys: [] }),
|
||||
]);
|
||||
const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult };
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'video-version', entityId: versionId }, cleanupInput);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||
import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
import { readBunnyUploadGrant } from '@/lib/bunny-upload-token';
|
||||
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
||||
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
@@ -64,7 +66,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const video = await db.video.findFirst({
|
||||
where: { id: videoId, projectId },
|
||||
include: {
|
||||
project: true,
|
||||
// The workspace owner comes along because they are the account the
|
||||
// upload is billed to, and the Bunny reservation released below is held
|
||||
// against them rather than against whoever is adding the version.
|
||||
project: { include: { workspace: { select: { ownerId: true } } } },
|
||||
versions: { orderBy: { versionNumber: 'desc' }, take: 1 },
|
||||
},
|
||||
});
|
||||
@@ -73,7 +78,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(video.project, session.user.id);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
@@ -88,6 +93,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
duration,
|
||||
setActive,
|
||||
uploadToken,
|
||||
objectKey,
|
||||
} = body;
|
||||
|
||||
if (!videoUrl) {
|
||||
@@ -103,13 +109,23 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate URLs use safe schemes (http/https only)
|
||||
const normalizedProviderIdEarly =
|
||||
typeof providerId === 'string' && providerId.trim()
|
||||
? providerId.trim().toLowerCase()
|
||||
: 'youtube';
|
||||
|
||||
if (normalizedProviderIdEarly === 'r2') {
|
||||
if (!videoUrl.startsWith('/api/upload/video/')) {
|
||||
return apiErrors.badRequest('Video URL must be a valid upload path');
|
||||
}
|
||||
} else {
|
||||
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
||||
if (videoUrlError) {
|
||||
return apiErrors.badRequest(videoUrlError);
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
|
||||
const thumbnailUrlError = validateOptionalUrlOrAppPath(thumbnailUrl, 'Thumbnail URL');
|
||||
if (thumbnailUrlError) {
|
||||
return apiErrors.badRequest(thumbnailUrlError);
|
||||
}
|
||||
@@ -122,19 +138,66 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
|
||||
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||
|
||||
let versionSizeBytes = BigInt(0);
|
||||
let bunnyReservation: string | null = null;
|
||||
let persistedProviderVideoId = normalizedProviderVideoId;
|
||||
let finalizedR2Session: {
|
||||
sessionId: string;
|
||||
reservationId: string | null;
|
||||
billedUserId: string;
|
||||
thumbnailProxyUrl: string;
|
||||
} | null = null;
|
||||
|
||||
if (normalizedProviderId === 'bunny') {
|
||||
if (!normalizedProviderVideoId || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
|
||||
const grant = readBunnyUploadGrant(normalizedUploadToken, {
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoId: normalizedProviderVideoId,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
if (!grant) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
|
||||
// The size the upload was admitted on, written down here so the account is
|
||||
// charged for it from this moment. Bunny reports nothing at all until it
|
||||
// has finished encoding, which for a half-hour video is the better part of
|
||||
// an hour, and until this row existed those bytes were simply invisible:
|
||||
// the uploader's own storage page read zero and the next upload was
|
||||
// measured against a total that ignored the one before it.
|
||||
versionSizeBytes = grant.declaredSizeBytes ?? BigInt(0);
|
||||
bunnyReservation = grant.reservationId;
|
||||
} else if (normalizedProviderId === 'r2') {
|
||||
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
|
||||
if (!normalizedObjectKey || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('R2 uploads must include objectKey and uploadToken');
|
||||
}
|
||||
|
||||
const finalizeResult = await finalizeR2VideoUpload({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoUrl,
|
||||
objectKey: normalizedObjectKey,
|
||||
uploadToken: normalizedUploadToken,
|
||||
});
|
||||
if (!finalizeResult.ok) {
|
||||
if (finalizeResult.status === 403) {
|
||||
return apiErrors.forbidden(finalizeResult.error);
|
||||
}
|
||||
return apiErrors.badRequest(finalizeResult.error);
|
||||
}
|
||||
|
||||
versionSizeBytes = finalizeResult.sizeBytes;
|
||||
persistedProviderVideoId = normalizedObjectKey;
|
||||
finalizedR2Session = {
|
||||
sessionId: finalizeResult.sessionId,
|
||||
reservationId: finalizeResult.reservationId,
|
||||
billedUserId: finalizeResult.billedUserId,
|
||||
thumbnailProxyUrl: finalizeResult.thumbnailProxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
|
||||
@@ -150,16 +213,60 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
}
|
||||
|
||||
if (finalizedR2Session) {
|
||||
const consumed = await tx.videoUploadSession.updateMany({
|
||||
where: {
|
||||
id: finalizedR2Session.sessionId,
|
||||
status: 'INITIATED',
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey: persistedProviderVideoId,
|
||||
},
|
||||
data: {
|
||||
status: 'FINALIZED',
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (consumed.count !== 1) {
|
||||
throw new Error('Upload session already consumed');
|
||||
}
|
||||
if (finalizedR2Session.reservationId) {
|
||||
await tx.uploadReservation.deleteMany({
|
||||
where: {
|
||||
id: finalizedR2Session.reservationId,
|
||||
billedUserId: finalizedR2Session.billedUserId,
|
||||
purpose: UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handed over in the same transaction that records the size, so the bytes
|
||||
// are never counted twice and never counted zero times.
|
||||
if (bunnyReservation) {
|
||||
await tx.uploadReservation.deleteMany({
|
||||
where: {
|
||||
id: bunnyReservation,
|
||||
billedUserId: video.project.workspace.ownerId,
|
||||
purpose: UPLOAD_RESERVATION_PURPOSES.BUNNY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx.videoVersion.create({
|
||||
data: {
|
||||
versionNumber: nextVersionNumber,
|
||||
versionLabel: versionLabel?.trim() || null,
|
||||
providerId: normalizedProviderId,
|
||||
videoId: normalizedProviderVideoId,
|
||||
videoId: persistedProviderVideoId,
|
||||
originalUrl: videoUrl,
|
||||
title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
|
||||
thumbnailUrl: thumbnailUrl || null,
|
||||
thumbnailUrl:
|
||||
normalizedProviderId === 'r2'
|
||||
? (finalizedR2Session?.thumbnailProxyUrl ?? '/placeholder-video-thumbnail.png')
|
||||
: thumbnailUrl || null,
|
||||
duration: duration || null,
|
||||
sizeBytes: versionSizeBytes,
|
||||
isActive: setActive ?? false,
|
||||
videoParentId: videoId,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { db } from '@/lib/db';
|
||||
import { logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { deleteProjectVideosWithCleanup, VideoStorageCleanupError } from '@/lib/video-delete';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
const MAX_BULK_DELETE = 50;
|
||||
|
||||
// POST /api/projects/[projectId]/videos/bulk-delete
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||
});
|
||||
if (!project) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Only project owner or admin can delete videos');
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { videoIds } = body as { videoIds?: unknown };
|
||||
|
||||
if (!Array.isArray(videoIds) || videoIds.length === 0) {
|
||||
return apiErrors.badRequest('videoIds must be a non-empty array');
|
||||
}
|
||||
if (videoIds.length > MAX_BULK_DELETE) {
|
||||
return apiErrors.badRequest(`You can delete at most ${MAX_BULK_DELETE} videos at once`);
|
||||
}
|
||||
if (!videoIds.every((id) => typeof id === 'string' && id.trim().length > 0)) {
|
||||
return apiErrors.badRequest('Each video id must be a non-empty string');
|
||||
}
|
||||
|
||||
const normalizedIds = [...new Set(videoIds.map((id) => id.trim()))];
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await deleteProjectVideosWithCleanup(projectId, normalizedIds);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'VIDEO_NOT_FOUND') {
|
||||
return apiErrors.badRequest('One or more selected videos do not belong to this project');
|
||||
}
|
||||
// Storage refused a delete, so nothing was removed and the videos are still there.
|
||||
// Saying so lets the caller retry, which is the whole point of leaving the rows.
|
||||
if (error instanceof VideoStorageCleanupError) {
|
||||
logCleanupWarnings(
|
||||
{ entityType: 'video', entityId: `bulk:${normalizedIds.join(',')}` },
|
||||
error.cleanupInput
|
||||
);
|
||||
return apiErrors.internalError(
|
||||
'Could not delete the stored media for these videos. Nothing was deleted; please try again.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result.cleanupWarnings) {
|
||||
logCleanupWarnings(
|
||||
{ entityType: 'video', entityId: `bulk:${normalizedIds.join(',')}` },
|
||||
result.cleanupInput
|
||||
);
|
||||
}
|
||||
|
||||
const response = successResponse({
|
||||
message: `${result.deletedCount} video${result.deletedCount === 1 ? '' : 's'} deleted`,
|
||||
deletedCount: result.deletedCount,
|
||||
...(result.cleanupWarnings ? { cleanupWarnings: result.cleanupWarnings } : {}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error bulk deleting videos:', error);
|
||||
return apiErrors.internalError('Failed to delete selected videos');
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,26 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
||||
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';
|
||||
import { createBunnyUploadToken, readBunnyUploadGrant } from '@/lib/bunny-upload-token';
|
||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { enforceStorageQuota } from '@/lib/storage-quota';
|
||||
import {
|
||||
enforceStorageQuota,
|
||||
getMaxVideoUploadBytesForUser,
|
||||
releaseStorageReservation,
|
||||
reserveStorageQuota,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
} from '@/lib/storage-quota';
|
||||
import { parseDeclaredUploadSize } from '@/lib/upload-size';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
// Long enough to outlive a slow upload and Bunny's own reporting delay, matching
|
||||
// what the R2 video path already reserves for. The reservation is what makes
|
||||
// concurrent uploads visible to each other, so it has to stay until the bytes it
|
||||
// stands for are counted, not until the upload finishes.
|
||||
const BUNNY_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
async function getProjectWithEditAccess(projectId: string, userId: string) {
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
@@ -27,7 +40,7 @@ async function getProjectWithEditAccess(projectId: string, userId: string) {
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, userId);
|
||||
const canEdit = access.canEdit;
|
||||
|
||||
if (!canEdit) return null;
|
||||
@@ -60,18 +73,49 @@ 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');
|
||||
if (!isBunnyUploadsEnabled()) {
|
||||
return apiErrors.badRequest('Bunny direct uploads are disabled by this host');
|
||||
}
|
||||
|
||||
const quotaError = await enforceStorageQuota(project.workspace.ownerId, BigInt(0));
|
||||
// The size the client says it is about to upload. It is a claim, not proof,
|
||||
// and the bytes never pass through us to be checked: they go straight to
|
||||
// Bunny, whose own reporting is what eventually settles the account. What
|
||||
// the claim buys is the two things asking for zero bytes could not. An
|
||||
// upload that plainly does not fit is refused before it starts instead of
|
||||
// halfway through, and the reservation written below makes concurrent
|
||||
// uploads visible to each other, where previously every request in the same
|
||||
// two-minute window read the same stale total and every one of them passed.
|
||||
const billedUserId = project.workspace.ownerId;
|
||||
const declaredSize = parseDeclaredUploadSize(
|
||||
body?.sizeBytes,
|
||||
await getMaxVideoUploadBytesForUser(billedUserId)
|
||||
);
|
||||
if ('error' in declaredSize) {
|
||||
return apiErrors.badRequest(declaredSize.error);
|
||||
}
|
||||
|
||||
const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes);
|
||||
if (quotaError) return quotaError;
|
||||
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
billedUserId,
|
||||
declaredSize.sizeBytes,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY,
|
||||
BUNNY_RESERVATION_TTL_MS
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
const { reservationId } = reserveResult;
|
||||
|
||||
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
||||
const libraryId =
|
||||
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
||||
|
||||
if (!apiKey || !libraryId) {
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||
);
|
||||
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
||||
}
|
||||
|
||||
@@ -87,6 +131,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
|
||||
if (!bunnyRes.ok) {
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||
);
|
||||
logError('Failed to create Bunny Stream video', await bunnyRes.text());
|
||||
return apiErrors.internalError('Failed to initialize video upload with provider');
|
||||
}
|
||||
@@ -94,6 +143,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const bunnyVideo = await bunnyRes.json();
|
||||
const videoId = bunnyVideo.guid;
|
||||
if (typeof videoId !== 'string' || videoId.length === 0) {
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||
);
|
||||
return apiErrors.internalError('Upload provider did not return a valid video identifier');
|
||||
}
|
||||
|
||||
@@ -109,6 +163,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoId,
|
||||
reservationId,
|
||||
declaredSizeBytes: declaredSize.sizeBytes,
|
||||
},
|
||||
3600
|
||||
);
|
||||
@@ -155,15 +211,27 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.badRequest('videoId and uploadToken are required');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
|
||||
const grant = readBunnyUploadGrant(uploadToken, {
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoId,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
if (!grant) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
|
||||
// Giving the quota back here rather than waiting for the reservation to
|
||||
// lapse: an abandoned upload that keeps holding gigabytes for two hours is
|
||||
// most of a trial's whole allowance, and the account has nothing to show for
|
||||
// it. Safe to do on a caller's say-so only because the reservation id rides
|
||||
// inside the signed token next to this video id, so releasing it costs the
|
||||
// caller the video it belongs to.
|
||||
await releaseStorageReservation(
|
||||
grant.reservationId,
|
||||
project.workspace.ownerId,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||
);
|
||||
|
||||
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
|
||||
|
||||
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { db } from '@/lib/db';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
const MAX_BULK_MOVE = 50;
|
||||
|
||||
// Thrown inside the move transaction when the atomic source-ownership re-check
|
||||
// fails (a concurrent request relocated a video between check and commit).
|
||||
class VideoMoveConflictError extends Error {}
|
||||
|
||||
// GET /api/projects/[projectId]/videos/move
|
||||
// Lists destination projects (same workspace, manageable by the user) the
|
||||
// current project's videos can be moved into.
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'api');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
const userId = session.user.id;
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||
});
|
||||
if (!project) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, userId);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
// Workspace owners/admins can manage every project in the workspace; everyone
|
||||
// else can only move into projects they own or are an admin member of.
|
||||
const [workspace, workspaceMember] = await Promise.all([
|
||||
db.workspace.findUnique({
|
||||
where: { id: project.workspaceId },
|
||||
select: { ownerId: true },
|
||||
}),
|
||||
db.workspaceMember.findUnique({
|
||||
where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } },
|
||||
}),
|
||||
]);
|
||||
const isWorkspaceManager = workspace?.ownerId === userId || workspaceMember?.role === 'ADMIN';
|
||||
|
||||
const targets = await db.project.findMany({
|
||||
where: {
|
||||
workspaceId: project.workspaceId,
|
||||
id: { not: projectId },
|
||||
...(isWorkspaceManager
|
||||
? {}
|
||||
: {
|
||||
OR: [{ ownerId: userId }, { members: { some: { userId, role: 'ADMIN' } } }],
|
||||
}),
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const response = successResponse({ projects: targets });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error listing video move targets:', error);
|
||||
return apiErrors.internalError('Failed to load destination projects');
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/projects/[projectId]/videos/move
|
||||
// Moves one or more videos from this project into another project in the same
|
||||
// workspace. Versions, comments and assets follow the video automatically.
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
const userId = session.user.id;
|
||||
|
||||
const body = await request.json();
|
||||
const { videoIds, targetProjectId } = body as {
|
||||
videoIds?: unknown;
|
||||
targetProjectId?: unknown;
|
||||
};
|
||||
|
||||
if (!Array.isArray(videoIds) || videoIds.length === 0) {
|
||||
return apiErrors.badRequest('videoIds must be a non-empty array');
|
||||
}
|
||||
if (videoIds.length > MAX_BULK_MOVE) {
|
||||
return apiErrors.badRequest(`You can move at most ${MAX_BULK_MOVE} videos at once`);
|
||||
}
|
||||
if (!videoIds.every((id) => typeof id === 'string' && id.trim().length > 0)) {
|
||||
return apiErrors.badRequest('Each video id must be a non-empty string');
|
||||
}
|
||||
if (typeof targetProjectId !== 'string' || targetProjectId.trim().length === 0) {
|
||||
return apiErrors.badRequest('targetProjectId must be a non-empty string');
|
||||
}
|
||||
|
||||
const normalizedIds = [...new Set(videoIds.map((id) => id.trim()))];
|
||||
const targetId = targetProjectId.trim();
|
||||
|
||||
if (targetId === projectId) {
|
||||
return apiErrors.badRequest('Source and destination projects are the same');
|
||||
}
|
||||
|
||||
const [sourceProject, targetProject] = await Promise.all([
|
||||
db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||
}),
|
||||
db.project.findUnique({
|
||||
where: { id: targetId },
|
||||
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||
}),
|
||||
]);
|
||||
if (!sourceProject) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
if (!targetProject) {
|
||||
return apiErrors.badRequest('Destination project not found');
|
||||
}
|
||||
if (sourceProject.workspaceId !== targetProject.workspaceId) {
|
||||
return apiErrors.badRequest('Videos can only be moved within the same workspace');
|
||||
}
|
||||
|
||||
const [sourceAccess, targetAccess] = await Promise.all([
|
||||
checkProjectAccess(sourceProject, userId),
|
||||
checkProjectAccess(targetProject, userId),
|
||||
]);
|
||||
if (!sourceAccess.canEdit) {
|
||||
return apiErrors.forbidden('You cannot move videos out of this project');
|
||||
}
|
||||
if (!targetAccess.canEdit) {
|
||||
return apiErrors.forbidden('You cannot move videos into the selected project');
|
||||
}
|
||||
|
||||
// Fast, friendly pre-check for the common case (stale UI). The authoritative
|
||||
// ownership guard is re-asserted atomically inside the transaction below.
|
||||
const videos = await db.video.findMany({
|
||||
where: { id: { in: normalizedIds }, projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (videos.length !== normalizedIds.length) {
|
||||
return apiErrors.badRequest('One or more selected videos do not belong to this project');
|
||||
}
|
||||
|
||||
try {
|
||||
await db.$transaction(async (tx) => {
|
||||
// Append moved videos after the destination's existing videos so ordering
|
||||
// stays stable instead of colliding with the source positions. Read this
|
||||
// before the move so the videos being moved aren't counted yet.
|
||||
const maxPosition = await tx.video.aggregate({
|
||||
where: { projectId: targetId },
|
||||
_max: { position: true },
|
||||
});
|
||||
const basePosition = (maxPosition._max.position ?? -1) + 1;
|
||||
|
||||
// Re-assert source ownership as part of the write itself: a concurrent
|
||||
// move can't slip a video out from under us between check and commit,
|
||||
// and the row locks serialize competing moves of the same videos.
|
||||
const moved = await tx.video.updateMany({
|
||||
where: { id: { in: normalizedIds }, projectId },
|
||||
data: { projectId: targetId },
|
||||
});
|
||||
if (moved.count !== normalizedIds.length) {
|
||||
throw new VideoMoveConflictError();
|
||||
}
|
||||
|
||||
// Apply per-video ordering now that the videos live in the destination.
|
||||
await Promise.all(
|
||||
normalizedIds.map((id, index) =>
|
||||
tx.video.update({ where: { id }, data: { position: basePosition + index } })
|
||||
)
|
||||
);
|
||||
|
||||
// Keep video-scoped share links pointing at the video's new project.
|
||||
await tx.shareLink.updateMany({
|
||||
where: { videoId: { in: normalizedIds } },
|
||||
data: { projectId: targetId },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof VideoMoveConflictError) {
|
||||
return apiErrors.conflict(
|
||||
'One or more selected videos changed while moving. Please refresh and try again.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
revalidatePath(`/projects/${targetId}`);
|
||||
|
||||
const response = successResponse({
|
||||
message: `${normalizedIds.length} video${normalizedIds.length === 1 ? '' : 's'} moved`,
|
||||
movedCount: normalizedIds.length,
|
||||
targetProjectId: targetId,
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error moving videos:', error);
|
||||
return apiErrors.internalError('Failed to move videos');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { parseR2UploadToken, verifyR2UploadToken } from '@/lib/r2-upload-token';
|
||||
import { abortMultipartVideoUpload, completeMultipartVideoUpload } from '@/lib/r2';
|
||||
import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { objectKeyToVideoProxyPath } from '@/lib/video-upload-validation';
|
||||
import { releaseStorageReservation, UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
async function getProjectWithEditAccess(projectId: string, userId: string) {
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
ownerId: true,
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
workspace: { select: { ownerId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const access = await checkProjectAccess(project, userId);
|
||||
if (!access.canEdit) return null;
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
type IncomingPart = { partNumber: number; etag: string };
|
||||
|
||||
function parseParts(raw: unknown): IncomingPart[] | null {
|
||||
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 10000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: IncomingPart[] = [];
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const entry of raw) {
|
||||
const partNumber = (entry as { partNumber?: unknown })?.partNumber;
|
||||
const etag = (entry as { etag?: unknown })?.etag;
|
||||
|
||||
if (
|
||||
typeof partNumber !== 'number' ||
|
||||
!Number.isInteger(partNumber) ||
|
||||
partNumber < 1 ||
|
||||
partNumber > 10000 ||
|
||||
seen.has(partNumber)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof etag !== 'string' || etag.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seen.add(partNumber);
|
||||
parts.push({ partNumber, etag: etag.trim() });
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
// POST /api/projects/[projectId]/videos/r2-complete
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
if (!isS3VideoUploadsEnabled()) {
|
||||
return apiErrors.badRequest('S3 video uploads are disabled by this host');
|
||||
}
|
||||
|
||||
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
||||
if (!project) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const objectKey = typeof body?.objectKey === 'string' ? body.objectKey.trim() : '';
|
||||
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
||||
const parts = parseParts(body?.parts);
|
||||
|
||||
if (!objectKey || !uploadToken) {
|
||||
return apiErrors.badRequest('objectKey and uploadToken are required');
|
||||
}
|
||||
|
||||
if (!parts) {
|
||||
return apiErrors.badRequest('parts must be a non-empty list of { partNumber, etag }');
|
||||
}
|
||||
|
||||
const tokenPayload = parseR2UploadToken(uploadToken);
|
||||
if (!tokenPayload) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyR2UploadToken(uploadToken, {
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey,
|
||||
sessionId: tokenPayload.sid,
|
||||
tokenId: tokenPayload.jti,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const uploadSession = await db.videoUploadSession.findFirst({
|
||||
where: {
|
||||
id: tokenPayload.sid,
|
||||
status: 'INITIATED',
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey,
|
||||
uploadJti: tokenPayload.jti,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
multipartUploadId: true,
|
||||
reservationId: true,
|
||||
billedUserId: true,
|
||||
},
|
||||
});
|
||||
if (!uploadSession || !uploadSession.multipartUploadId) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const proxyUrl = objectKeyToVideoProxyPath(objectKey);
|
||||
if (!proxyUrl) {
|
||||
return apiErrors.badRequest('Invalid object key');
|
||||
}
|
||||
|
||||
try {
|
||||
await completeMultipartVideoUpload(objectKey, uploadSession.multipartUploadId, parts);
|
||||
} catch (error) {
|
||||
logError('Failed to complete R2 multipart upload:', error);
|
||||
await abortMultipartVideoUpload(objectKey, uploadSession.multipartUploadId).catch(
|
||||
() => undefined
|
||||
);
|
||||
await db.videoUploadSession.updateMany({
|
||||
where: { id: uploadSession.id, status: 'INITIATED' },
|
||||
data: { status: 'CANCELLED', consumedAt: new Date() },
|
||||
});
|
||||
await releaseStorageReservation(
|
||||
uploadSession.reservationId,
|
||||
uploadSession.billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
return apiErrors.internalError('Failed to complete multipart upload');
|
||||
}
|
||||
|
||||
const response = successResponse({ objectKey, proxyUrl });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error completing R2 multipart upload:', error);
|
||||
return apiErrors.internalError('Failed to complete upload');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import {
|
||||
createR2UploadToken,
|
||||
parseR2UploadToken,
|
||||
verifyR2UploadToken,
|
||||
} from '@/lib/r2-upload-token';
|
||||
import {
|
||||
abortMultipartVideoUpload,
|
||||
createMultipartVideoUpload,
|
||||
createPresignedImagePutUrl,
|
||||
createPresignedUploadPartUrl,
|
||||
createPresignedVideoPutUrl,
|
||||
deleteR2Object,
|
||||
deleteVideoObject,
|
||||
} from '@/lib/r2';
|
||||
import {
|
||||
getR2MultipartPartSizeBytes,
|
||||
getR2MultipartThresholdBytes,
|
||||
isS3VideoUploadsEnabled,
|
||||
} from '@/lib/feature-flags';
|
||||
import {
|
||||
buildVideoObjectKey,
|
||||
getVideoExtensionFromMime,
|
||||
resolveVideoContentType,
|
||||
videoProxyPathFromFilename,
|
||||
} from '@/lib/video-upload-validation';
|
||||
import { logError } from '@/lib/logger';
|
||||
import {
|
||||
enforceStorageQuota,
|
||||
getMaxVideoUploadBytesForUser,
|
||||
releaseStorageReservation,
|
||||
reserveStorageQuota,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
} from '@/lib/storage-quota';
|
||||
import { uploadTooLargeMessage } from '@/lib/upload-size';
|
||||
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
const VIDEO_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
const THUMBNAIL_RESERVE_BYTES = BigInt(512 * 1024);
|
||||
|
||||
async function getProjectWithEditAccess(projectId: string, userId: string) {
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ownerId: true,
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
workspace: { select: { ownerId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const access = await checkProjectAccess(project, userId);
|
||||
if (!access.canEdit) return null;
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
// POST /api/projects/[projectId]/videos/r2-init
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
if (!isS3VideoUploadsEnabled()) {
|
||||
return apiErrors.badRequest('S3 video uploads are disabled by this host');
|
||||
}
|
||||
|
||||
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
||||
if (!project) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const fileName = typeof body?.fileName === 'string' ? body.fileName.trim() : '';
|
||||
const contentTypeInput = typeof body?.contentType === 'string' ? body.contentType.trim() : '';
|
||||
const sizeBytesRaw = body?.sizeBytes;
|
||||
|
||||
if (!fileName) {
|
||||
return apiErrors.badRequest('fileName is required');
|
||||
}
|
||||
|
||||
let sizeBytes: bigint;
|
||||
try {
|
||||
sizeBytes = BigInt(sizeBytesRaw);
|
||||
if (sizeBytes <= BigInt(0)) {
|
||||
return apiErrors.badRequest('sizeBytes must be a positive integer');
|
||||
}
|
||||
} catch {
|
||||
return apiErrors.badRequest('sizeBytes must be a positive integer');
|
||||
}
|
||||
|
||||
const maxBytes = await getMaxVideoUploadBytesForUser(project.workspace.ownerId);
|
||||
if (sizeBytes > maxBytes) {
|
||||
return apiErrors.badRequest(uploadTooLargeMessage(maxBytes));
|
||||
}
|
||||
|
||||
const contentType = resolveVideoContentType(fileName, contentTypeInput);
|
||||
if (!contentType) {
|
||||
return apiErrors.badRequest('Unsupported video format');
|
||||
}
|
||||
|
||||
const ext = getVideoExtensionFromMime(contentType);
|
||||
if (!ext) {
|
||||
return apiErrors.badRequest('Unsupported video format');
|
||||
}
|
||||
|
||||
const quotaError = await enforceStorageQuota(
|
||||
project.workspace.ownerId,
|
||||
sizeBytes + THUMBNAIL_RESERVE_BYTES
|
||||
);
|
||||
if (quotaError) return quotaError;
|
||||
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
project.workspace.ownerId,
|
||||
sizeBytes + THUMBNAIL_RESERVE_BYTES,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||
VIDEO_RESERVATION_TTL_MS
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
|
||||
const fileId = randomUUID();
|
||||
const filename = `${fileId}.${ext}`;
|
||||
const objectKey = buildVideoObjectKey(filename);
|
||||
const proxyUrl = videoProxyPathFromFilename(filename);
|
||||
const thumbnailFilename = `${fileId}.jpg`;
|
||||
const thumbnailObjectKey = `images/${thumbnailFilename}`;
|
||||
const thumbnailProxyUrl = `/api/upload/image/${thumbnailFilename}`;
|
||||
|
||||
const useMultipart = sizeBytes > getR2MultipartThresholdBytes();
|
||||
|
||||
let presignedPutUrl = '';
|
||||
let thumbnailPresignedPutUrl: string;
|
||||
let multipartUploadId: string | null = null;
|
||||
let multipart: {
|
||||
uploadId: string;
|
||||
partSizeBytes: number;
|
||||
parts: Array<{ partNumber: number; url: string }>;
|
||||
} | null = null;
|
||||
|
||||
try {
|
||||
if (useMultipart) {
|
||||
const partSize = getR2MultipartPartSizeBytes();
|
||||
const partCount = Number((sizeBytes + partSize - BigInt(1)) / partSize);
|
||||
|
||||
multipartUploadId = await createMultipartVideoUpload(objectKey, contentType);
|
||||
|
||||
try {
|
||||
const [parts, thumbnailUrl] = await Promise.all([
|
||||
Promise.all(
|
||||
Array.from({ length: partCount }, async (_unused, index) => {
|
||||
const partNumber = index + 1;
|
||||
const url = await createPresignedUploadPartUrl(
|
||||
objectKey,
|
||||
multipartUploadId as string,
|
||||
partNumber
|
||||
);
|
||||
return { partNumber, url };
|
||||
})
|
||||
),
|
||||
createPresignedImagePutUrl(thumbnailObjectKey, 'image/jpeg'),
|
||||
]);
|
||||
|
||||
multipart = { uploadId: multipartUploadId, partSizeBytes: Number(partSize), parts };
|
||||
thumbnailPresignedPutUrl = thumbnailUrl;
|
||||
} catch (error) {
|
||||
await abortMultipartVideoUpload(objectKey, multipartUploadId).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
[presignedPutUrl, thumbnailPresignedPutUrl] = await Promise.all([
|
||||
createPresignedVideoPutUrl(objectKey, contentType, sizeBytes),
|
||||
createPresignedImagePutUrl(thumbnailObjectKey, 'image/jpeg'),
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
await releaseStorageReservation(
|
||||
reserveResult.reservationId,
|
||||
project.workspace.ownerId,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
logError('Failed to create presigned video upload URL:', error);
|
||||
return apiErrors.internalError('Failed to initialize video upload');
|
||||
}
|
||||
|
||||
const uploadJti = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + VIDEO_RESERVATION_TTL_MS);
|
||||
const uploadSession = await createR2UploadSession({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
billedUserId: project.workspace.ownerId,
|
||||
objectKey,
|
||||
thumbnailObjectKey,
|
||||
declaredSizeBytes: sizeBytes,
|
||||
contentType,
|
||||
reservationId: reserveResult.reservationId,
|
||||
uploadJti,
|
||||
expiresAt,
|
||||
multipartUploadId,
|
||||
});
|
||||
|
||||
const uploadToken = createR2UploadToken({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey,
|
||||
sessionId: uploadSession.id,
|
||||
tokenId: uploadJti,
|
||||
thumbnailObjectKey,
|
||||
});
|
||||
|
||||
const response = successResponse({
|
||||
presignedPutUrl,
|
||||
objectKey,
|
||||
proxyUrl,
|
||||
uploadToken,
|
||||
reservationId: reserveResult.reservationId,
|
||||
contentType,
|
||||
thumbnailPresignedPutUrl,
|
||||
thumbnailObjectKey,
|
||||
thumbnailProxyUrl,
|
||||
multipart,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error initializing R2 video upload:', error);
|
||||
return apiErrors.internalError('Failed to initialize upload');
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/projects/[projectId]/videos/r2-init
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
if (!isS3VideoUploadsEnabled()) {
|
||||
return apiErrors.badRequest('S3 video uploads are disabled by this host');
|
||||
}
|
||||
|
||||
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
||||
if (!project) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const objectKey = typeof body?.objectKey === 'string' ? body.objectKey.trim() : '';
|
||||
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
||||
const thumbnailObjectKey =
|
||||
typeof body?.thumbnailObjectKey === 'string' ? body.thumbnailObjectKey.trim() : '';
|
||||
|
||||
if (!objectKey || !uploadToken) {
|
||||
return apiErrors.badRequest('objectKey and uploadToken are required');
|
||||
}
|
||||
|
||||
const tokenPayload = parseR2UploadToken(uploadToken);
|
||||
if (!tokenPayload) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyR2UploadToken(uploadToken, {
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey,
|
||||
sessionId: tokenPayload.sid,
|
||||
tokenId: tokenPayload.jti,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const uploadSession = await db.videoUploadSession.findFirst({
|
||||
where: {
|
||||
id: tokenPayload.sid,
|
||||
status: 'INITIATED',
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey,
|
||||
uploadJti: tokenPayload.jti,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
reservationId: true,
|
||||
billedUserId: true,
|
||||
thumbnailObjectKey: true,
|
||||
multipartUploadId: true,
|
||||
},
|
||||
});
|
||||
if (!uploadSession) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
if (thumbnailObjectKey && thumbnailObjectKey !== uploadSession.thumbnailObjectKey) {
|
||||
return apiErrors.badRequest('Invalid thumbnail object key');
|
||||
}
|
||||
|
||||
const cancelled = await db.videoUploadSession.updateMany({
|
||||
where: {
|
||||
id: uploadSession.id,
|
||||
status: 'INITIATED',
|
||||
},
|
||||
data: {
|
||||
status: 'CANCELLED',
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (cancelled.count !== 1) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
uploadSession.multipartUploadId
|
||||
? abortMultipartVideoUpload(objectKey, uploadSession.multipartUploadId)
|
||||
: Promise.resolve(),
|
||||
deleteVideoObject(objectKey),
|
||||
uploadSession.thumbnailObjectKey.startsWith('images/')
|
||||
? deleteR2Object(uploadSession.thumbnailObjectKey)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
} catch (error) {
|
||||
logError('Failed to delete pending R2 video object:', error);
|
||||
}
|
||||
|
||||
await releaseStorageReservation(
|
||||
uploadSession.reservationId,
|
||||
uploadSession.billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error cleaning up pending R2 video upload:', error);
|
||||
return apiErrors.internalError('Failed to cleanup pending upload');
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||
import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
import { readBunnyUploadGrant } from '@/lib/bunny-upload-token';
|
||||
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
||||
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
@@ -75,14 +78,22 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
// Check project access (must be owner, project admin, or workspace admin)
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ownerId: true,
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
// The billed account, which is who the Bunny reservation is held against.
|
||||
workspace: { select: { ownerId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
|
||||
const access = await checkProjectAccess(project, session.user.id);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
@@ -97,19 +108,30 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
thumbnailUrl,
|
||||
duration,
|
||||
uploadToken,
|
||||
objectKey,
|
||||
} = body;
|
||||
|
||||
if (!title || !videoUrl) {
|
||||
return apiErrors.badRequest('Title and video URL are required');
|
||||
}
|
||||
|
||||
// Validate URLs use safe schemes (http/https only)
|
||||
const normalizedProviderIdEarly =
|
||||
typeof providerId === 'string' && providerId.trim()
|
||||
? providerId.trim().toLowerCase()
|
||||
: 'youtube';
|
||||
|
||||
if (normalizedProviderIdEarly === 'r2') {
|
||||
if (!videoUrl.startsWith('/api/upload/video/')) {
|
||||
return apiErrors.badRequest('Video URL must be a valid upload path');
|
||||
}
|
||||
} else {
|
||||
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
||||
if (videoUrlError) {
|
||||
return apiErrors.badRequest(videoUrlError);
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
|
||||
const thumbnailUrlError = validateOptionalUrlOrAppPath(thumbnailUrl, 'Thumbnail URL');
|
||||
if (thumbnailUrlError) {
|
||||
return apiErrors.badRequest(thumbnailUrlError);
|
||||
}
|
||||
@@ -121,21 +143,70 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
|
||||
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||
|
||||
let versionSizeBytes = BigInt(0);
|
||||
let bunnyReservation: string | null = null;
|
||||
let finalizedR2Session: {
|
||||
sessionId: string;
|
||||
reservationId: string | null;
|
||||
billedUserId: string;
|
||||
thumbnailProxyUrl: string;
|
||||
} | null = null;
|
||||
|
||||
if (normalizedProviderId === 'bunny') {
|
||||
if (!normalizedVideoId || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
|
||||
const grant = readBunnyUploadGrant(normalizedUploadToken, {
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoId: normalizedVideoId,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
if (!grant) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
|
||||
// See the versions route: Bunny reports no size at all until it has
|
||||
// finished encoding, so the size the upload was admitted on is what the
|
||||
// account is charged until a real figure arrives.
|
||||
versionSizeBytes = grant.declaredSizeBytes ?? BigInt(0);
|
||||
bunnyReservation = grant.reservationId;
|
||||
} else if (normalizedProviderId === 'r2') {
|
||||
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
|
||||
if (!normalizedObjectKey || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('R2 uploads must include objectKey and uploadToken');
|
||||
}
|
||||
|
||||
const finalizeResult = await finalizeR2VideoUpload({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoUrl,
|
||||
objectKey: normalizedObjectKey,
|
||||
uploadToken: normalizedUploadToken,
|
||||
});
|
||||
if (!finalizeResult.ok) {
|
||||
if (finalizeResult.status === 403) {
|
||||
return apiErrors.forbidden(finalizeResult.error);
|
||||
}
|
||||
return apiErrors.badRequest(finalizeResult.error);
|
||||
}
|
||||
|
||||
versionSizeBytes = finalizeResult.sizeBytes;
|
||||
finalizedR2Session = {
|
||||
sessionId: finalizeResult.sessionId,
|
||||
reservationId: finalizeResult.reservationId,
|
||||
billedUserId: finalizeResult.billedUserId,
|
||||
thumbnailProxyUrl: finalizeResult.thumbnailProxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const persistedVideoId =
|
||||
normalizedProviderId === 'r2'
|
||||
? typeof objectKey === 'string'
|
||||
? objectKey.trim()
|
||||
: ''
|
||||
: normalizedVideoId;
|
||||
|
||||
// Get the next position
|
||||
const lastVideo = await db.video.findFirst({
|
||||
where: { projectId },
|
||||
@@ -144,7 +215,48 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const nextPosition = (lastVideo?.position ?? -1) + 1;
|
||||
|
||||
// Create video with initial version
|
||||
const video = await db.video.create({
|
||||
const video = await db.$transaction(async (tx) => {
|
||||
if (finalizedR2Session) {
|
||||
const consumed = await tx.videoUploadSession.updateMany({
|
||||
where: {
|
||||
id: finalizedR2Session.sessionId,
|
||||
status: 'INITIATED',
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey: persistedVideoId,
|
||||
},
|
||||
data: {
|
||||
status: 'FINALIZED',
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (consumed.count !== 1) {
|
||||
throw new Error('Upload session already consumed');
|
||||
}
|
||||
if (finalizedR2Session.reservationId) {
|
||||
await tx.uploadReservation.deleteMany({
|
||||
where: {
|
||||
id: finalizedR2Session.reservationId,
|
||||
billedUserId: finalizedR2Session.billedUserId,
|
||||
purpose: UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Released in the transaction that records the size, so the bytes are never
|
||||
// counted twice and never counted zero times.
|
||||
if (bunnyReservation) {
|
||||
await tx.uploadReservation.deleteMany({
|
||||
where: {
|
||||
id: bunnyReservation,
|
||||
billedUserId: project.workspace.ownerId,
|
||||
purpose: UPLOAD_RESERVATION_PURPOSES.BUNNY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx.video.create({
|
||||
data: {
|
||||
title: title.trim(),
|
||||
description: description?.trim() || null,
|
||||
@@ -154,11 +266,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
create: {
|
||||
versionNumber: 1,
|
||||
providerId: normalizedProviderId,
|
||||
videoId: normalizedVideoId,
|
||||
videoId: persistedVideoId,
|
||||
originalUrl: videoUrl,
|
||||
title: title.trim(),
|
||||
thumbnailUrl: thumbnailUrl || null,
|
||||
thumbnailUrl:
|
||||
normalizedProviderId === 'r2'
|
||||
? (finalizedR2Session?.thumbnailProxyUrl ?? '/placeholder-video-thumbnail.png')
|
||||
: thumbnailUrl || null,
|
||||
duration: duration || null,
|
||||
sizeBytes: versionSizeBytes,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
@@ -168,6 +284,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
_count: { select: { versions: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Notify project owner (fire-and-forget, skip if they added it themselves)
|
||||
if (project.ownerId !== session.user.id) {
|
||||
@@ -181,6 +298,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}).catch((err) => logError('Notification failed:', err));
|
||||
}
|
||||
|
||||
await recordEvent({
|
||||
name: 'VIDEO_ADDED',
|
||||
dedupeKey: eventKey('VIDEO_ADDED', video.id),
|
||||
userId: project.ownerId,
|
||||
});
|
||||
|
||||
const response = successResponse(video, 201);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
|
||||
+37
-13
@@ -3,10 +3,12 @@ import { db } from '@/lib/db';
|
||||
import { auth, checkWorkspaceAccess } from '@/lib/auth';
|
||||
import { ProjectVisibility } from '@prisma/client';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
||||
import { buildBillingAccessWhereInput, isPaidTier } from '@/lib/billing';
|
||||
import { TRIAL_PROJECT_LIMIT } from '@/lib/trial-limits';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
// GET /api/projects - List all projects for the authenticated user
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -42,22 +44,15 @@ export async function GET(request: NextRequest) {
|
||||
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
|
||||
}
|
||||
|
||||
// Build base filter: user is owner OR a member
|
||||
// Build base filter: user is the project owner, a project member, or a member of the
|
||||
// workspace the project lives in. The third branch used to be dropped whenever a
|
||||
// workspaceId was supplied, so filtering by their own workspace showed a workspace
|
||||
// member an empty list while the unfiltered call returned the same project.
|
||||
const baseFilter: Record<string, unknown> = {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{ members: { some: { userId: session.user.id } } },
|
||||
// Also include projects in workspaces where the user is a workspace member
|
||||
...(workspaceId
|
||||
? []
|
||||
: [
|
||||
{
|
||||
workspace: {
|
||||
owner: buildBillingAccessWhereInput(),
|
||||
members: { some: { userId: session.user.id } },
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ workspace: { members: { some: { userId: session.user.id } } } },
|
||||
],
|
||||
workspace: {
|
||||
owner: buildBillingAccessWhereInput(),
|
||||
@@ -167,6 +162,26 @@ export async function POST(request: NextRequest) {
|
||||
return apiErrors.forbidden('Only workspace owners and admins can create projects');
|
||||
}
|
||||
|
||||
// Counted against the workspace owner rather than the caller, because that is
|
||||
// the account being billed: `ownerId` on the project below is the workspace
|
||||
// owner too. A workspace admin on somebody else's trial hits the same ceiling.
|
||||
const owner = await db.user.findUnique({
|
||||
where: { id: workspace.ownerId },
|
||||
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
||||
});
|
||||
|
||||
if (owner && !isPaidTier(owner)) {
|
||||
const ownedProjectCount = await db.project.count({
|
||||
where: { ownerId: workspace.ownerId },
|
||||
});
|
||||
|
||||
if (ownedProjectCount >= TRIAL_PROJECT_LIMIT) {
|
||||
return apiErrors.forbidden(
|
||||
'Your free trial covers one project at a time. Delete the existing project or subscribe to run more in parallel.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const project = await db.$transaction(async (tx) => {
|
||||
const createdProject = await tx.project.create({
|
||||
data: {
|
||||
@@ -194,6 +209,15 @@ export async function POST(request: NextRequest) {
|
||||
return createdProject;
|
||||
});
|
||||
|
||||
// Attributed to the workspace owner rather than the caller: the funnel asks
|
||||
// which account is progressing, and a team member creating a project moves
|
||||
// the owner's account, not their own.
|
||||
await recordEvent({
|
||||
name: 'PROJECT_CREATED',
|
||||
dedupeKey: eventKey('PROJECT_CREATED', project.id),
|
||||
userId: workspace.ownerId,
|
||||
});
|
||||
|
||||
const response = successResponse(project, 201);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
|
||||
+10
-1
@@ -2,6 +2,7 @@ import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse } from '@/lib/api-response';
|
||||
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
||||
import { checkRateLimit, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
@@ -38,8 +39,15 @@ export async function GET(request: NextRequest) {
|
||||
return apiErrors.badRequest('Query too long.');
|
||||
}
|
||||
|
||||
// Access filter reused across queries
|
||||
// Access filter reused across queries. The billing condition is the same one every
|
||||
// other read path carries (GET /api/projects, checkProjectAccess): without it search
|
||||
// kept returning project names, descriptions and video titles for a tenant whose
|
||||
// access had otherwise been cut off, which is a lapsed-tenant surface no other read
|
||||
// path leaves open.
|
||||
const ownerWithBillingAccess = buildBillingAccessWhereInput();
|
||||
|
||||
const projectAccessFilter = {
|
||||
workspace: { owner: ownerWithBillingAccess },
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{ members: { some: { userId } } },
|
||||
@@ -48,6 +56,7 @@ export async function GET(request: NextRequest) {
|
||||
};
|
||||
|
||||
const workspaceAccessFilter = {
|
||||
owner: ownerWithBillingAccess,
|
||||
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
||||
};
|
||||
|
||||
|
||||
@@ -191,9 +191,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
const fromAddress =
|
||||
process.env.SMTP_FROM ||
|
||||
process.env.EMAIL_FROM ||
|
||||
'OpenFrame <[email protected]>';
|
||||
process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { getUserStorageInfo } from '@/lib/storage-quota';
|
||||
import { getStorageContextForUser, getUserStorageInfo } from '@/lib/storage-quota';
|
||||
import { hasBillingAccess } from '@/lib/billing';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
@@ -27,12 +27,19 @@ export async function GET() {
|
||||
return apiErrors.forbidden();
|
||||
}
|
||||
|
||||
const info = await getUserStorageInfo(session.user.id);
|
||||
const [info, storage] = await Promise.all([
|
||||
getUserStorageInfo(session.user.id),
|
||||
getStorageContextForUser(session.user.id),
|
||||
]);
|
||||
|
||||
const response = successResponse({
|
||||
usedBytes: info.usedBytes.toString(),
|
||||
limitBytes: info.limitBytes.toString(),
|
||||
percentage: info.percentage,
|
||||
// Which ceiling this is, so the card can name it and say what to do about it.
|
||||
// A trial has 3 GB because it has not subscribed; deleting files is the wrong
|
||||
// advice there, and "200 GB limit" was the wrong caption.
|
||||
isPaid: storage.isPaid,
|
||||
});
|
||||
|
||||
// Cache for 60s — stale data is acceptable for a usage meter
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import type Stripe from 'stripe';
|
||||
import { markSubscriptionCanceledByCustomerId, syncStripeSubscriptionToUser } from '@/lib/billing';
|
||||
import { syncStripeCustomerSubscriptions } from '@/lib/billing';
|
||||
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
const currentPeriodEnd =
|
||||
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
|
||||
? new Date(subscription.current_period_end * 1000)
|
||||
: null;
|
||||
const endedAt =
|
||||
'ended_at' in subscription && typeof subscription.ended_at === 'number'
|
||||
? new Date(subscription.ended_at * 1000)
|
||||
: currentPeriodEnd;
|
||||
|
||||
await markSubscriptionCanceledByCustomerId(customerId, {
|
||||
currentPeriodEnd,
|
||||
endedAt,
|
||||
});
|
||||
function getCustomerId(
|
||||
customer: string | Stripe.Customer | Stripe.DeletedCustomer | null
|
||||
): string | null {
|
||||
if (!customer) return null;
|
||||
return typeof customer === 'string' ? customer : customer.id;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -43,30 +31,28 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
|
||||
// Every subscription-related event re-derives the user's state from the
|
||||
// full set of the customer's Stripe subscriptions, so a stale event (e.g.
|
||||
// an old subscription being deleted) can never clobber a newer active one.
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
if (session.mode === 'subscription' && session.subscription) {
|
||||
const subscriptionId =
|
||||
typeof session.subscription === 'string'
|
||||
? session.subscription
|
||||
: session.subscription.id;
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
await syncStripeSubscriptionToUser(subscription);
|
||||
if (session.mode === 'subscription') {
|
||||
const customerId = getCustomerId(session.customer);
|
||||
if (customerId) {
|
||||
await syncStripeCustomerSubscriptions(customerId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await syncStripeSubscriptionToUser(subscription);
|
||||
break;
|
||||
}
|
||||
case 'customer.subscription.updated':
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await handleSubscriptionDeleted(subscription);
|
||||
const customerId = getCustomerId(subscription.customer);
|
||||
if (customerId) {
|
||||
await syncStripeCustomerSubscriptions(customerId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -39,38 +39,50 @@ export async function GET(
|
||||
// Parallelize the DB lookup and session check to narrow the timing delta
|
||||
// between "asset not found" and "asset found, access denied" responses.
|
||||
const voiceUrl = `/api/upload/audio/${filename}`;
|
||||
const [comment, session] = await Promise.all([
|
||||
db.comment.findFirst({
|
||||
where: { voiceUrl },
|
||||
select: {
|
||||
version: {
|
||||
select: {
|
||||
video: {
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {
|
||||
const projectSelect = {
|
||||
id: true,
|
||||
ownerId: true,
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
} as const;
|
||||
const videoSelect = {
|
||||
id: true,
|
||||
projectId: true,
|
||||
project: { select: projectSelect },
|
||||
} as const;
|
||||
const [comments, videoAssets, session] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: { voiceUrl },
|
||||
take: 2,
|
||||
select: {
|
||||
version: {
|
||||
select: { video: { select: videoSelect } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.videoAsset.findMany({
|
||||
where: { sourceUrl: voiceUrl },
|
||||
take: 2,
|
||||
select: { video: { select: videoSelect } },
|
||||
}),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
if (!comment) {
|
||||
const uniqueVideos = new Map<string, (typeof videoAssets)[number]['video']>();
|
||||
comments.forEach((comment) => {
|
||||
if (comment.version?.video) uniqueVideos.set(comment.version.video.id, comment.version.video);
|
||||
});
|
||||
videoAssets.forEach((videoAsset) => uniqueVideos.set(videoAsset.video.id, videoAsset.video));
|
||||
|
||||
if (uniqueVideos.size > 1) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const video = uniqueVideos.values().next().value ?? null;
|
||||
if (!video) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const { video } = comment.version;
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
|
||||
if (!access.hasAccess) {
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
enforceGuestUploadQuota,
|
||||
verifyGuestUploadToken,
|
||||
} from '@/lib/guest-upload-token';
|
||||
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
|
||||
import {
|
||||
reserveStorageQuota,
|
||||
releaseStorageReservation,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
} from '@/lib/storage-quota';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -37,6 +41,9 @@ const MIME_ALIASES: Record<string, string> = {
|
||||
'audio/x-pn-wav': 'audio/wav',
|
||||
'audio/mp3': 'audio/mpeg',
|
||||
'audio/x-mpeg': 'audio/mpeg',
|
||||
// Some browsers report MediaRecorder audio-only blobs as video/* containers.
|
||||
'video/webm': 'audio/webm',
|
||||
'video/mp4': 'audio/mp4',
|
||||
};
|
||||
|
||||
// Map canonical MIME to fallback file extension
|
||||
@@ -202,7 +209,11 @@ export async function POST(request: NextRequest) {
|
||||
// All paths use the advisory-locked reservation so concurrent uploads always
|
||||
// see each other's in-flight sizes, eliminating the TOCTOU race.
|
||||
const workspaceOwnerId = video.project.workspace.ownerId;
|
||||
const reserveResult = await reserveStorageQuota(workspaceOwnerId, BigInt(file.size));
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
workspaceOwnerId,
|
||||
BigInt(file.size),
|
||||
UPLOAD_RESERVATION_PURPOSES.AUDIO
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
const reservationId = reserveResult.reservationId;
|
||||
|
||||
@@ -211,7 +222,11 @@ export async function POST(request: NextRequest) {
|
||||
const strippedType = rawContentType.split(';')[0].trim().toLowerCase();
|
||||
const contentType = MIME_ALIASES[strippedType] ?? strippedType;
|
||||
if (!ALLOWED_TYPES.has(contentType)) {
|
||||
await releaseStorageReservation(reservationId);
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
workspaceOwnerId,
|
||||
UPLOAD_RESERVATION_PURPOSES.AUDIO
|
||||
);
|
||||
return apiErrors.badRequest(`Unsupported audio format: ${rawContentType}`);
|
||||
}
|
||||
|
||||
@@ -228,11 +243,20 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Validate file content against magic bytes — rejects HTML/scripts masquerading as audio
|
||||
if (isHtmlContent(buffer)) {
|
||||
await releaseStorageReservation(reservationId);
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
workspaceOwnerId,
|
||||
UPLOAD_RESERVATION_PURPOSES.AUDIO
|
||||
);
|
||||
return apiErrors.badRequest('File content does not match an audio format');
|
||||
}
|
||||
if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) {
|
||||
await releaseStorageReservation(reservationId);
|
||||
const hasValidMagicBytes = hasValidAudioMagicBytes(buffer.slice(0, 16), contentType);
|
||||
if (!hasValidMagicBytes) {
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
workspaceOwnerId,
|
||||
UPLOAD_RESERVATION_PURPOSES.AUDIO
|
||||
);
|
||||
return apiErrors.badRequest('File content does not match the declared audio format');
|
||||
}
|
||||
|
||||
@@ -247,7 +271,11 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
);
|
||||
} catch (uploadError) {
|
||||
await releaseStorageReservation(reservationId);
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
workspaceOwnerId,
|
||||
UPLOAD_RESERVATION_PURPOSES.AUDIO
|
||||
);
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,23 +48,43 @@ export async function GET(
|
||||
projectId: true,
|
||||
project: { select: projectSelect },
|
||||
} as const;
|
||||
const [comment, videoAsset, session] = await Promise.all([
|
||||
db.comment.findFirst({
|
||||
const [comments, videoAssets, videoVersions, session] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: { imageUrl },
|
||||
take: 2,
|
||||
select: {
|
||||
version: {
|
||||
select: { video: { select: videoSelect } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.videoAsset.findFirst({
|
||||
db.videoAsset.findMany({
|
||||
where: { sourceUrl: imageUrl },
|
||||
take: 2,
|
||||
select: { video: { select: videoSelect } },
|
||||
}),
|
||||
db.videoVersion.findMany({
|
||||
where: { thumbnailUrl: imageUrl },
|
||||
take: 2,
|
||||
select: { video: { select: videoSelect } },
|
||||
}),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
const video = comment?.version?.video ?? videoAsset?.video ?? null;
|
||||
const uniqueVideos = new Map<string, (typeof videoAssets)[number]['video']>();
|
||||
comments.forEach((comment) => {
|
||||
if (comment.version?.video) uniqueVideos.set(comment.version.video.id, comment.version.video);
|
||||
});
|
||||
videoAssets.forEach((videoAsset) => uniqueVideos.set(videoAsset.video.id, videoAsset.video));
|
||||
videoVersions.forEach((videoVersion) =>
|
||||
uniqueVideos.set(videoVersion.video.id, videoVersion.video)
|
||||
);
|
||||
|
||||
if (uniqueVideos.size > 1) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const video = uniqueVideos.values().next().value ?? null;
|
||||
if (!video) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
verifyGuestUploadToken,
|
||||
} from '@/lib/guest-upload-token';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
|
||||
import {
|
||||
reserveStorageQuota,
|
||||
releaseStorageReservation,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
} from '@/lib/storage-quota';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + 512 * 1024; // file + multipart overhead
|
||||
@@ -137,14 +141,22 @@ export async function POST(request: NextRequest) {
|
||||
// All paths use the advisory-locked reservation so concurrent uploads always
|
||||
// see each other's in-flight sizes, eliminating the TOCTOU race.
|
||||
const workspaceOwnerId = video.project.workspace.ownerId;
|
||||
const reserveResult = await reserveStorageQuota(workspaceOwnerId, BigInt(file.size));
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
workspaceOwnerId,
|
||||
BigInt(file.size),
|
||||
UPLOAD_RESERVATION_PURPOSES.IMAGE
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
const reservationId = reserveResult.reservationId;
|
||||
|
||||
// Check content type
|
||||
const normalizedMime = normalizeImageMime(file.type);
|
||||
if (normalizedMime && !isAllowedImageType(normalizedMime)) {
|
||||
await releaseStorageReservation(reservationId);
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
workspaceOwnerId,
|
||||
UPLOAD_RESERVATION_PURPOSES.IMAGE
|
||||
);
|
||||
return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
|
||||
}
|
||||
|
||||
@@ -153,7 +165,11 @@ export async function POST(request: NextRequest) {
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const detectedMime = detectImageMime(buffer);
|
||||
if (!detectedMime) {
|
||||
await releaseStorageReservation(reservationId);
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
workspaceOwnerId,
|
||||
UPLOAD_RESERVATION_PURPOSES.IMAGE
|
||||
);
|
||||
return apiErrors.badRequest('Uploaded file content does not match an allowed image type');
|
||||
}
|
||||
|
||||
@@ -173,7 +189,11 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
);
|
||||
} catch (uploadError) {
|
||||
await releaseStorageReservation(reservationId);
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
workspaceOwnerId,
|
||||
UPLOAD_RESERVATION_PURPOSES.IMAGE
|
||||
);
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors } from '@/lib/api-response';
|
||||
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
|
||||
import { buildVideoObjectKey, SAFE_VIDEO_BASENAME } from '@/lib/video-upload-validation';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const VIDEO_CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
ogg: 'video/ogg',
|
||||
mov: 'video/quicktime',
|
||||
m4v: 'video/mp4',
|
||||
mkv: 'video/x-matroska',
|
||||
avi: 'video/x-msvideo',
|
||||
};
|
||||
|
||||
function getVideoContentType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
return VIDEO_CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> }
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
|
||||
if (!SAFE_VIDEO_BASENAME.test(filename)) {
|
||||
return apiErrors.badRequest('Invalid filename');
|
||||
}
|
||||
|
||||
const originalUrl = `/api/upload/video/${filename}`;
|
||||
const projectSelect = {
|
||||
id: true,
|
||||
ownerId: true,
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
} as const;
|
||||
const videoSelect = {
|
||||
id: true,
|
||||
projectId: true,
|
||||
project: { select: projectSelect },
|
||||
} as const;
|
||||
|
||||
const [versions, assets, session] = await Promise.all([
|
||||
db.videoVersion.findMany({
|
||||
where: { originalUrl },
|
||||
take: 2,
|
||||
select: {
|
||||
id: true,
|
||||
video: { select: videoSelect },
|
||||
},
|
||||
}),
|
||||
db.videoAsset.findMany({
|
||||
where: { sourceUrl: originalUrl },
|
||||
take: 2,
|
||||
select: {
|
||||
id: true,
|
||||
video: { select: videoSelect },
|
||||
},
|
||||
}),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
const uniqueVideos = new Map<string, (typeof versions)[number]['video']>();
|
||||
for (const version of versions) {
|
||||
uniqueVideos.set(version.video.id, version.video);
|
||||
}
|
||||
for (const asset of assets) {
|
||||
uniqueVideos.set(asset.video.id, asset.video);
|
||||
}
|
||||
if (uniqueVideos.size > 1) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const video = uniqueVideos.values().next().value ?? null;
|
||||
if (!video) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
|
||||
if (!access.hasAccess) {
|
||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: video.projectId,
|
||||
videoId: video.id,
|
||||
requiredPermission: 'VIEW',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: {
|
||||
hasAccess: false,
|
||||
canComment: false,
|
||||
canDownload: false,
|
||||
allowGuests: false,
|
||||
requiresPassword: false,
|
||||
};
|
||||
|
||||
if (!shareAccess.hasAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
const key = buildVideoObjectKey(filename);
|
||||
return proxyR2MediaObject({
|
||||
request,
|
||||
key,
|
||||
fallbackContentType: getVideoContentType(filename),
|
||||
cacheControl: 'private, max-age=3600',
|
||||
internalErrorMessage: 'Failed to load video',
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Error serving video upload:', error);
|
||||
return apiErrors.internalError('Failed to load video');
|
||||
}
|
||||
}
|
||||
@@ -84,9 +84,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
if (!version) return apiErrors.notFound('Version');
|
||||
|
||||
const access = await checkProjectAccess(version.video.project, session.user.id, {
|
||||
intent: 'manage',
|
||||
});
|
||||
const access = await checkProjectAccess(version.video.project, session.user.id);
|
||||
if (!access.canEdit) return apiErrors.forbidden('Access denied');
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
|
||||
@@ -6,52 +6,31 @@ import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import {
|
||||
ensureGuestIdentityFromRequest,
|
||||
getGuestIdentityFromRequest,
|
||||
setGuestIdentityCookie,
|
||||
} from '@/lib/guest-identity';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
import {
|
||||
extractImageFileNameFromProxyUrl,
|
||||
extractAudioFileNameFromProxyUrl,
|
||||
sanitizeAssetDisplayName,
|
||||
} from '@/lib/video-assets';
|
||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||
import { parseCommentImageUrls } from '@/lib/comment-images';
|
||||
import { isFreshAttachment } from '@/lib/upload-freshness';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
|
||||
import {
|
||||
reserveStorageQuota,
|
||||
releaseStorageReservation,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
} from '@/lib/storage-quota';
|
||||
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
||||
|
||||
type RouteParams = { params: Promise<{ versionId: string }> };
|
||||
const SAFE_IMAGE_PATH =
|
||||
/^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
const SAFE_AUDIO_PATH =
|
||||
/^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint };
|
||||
|
||||
async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise<AttachmentCheck> {
|
||||
const prefix = kind === 'audio' ? '/api/upload/audio/' : '/api/upload/image/';
|
||||
if (!url.startsWith(prefix)) return { isFresh: false, sizeBytes: BigInt(0) };
|
||||
|
||||
const filename = url.slice(prefix.length);
|
||||
const key = kind === 'audio' ? `voice/${filename}` : `images/${filename}`;
|
||||
|
||||
try {
|
||||
const head = await r2Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
|
||||
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
||||
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
|
||||
} catch {
|
||||
return { isFresh: false, sizeBytes: BigInt(0) };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEtag(value: string): string {
|
||||
return value.trim().replace(/^W\//, '');
|
||||
@@ -147,6 +126,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -169,6 +149,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -200,6 +181,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
// POST /api/versions/[versionId]/comments
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
let attachmentReservationId: string | null = null;
|
||||
// Carried out of the try so the catch below can scope the release to the
|
||||
// account the hold was opened against.
|
||||
let attachmentBilledUserId: string | null = null;
|
||||
try {
|
||||
const limited = await rateLimit(request, 'comment');
|
||||
if (limited) return limited;
|
||||
@@ -266,21 +250,64 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
guestName,
|
||||
guestEmail,
|
||||
tagId,
|
||||
imageUrl,
|
||||
annotationData,
|
||||
} = body;
|
||||
|
||||
// A comment carries a list of images now; `imageUrl` is still accepted as a
|
||||
// one-element list so an older client keeps working.
|
||||
const imageUrlsResult = parseCommentImageUrls(body);
|
||||
if ('error' in imageUrlsResult) {
|
||||
return apiErrors.badRequest(imageUrlsResult.error);
|
||||
}
|
||||
const attachedImageUrls = imageUrlsResult.urls;
|
||||
const primaryImageUrl = attachedImageUrls[0] ?? null;
|
||||
|
||||
// Validate required fields
|
||||
if (timestamp === undefined || timestamp === null) {
|
||||
return apiErrors.badRequest('Timestamp is required');
|
||||
}
|
||||
|
||||
const parsedTimestamp = parseFloat(timestamp);
|
||||
if (isNaN(parsedTimestamp)) {
|
||||
return apiErrors.badRequest('Timestamp must be a valid number');
|
||||
const maxTimestamp =
|
||||
typeof version.duration === 'number' && Number.isFinite(version.duration)
|
||||
? version.duration
|
||||
: null;
|
||||
|
||||
const parseCommentTimestamp = (value: unknown, fieldName: string) => {
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return {
|
||||
error: apiErrors.badRequest(`${fieldName} must be a finite non-negative number`),
|
||||
};
|
||||
}
|
||||
|
||||
if (!content && !voiceUrl && !imageUrl && !annotationData) {
|
||||
if (maxTimestamp !== null && parsed > maxTimestamp) {
|
||||
return {
|
||||
error: apiErrors.badRequest(`${fieldName} must be less than or equal to video duration`),
|
||||
};
|
||||
}
|
||||
|
||||
return { value: parsed };
|
||||
};
|
||||
|
||||
const parsedTimestampResult = parseCommentTimestamp(timestamp, 'Timestamp');
|
||||
if ('error' in parsedTimestampResult) {
|
||||
return parsedTimestampResult.error;
|
||||
}
|
||||
const parsedTimestamp = parsedTimestampResult.value;
|
||||
|
||||
let parsedTimestampEnd: number | null = null;
|
||||
if (timestampEnd !== undefined && timestampEnd !== null) {
|
||||
const parsedTimestampEndResult = parseCommentTimestamp(timestampEnd, 'Timestamp end');
|
||||
if ('error' in parsedTimestampEndResult) {
|
||||
return parsedTimestampEndResult.error;
|
||||
}
|
||||
parsedTimestampEnd = parsedTimestampEndResult.value;
|
||||
if (parsedTimestampEnd < parsedTimestamp) {
|
||||
return apiErrors.badRequest('Timestamp end must be greater than or equal to timestamp');
|
||||
}
|
||||
}
|
||||
|
||||
if (!content && !voiceUrl && attachedImageUrls.length === 0 && !annotationData) {
|
||||
return apiErrors.badRequest(
|
||||
'Either content, a voice recording, an image attachment, or an annotation is required'
|
||||
);
|
||||
@@ -293,14 +320,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (guestName !== undefined && guestName !== null && String(guestName).length > 100) {
|
||||
return apiErrors.badRequest('Guest name must be 100 characters or fewer');
|
||||
}
|
||||
let normalizedGuestEmail: string | null = null;
|
||||
if (guestEmail !== undefined && guestEmail !== null) {
|
||||
const emailStr = String(guestEmail);
|
||||
if (emailStr.length > 254) {
|
||||
return apiErrors.badRequest('Guest email must be 254 characters or fewer');
|
||||
}
|
||||
// RFC 5321 / HTML5 email pattern — simple but sufficient for a stored-value guard
|
||||
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRe.test(emailStr)) {
|
||||
normalizedGuestEmail = normalizeEmail(String(guestEmail));
|
||||
if (!isValidEmailAddress(normalizedGuestEmail)) {
|
||||
return apiErrors.badRequest('Guest email must be a valid email address');
|
||||
}
|
||||
}
|
||||
@@ -362,17 +385,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
voiceSizeBytes = voiceCheck.sizeBytes;
|
||||
}
|
||||
|
||||
if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) {
|
||||
return apiErrors.badRequest('Image URL must reference an uploaded image file');
|
||||
}
|
||||
let imageSizeBytes = BigInt(0);
|
||||
if (imageUrl) {
|
||||
const imageCheck = await isFreshAttachment(imageUrl, 'image');
|
||||
if (!imageCheck.isFresh) {
|
||||
// The uploads happened in parallel, so check them the same way rather than
|
||||
// paying one R2 round trip per screenshot.
|
||||
const imageChecks = await Promise.all(
|
||||
attachedImageUrls.map(async (url) => ({ url, ...(await isFreshAttachment(url, 'image')) }))
|
||||
);
|
||||
if (imageChecks.some((check) => !check.isFresh)) {
|
||||
return apiErrors.badRequest('Image upload expired. Please upload again.');
|
||||
}
|
||||
imageSizeBytes = imageCheck.sizeBytes;
|
||||
}
|
||||
const imageSizeBytes = imageChecks.reduce((total, check) => total + check.sizeBytes, BigInt(0));
|
||||
|
||||
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
|
||||
|
||||
@@ -383,10 +404,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (totalAttachmentBytes > BigInt(0)) {
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
project.workspace.ownerId,
|
||||
totalAttachmentBytes
|
||||
totalAttachmentBytes,
|
||||
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
attachmentReservationId = reserveResult.reservationId;
|
||||
attachmentBilledUserId = project.workspace.ownerId;
|
||||
}
|
||||
|
||||
// Use a transaction to create both the comment and any asset rows atomically.
|
||||
@@ -394,22 +417,29 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
if (attachmentReservationId) {
|
||||
await tx.uploadReservation.deleteMany({
|
||||
where: { id: attachmentReservationId, billedUserId: project.workspace.ownerId },
|
||||
where: {
|
||||
id: attachmentReservationId,
|
||||
billedUserId: project.workspace.ownerId,
|
||||
purpose: UPLOAD_RESERVATION_PURPOSES.ATTACHMENT,
|
||||
},
|
||||
});
|
||||
}
|
||||
const comment = await tx.comment.create({
|
||||
data: {
|
||||
content: content?.trim() || null,
|
||||
timestamp: parsedTimestamp,
|
||||
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
|
||||
timestampEnd: parsedTimestampEnd,
|
||||
parentId: parentId || null,
|
||||
voiceUrl: voiceUrl || null,
|
||||
voiceDuration: voiceDuration || null,
|
||||
imageUrl: imageUrl || null,
|
||||
imageUrl: primaryImageUrl,
|
||||
images: {
|
||||
create: attachedImageUrls.map((url, index) => ({ url, position: index })),
|
||||
},
|
||||
annotationData: serializedAnnotationData,
|
||||
authorId: session?.user?.id || null,
|
||||
guestName: isGuest ? guestName : null,
|
||||
guestEmail: isGuest ? guestEmail : null,
|
||||
guestEmail: isGuest ? normalizedGuestEmail : null,
|
||||
guestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
|
||||
tagId: tagId || null,
|
||||
versionId,
|
||||
@@ -417,18 +447,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
replies: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
tag: { select: { id: true, name: true, color: true } },
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// If an image was attached to the comment, also add it to the assets pane
|
||||
if (imageUrl) {
|
||||
const fileName = extractImageFileNameFromProxyUrl(imageUrl);
|
||||
// Every attached image also shows up in the assets pane
|
||||
for (const check of imageChecks) {
|
||||
const fileName = extractImageFileNameFromProxyUrl(check.url);
|
||||
const displayName = sanitizeAssetDisplayName(null, fileName || 'Comment Image');
|
||||
const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80);
|
||||
|
||||
@@ -438,9 +470,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
kind: 'IMAGE',
|
||||
provider: 'R2_IMAGE',
|
||||
displayName,
|
||||
sourceUrl: imageUrl,
|
||||
thumbnailUrl: imageUrl,
|
||||
sizeBytes: imageSizeBytes,
|
||||
sourceUrl: check.url,
|
||||
thumbnailUrl: check.url,
|
||||
sizeBytes: check.sizeBytes,
|
||||
uploadedByUserId: session?.user?.id || null,
|
||||
uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
|
||||
uploadedByGuestName: isGuest ? safeGuestName : null,
|
||||
@@ -497,7 +529,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
projectName: project.name,
|
||||
videoTitle,
|
||||
replyAuthor: commentAuthorName,
|
||||
replyText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
|
||||
replyText: content?.trim() || (primaryImageUrl ? '(image attachment)' : '(voice note)'),
|
||||
parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone',
|
||||
timestamp: ts,
|
||||
url: `${baseUrl}/watch/${version.video.id}`,
|
||||
@@ -508,13 +540,25 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
projectName: project.name,
|
||||
videoTitle,
|
||||
commentAuthor: commentAuthorName,
|
||||
commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
|
||||
commentText: content?.trim() || (primaryImageUrl ? '(image attachment)' : '(voice note)'),
|
||||
timestamp: ts,
|
||||
url: `${baseUrl}/watch/${version.video.id}`,
|
||||
}).catch((err) => logError('Notification failed:', err));
|
||||
}
|
||||
}
|
||||
|
||||
// Feedback arriving from outside the team is the moment this product
|
||||
// becomes worth paying for, so it is the activation step of the funnel.
|
||||
// Keyed on the account, not the comment: what matters is the first time an
|
||||
// account ever received one.
|
||||
if (isGuest) {
|
||||
await recordEvent({
|
||||
name: 'FIRST_GUEST_COMMENT',
|
||||
dedupeKey: eventKey('FIRST_GUEST_COMMENT', project.workspace.ownerId),
|
||||
userId: project.workspace.ownerId,
|
||||
});
|
||||
}
|
||||
|
||||
const viewerUserId = session?.user?.id ?? null;
|
||||
const viewerGuestIdentityId = viewerUserId
|
||||
? null
|
||||
@@ -541,7 +585,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
await releaseStorageReservation(attachmentReservationId);
|
||||
await releaseStorageReservation(
|
||||
attachmentReservationId,
|
||||
attachmentBilledUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
|
||||
);
|
||||
logError('Error creating comment:', error);
|
||||
return apiErrors.internalError('Failed to create comment');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { DownloadEgressSource } from '@prisma/client';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { canDownloadProjectMedia } from '@/lib/project-download';
|
||||
|
||||
type RouteParams = { params: Promise<{ versionId: string }> };
|
||||
type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed';
|
||||
@@ -305,7 +306,17 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
requiresPassword: false,
|
||||
};
|
||||
const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
||||
if (!access.hasAccess && !canDownloadViaShareLink) {
|
||||
const canDownloadViaMembership = canDownloadProjectMedia(version.video.project, access);
|
||||
if (!canDownloadViaMembership && !canDownloadViaShareLink) {
|
||||
// A caller with no relationship to the project at all is told the version does not
|
||||
// exist, matching the comment export route: answering 403 for an id belonging to
|
||||
// another tenant confirms that the id exists. Anyone who does have a relationship,
|
||||
// including an owner whose billing has lapsed, already knows it exists and gets the
|
||||
// more informative 403.
|
||||
const belongsToProject = access.isOwner || access.isProjectMember || access.isWorkspaceMember;
|
||||
if (!belongsToProject && !shareAccess.hasAccess) {
|
||||
return apiErrors.notFound('Version');
|
||||
}
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -313,8 +324,13 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.badRequest('Download is currently supported for Bunny versions only');
|
||||
}
|
||||
|
||||
if (sourceParam !== null && sourceParam !== 'original' && sourceParam !== 'compressed') {
|
||||
return apiErrors.badRequest('Invalid source. Allowed values: original, compressed');
|
||||
if (
|
||||
sourceParam !== null &&
|
||||
sourceParam !== 'auto' &&
|
||||
sourceParam !== 'original' &&
|
||||
sourceParam !== 'compressed'
|
||||
) {
|
||||
return apiErrors.badRequest('Invalid source. Allowed values: auto, original, compressed');
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -8,8 +8,11 @@ import { db } from '@/lib/db';
|
||||
import {
|
||||
extractImageFileNameFromProxyUrl,
|
||||
extractAudioFileNameFromProxyUrl,
|
||||
extractVideoFileNameFromProxyUrl,
|
||||
getVideoAssetAccessContext,
|
||||
withFileExtension,
|
||||
} from '@/lib/video-assets';
|
||||
import { buildVideoObjectKey } from '@/lib/video-upload-validation';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
|
||||
@@ -35,6 +38,16 @@ const AUDIO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
};
|
||||
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
|
||||
|
||||
const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
ogg: 'video/ogg',
|
||||
mov: 'video/quicktime',
|
||||
m4v: 'video/mp4',
|
||||
mkv: 'video/x-matroska',
|
||||
avi: 'video/x-msvideo',
|
||||
};
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
const sanitized = value
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
||||
@@ -43,6 +56,10 @@ function sanitizeFileName(value: string): string {
|
||||
return sanitized.length > 0 ? sanitized : 'asset';
|
||||
}
|
||||
|
||||
function withExtension(displayName: string, extension: string): string {
|
||||
return withFileExtension(sanitizeFileName(displayName), extension);
|
||||
}
|
||||
|
||||
function toAsciiFileName(value: string): string {
|
||||
const normalized = value
|
||||
.normalize('NFKD')
|
||||
@@ -72,9 +89,17 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const { videoId, assetId } = await params;
|
||||
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
|
||||
if (!context) return apiErrors.notFound('Video');
|
||||
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
|
||||
if (!context.viewerUserId || !context.canDownloadAssets) {
|
||||
return apiErrors.forbidden('Asset downloads require an authenticated account');
|
||||
// A caller with no relationship to the project is told the video does not exist.
|
||||
// Answering 403 for an id belonging to another tenant confirms that the id exists,
|
||||
// and the comment export route already answers 404 for the identical shape. Somebody
|
||||
// who does belong, including an owner whose billing lapsed, gets the 403.
|
||||
if (!context.hasViewAccess) {
|
||||
return context.viewerBelongsToProject
|
||||
? apiErrors.forbidden('Access denied')
|
||||
: apiErrors.notFound('Video');
|
||||
}
|
||||
if (!context.canDownloadAssets) {
|
||||
return apiErrors.forbidden('Downloads are disabled for this project');
|
||||
}
|
||||
|
||||
const asset = await db.videoAsset.findFirst({
|
||||
@@ -97,7 +122,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
if (!fileName) return apiErrors.badRequest('Invalid image asset URL');
|
||||
const key = `images/${fileName}`;
|
||||
const extension = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.png';
|
||||
const downloadName = `${sanitizeFileName(asset.displayName)}${extension}`;
|
||||
const downloadName = withExtension(asset.displayName, extension);
|
||||
const contentDisposition = buildContentDisposition(downloadName);
|
||||
|
||||
return proxyR2MediaObject({
|
||||
@@ -119,7 +144,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
if (!fileName) return apiErrors.badRequest('Invalid audio asset URL');
|
||||
const key = `voice/${fileName}`;
|
||||
const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.webm';
|
||||
const downloadName = `${sanitizeFileName(asset.displayName)}${ext}`;
|
||||
const downloadName = withExtension(asset.displayName, ext);
|
||||
const contentDisposition = buildContentDisposition(downloadName);
|
||||
const extKey = ext.replace('.', '');
|
||||
const contentType = AUDIO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'audio/webm';
|
||||
@@ -137,6 +162,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
}
|
||||
|
||||
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
||||
const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl);
|
||||
if (!fileName) return apiErrors.badRequest('Invalid video asset URL');
|
||||
const key = buildVideoObjectKey(fileName);
|
||||
const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.mp4';
|
||||
const downloadName = withExtension(asset.displayName, ext);
|
||||
const contentDisposition = buildContentDisposition(downloadName);
|
||||
const extKey = ext.replace('.', '');
|
||||
const contentType = VIDEO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'video/mp4';
|
||||
|
||||
return proxyR2MediaObject({
|
||||
request,
|
||||
key,
|
||||
fallbackContentType: contentType,
|
||||
cacheControl: 'private, no-store',
|
||||
extraHeaders: {
|
||||
'Content-Disposition': contentDisposition,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
internalErrorMessage: 'Failed to retrieve video',
|
||||
});
|
||||
}
|
||||
|
||||
const sourceParam = request.nextUrl.searchParams.get('source');
|
||||
const rawQuality = request.nextUrl.searchParams.get('quality');
|
||||
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
|
||||
|
||||
@@ -29,6 +29,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
provider: true,
|
||||
sourceUrl: true,
|
||||
providerVideoId: true,
|
||||
thumbnailUrl: true,
|
||||
uploadedByUserId: true,
|
||||
uploadedByGuestIdentityId: true,
|
||||
},
|
||||
@@ -41,13 +42,15 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
let shouldDeleteImageObject = false;
|
||||
let shouldDeleteAudioObject = false;
|
||||
let shouldDeleteVideoObject = false;
|
||||
let shouldDeleteVideoThumbnail = false;
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.videoAsset.delete({ where: { id: asset.id } });
|
||||
|
||||
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
|
||||
const [assetReferenceCount, commentReferenceCount] = await Promise.all([
|
||||
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
|
||||
tx.comment.count({ where: { imageUrl: asset.sourceUrl } }),
|
||||
tx.commentImage.count({ where: { url: asset.sourceUrl } }),
|
||||
]);
|
||||
shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||
}
|
||||
@@ -59,6 +62,22 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
]);
|
||||
shouldDeleteAudioObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||
}
|
||||
|
||||
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
||||
const [assetReferenceCount, versionReferenceCount] = await Promise.all([
|
||||
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
|
||||
tx.videoVersion.count({ where: { originalUrl: asset.sourceUrl } }),
|
||||
]);
|
||||
shouldDeleteVideoObject = assetReferenceCount === 0 && versionReferenceCount === 0;
|
||||
|
||||
if (asset.thumbnailUrl) {
|
||||
const [assetThumbnailCount, commentImageCount] = await Promise.all([
|
||||
tx.videoAsset.count({ where: { thumbnailUrl: asset.thumbnailUrl } }),
|
||||
tx.commentImage.count({ where: { url: asset.thumbnailUrl } }),
|
||||
]);
|
||||
shouldDeleteVideoThumbnail = assetThumbnailCount === 0 && commentImageCount === 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
|
||||
@@ -68,6 +87,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
if (asset.provider === VideoAssetProvider.R2_AUDIO && shouldDeleteAudioObject) {
|
||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||
}
|
||||
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
||||
const urlsToDelete: string[] = [];
|
||||
if (shouldDeleteVideoObject && asset.sourceUrl) {
|
||||
urlsToDelete.push(asset.sourceUrl);
|
||||
}
|
||||
if (shouldDeleteVideoThumbnail && asset.thumbnailUrl) {
|
||||
urlsToDelete.push(asset.thumbnailUrl);
|
||||
}
|
||||
if (urlsToDelete.length > 0) {
|
||||
r2CleanupResult = await deleteMediaFilesBestEffort(urlsToDelete);
|
||||
}
|
||||
}
|
||||
|
||||
let bunnyCleanupResult:
|
||||
| Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>>
|
||||
|
||||
@@ -2,22 +2,52 @@ import crypto from 'crypto';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
import {
|
||||
createBunnyUploadToken,
|
||||
readBunnyUploadGrant,
|
||||
type BunnyUploadGrant,
|
||||
} from '@/lib/bunny-upload-token';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import {
|
||||
createGuestUploadToken,
|
||||
deriveGuestUploadContext,
|
||||
enforceGuestUploadQuota,
|
||||
verifyGuestUploadToken,
|
||||
readGuestUploadGrant,
|
||||
type GuestUploadGrant,
|
||||
} from '@/lib/guest-upload-token';
|
||||
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { enforceStorageQuota } from '@/lib/storage-quota';
|
||||
import {
|
||||
enforceStorageQuota,
|
||||
getMaxVideoUploadBytesForUser,
|
||||
releaseStorageReservation,
|
||||
reserveStorageQuota,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
} from '@/lib/storage-quota';
|
||||
import { parseDeclaredUploadSize } from '@/lib/upload-size';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||
|
||||
// Matches the project video path: long enough to outlive a slow upload and
|
||||
// Bunny's own reporting delay.
|
||||
const BUNNY_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* A guest's hold lapses sooner than a member's.
|
||||
*
|
||||
* A guest is whoever opened the share link, and the hold is written against the
|
||||
* workspace owner's quota rather than their own. Declaring a size and then
|
||||
* walking away costs the guest nothing and costs the owner their whole remaining
|
||||
* allowance, which on a trial is the entire account. Half an hour is the same
|
||||
* window the R2 attachment paths already accept, and it bounds what a guest who
|
||||
* never uploads can take away. A guest whose upload outruns it loses only the
|
||||
* concurrency guard for the tail of the transfer; the bytes are still recorded
|
||||
* from the signed size when the asset is created.
|
||||
*/
|
||||
const GUEST_BUNNY_RESERVATION_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
// POST /api/videos/[videoId]/assets/bunny-init
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
@@ -33,12 +63,23 @@ 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()) {
|
||||
if (!isBunnyUploadsEnabled()) {
|
||||
return apiErrors.badRequest('Direct uploads are disabled by this host');
|
||||
}
|
||||
|
||||
// See the project video route for why the client's declared size is asked for
|
||||
// and what it is worth: it buys an honest refusal before the upload starts,
|
||||
// and a reservation that concurrent uploads can see.
|
||||
const billedUserId = context.video.project.workspace.ownerId;
|
||||
const quotaError = await enforceStorageQuota(billedUserId, BigInt(0));
|
||||
const declaredSize = parseDeclaredUploadSize(
|
||||
body?.sizeBytes,
|
||||
await getMaxVideoUploadBytesForUser(billedUserId)
|
||||
);
|
||||
if ('error' in declaredSize) {
|
||||
return apiErrors.badRequest(declaredSize.error);
|
||||
}
|
||||
|
||||
const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes);
|
||||
if (quotaError) return quotaError;
|
||||
|
||||
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
||||
@@ -52,10 +93,24 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (quotaError) return quotaError;
|
||||
}
|
||||
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
billedUserId,
|
||||
declaredSize.sizeBytes,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY,
|
||||
context.viewerUserId ? BUNNY_RESERVATION_TTL_MS : GUEST_BUNNY_RESERVATION_TTL_MS
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
const { reservationId } = reserveResult;
|
||||
|
||||
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
||||
const libraryId =
|
||||
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
||||
if (!apiKey || !libraryId) {
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||
);
|
||||
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
||||
}
|
||||
|
||||
@@ -70,6 +125,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
|
||||
if (!bunnyRes.ok) {
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||
);
|
||||
logError('Failed to create Bunny Stream video asset', await bunnyRes.text());
|
||||
return apiErrors.internalError('Failed to initialize Bunny upload');
|
||||
}
|
||||
@@ -77,6 +137,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const bunnyVideo = await bunnyRes.json();
|
||||
const bunnyVideoId = typeof bunnyVideo?.guid === 'string' ? bunnyVideo.guid.trim() : '';
|
||||
if (!bunnyVideoId || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) {
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||
);
|
||||
return apiErrors.internalError('Upload provider did not return a valid video identifier');
|
||||
}
|
||||
|
||||
@@ -92,21 +157,36 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
userId: context.viewerUserId,
|
||||
projectId: context.video.projectId,
|
||||
videoId: bunnyVideoId,
|
||||
reservationId,
|
||||
declaredSizeBytes: declaredSize.sizeBytes,
|
||||
},
|
||||
3600
|
||||
);
|
||||
} else {
|
||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||
if (!expectedContext) {
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||
);
|
||||
return apiErrors.forbidden('Missing trusted client IP header');
|
||||
}
|
||||
|
||||
// The guest grant carries the same three claims the signed-in one does,
|
||||
// and is bound to the Bunny video as well as to ours. That binding is what
|
||||
// makes releasing safe on the guest's say-so: presenting this token to
|
||||
// cancel deletes the upload it stands for, so it cannot be used to drop the
|
||||
// hold while the transfer carries on.
|
||||
uploadToken = createGuestUploadToken(
|
||||
{
|
||||
projectId: context.video.projectId,
|
||||
videoId: context.video.id,
|
||||
intent: 'bunny',
|
||||
context: expectedContext,
|
||||
providerVideoId: bunnyVideoId,
|
||||
reservationId,
|
||||
declaredSizeBytes: declaredSize.sizeBytes,
|
||||
},
|
||||
3600
|
||||
);
|
||||
@@ -144,15 +224,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.badRequest('videoId and uploadToken are required');
|
||||
}
|
||||
|
||||
// Both grants are read rather than merely checked, because both carry the
|
||||
// reservation this upload holds. Releasing on the caller's say-so is safe
|
||||
// only because the id is signed next to this Bunny video id: presenting the
|
||||
// token costs them the video, which is deleted immediately below.
|
||||
let grant: BunnyUploadGrant | GuestUploadGrant | null = null;
|
||||
|
||||
if (context.viewerUserId) {
|
||||
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
|
||||
grant = readBunnyUploadGrant(uploadToken, {
|
||||
userId: context.viewerUserId,
|
||||
projectId: context.video.projectId,
|
||||
videoId: bunnyVideoId,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
} else {
|
||||
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||
@@ -160,16 +243,27 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.forbidden('Missing trusted client IP header');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyGuestUploadToken(uploadToken, {
|
||||
grant = readGuestUploadGrant(
|
||||
uploadToken,
|
||||
{
|
||||
projectId: context.video.projectId,
|
||||
videoId: context.video.id,
|
||||
intent: 'bunny',
|
||||
context: expectedContext,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
},
|
||||
bunnyVideoId
|
||||
);
|
||||
}
|
||||
|
||||
if (!grant) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
}
|
||||
|
||||
await releaseStorageReservation(
|
||||
grant.reservationId,
|
||||
context.video.project.workspace.ownerId,
|
||||
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||
);
|
||||
|
||||
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId: bunnyVideoId }]);
|
||||
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '@/lib/db';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import {
|
||||
createR2UploadToken,
|
||||
parseR2UploadToken,
|
||||
verifyR2UploadToken,
|
||||
} from '@/lib/r2-upload-token';
|
||||
import {
|
||||
createPresignedImagePutUrl,
|
||||
createPresignedVideoPutUrl,
|
||||
deleteR2Object,
|
||||
deleteVideoObject,
|
||||
} from '@/lib/r2';
|
||||
import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
import {
|
||||
buildVideoObjectKey,
|
||||
getVideoExtensionFromMime,
|
||||
resolveVideoContentType,
|
||||
videoProxyPathFromFilename,
|
||||
} from '@/lib/video-upload-validation';
|
||||
import { logError } from '@/lib/logger';
|
||||
import {
|
||||
enforceStorageQuota,
|
||||
getMaxVideoUploadBytesForUser,
|
||||
releaseStorageReservation,
|
||||
reserveStorageQuota,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
} from '@/lib/storage-quota';
|
||||
import { uploadTooLargeMessage } from '@/lib/upload-size';
|
||||
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
||||
import { getVideoAssetAccessContext } from '@/lib/video-assets';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||
|
||||
const VIDEO_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
const THUMBNAIL_RESERVE_BYTES = BigInt(512 * 1024);
|
||||
|
||||
// POST /api/videos/[videoId]/assets/r2-init
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'asset-r2-init');
|
||||
if (limited) return limited;
|
||||
|
||||
const { videoId } = await params;
|
||||
const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT');
|
||||
if (!context) return apiErrors.notFound('Video');
|
||||
if (!context.canUploadAssets) return apiErrors.forbidden('Access denied');
|
||||
|
||||
if (!context.viewerUserId) {
|
||||
return apiErrors.unauthorized('Sign in is required for direct video uploads');
|
||||
}
|
||||
|
||||
if (!isS3VideoUploadsEnabled()) {
|
||||
return apiErrors.badRequest('S3 video uploads are disabled by this host');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const fileName = typeof body?.fileName === 'string' ? body.fileName.trim() : '';
|
||||
const contentTypeInput = typeof body?.contentType === 'string' ? body.contentType.trim() : '';
|
||||
const sizeBytesRaw = body?.sizeBytes;
|
||||
|
||||
if (!fileName) {
|
||||
return apiErrors.badRequest('fileName is required');
|
||||
}
|
||||
|
||||
let sizeBytes: bigint;
|
||||
try {
|
||||
sizeBytes = BigInt(sizeBytesRaw);
|
||||
if (sizeBytes <= BigInt(0)) {
|
||||
return apiErrors.badRequest('sizeBytes must be a positive integer');
|
||||
}
|
||||
} catch {
|
||||
return apiErrors.badRequest('sizeBytes must be a positive integer');
|
||||
}
|
||||
|
||||
const billedUserId = context.video.project.workspace.ownerId;
|
||||
const projectId = context.video.projectId;
|
||||
|
||||
const maxBytes = await getMaxVideoUploadBytesForUser(billedUserId);
|
||||
if (sizeBytes > maxBytes) {
|
||||
return apiErrors.badRequest(uploadTooLargeMessage(maxBytes));
|
||||
}
|
||||
|
||||
const contentType = resolveVideoContentType(fileName, contentTypeInput);
|
||||
if (!contentType) {
|
||||
return apiErrors.badRequest('Unsupported video format');
|
||||
}
|
||||
|
||||
const ext = getVideoExtensionFromMime(contentType);
|
||||
if (!ext) {
|
||||
return apiErrors.badRequest('Unsupported video format');
|
||||
}
|
||||
|
||||
const quotaError = await enforceStorageQuota(billedUserId, sizeBytes + THUMBNAIL_RESERVE_BYTES);
|
||||
if (quotaError) return quotaError;
|
||||
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
billedUserId,
|
||||
sizeBytes + THUMBNAIL_RESERVE_BYTES,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||
VIDEO_RESERVATION_TTL_MS
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
|
||||
const fileId = randomUUID();
|
||||
const filename = `${fileId}.${ext}`;
|
||||
const objectKey = buildVideoObjectKey(filename);
|
||||
const proxyUrl = videoProxyPathFromFilename(filename);
|
||||
const thumbnailFilename = `${fileId}.jpg`;
|
||||
const thumbnailObjectKey = `images/${thumbnailFilename}`;
|
||||
const thumbnailProxyUrl = `/api/upload/image/${thumbnailFilename}`;
|
||||
|
||||
let presignedPutUrl: string;
|
||||
let thumbnailPresignedPutUrl: string;
|
||||
try {
|
||||
[presignedPutUrl, thumbnailPresignedPutUrl] = await Promise.all([
|
||||
createPresignedVideoPutUrl(objectKey, contentType, sizeBytes),
|
||||
createPresignedImagePutUrl(thumbnailObjectKey, 'image/jpeg'),
|
||||
]);
|
||||
} catch (error) {
|
||||
await releaseStorageReservation(
|
||||
reserveResult.reservationId,
|
||||
billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
logError('Failed to create presigned asset video upload URL:', error);
|
||||
return apiErrors.internalError('Failed to initialize video upload');
|
||||
}
|
||||
|
||||
const uploadJti = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + VIDEO_RESERVATION_TTL_MS);
|
||||
const uploadSession = await createR2UploadSession({
|
||||
userId: context.viewerUserId,
|
||||
projectId,
|
||||
billedUserId,
|
||||
objectKey,
|
||||
thumbnailObjectKey,
|
||||
declaredSizeBytes: sizeBytes,
|
||||
contentType,
|
||||
reservationId: reserveResult.reservationId,
|
||||
uploadJti,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const uploadToken = createR2UploadToken({
|
||||
userId: context.viewerUserId,
|
||||
projectId,
|
||||
objectKey,
|
||||
sessionId: uploadSession.id,
|
||||
tokenId: uploadJti,
|
||||
thumbnailObjectKey,
|
||||
});
|
||||
|
||||
const response = successResponse({
|
||||
presignedPutUrl,
|
||||
objectKey,
|
||||
proxyUrl,
|
||||
uploadToken,
|
||||
reservationId: reserveResult.reservationId,
|
||||
contentType,
|
||||
thumbnailPresignedPutUrl,
|
||||
thumbnailObjectKey,
|
||||
thumbnailProxyUrl,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error initializing R2 asset video upload:', error);
|
||||
return apiErrors.internalError('Failed to initialize upload');
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/videos/[videoId]/assets/r2-init
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'asset-r2-init');
|
||||
if (limited) return limited;
|
||||
|
||||
const { videoId } = await params;
|
||||
const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT');
|
||||
if (!context) return apiErrors.notFound('Video');
|
||||
if (!context.canUploadAssets) return apiErrors.forbidden('Access denied');
|
||||
|
||||
if (!context.viewerUserId) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
if (!isS3VideoUploadsEnabled()) {
|
||||
return apiErrors.badRequest('S3 video uploads are disabled by this host');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const objectKey = typeof body?.objectKey === 'string' ? body.objectKey.trim() : '';
|
||||
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
||||
const thumbnailObjectKey =
|
||||
typeof body?.thumbnailObjectKey === 'string' ? body.thumbnailObjectKey.trim() : '';
|
||||
|
||||
if (!objectKey || !uploadToken) {
|
||||
return apiErrors.badRequest('objectKey and uploadToken are required');
|
||||
}
|
||||
|
||||
const projectId = context.video.projectId;
|
||||
const tokenPayload = parseR2UploadToken(uploadToken);
|
||||
if (!tokenPayload) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyR2UploadToken(uploadToken, {
|
||||
userId: context.viewerUserId,
|
||||
projectId,
|
||||
objectKey,
|
||||
sessionId: tokenPayload.sid,
|
||||
tokenId: tokenPayload.jti,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const uploadSession = await db.videoUploadSession.findFirst({
|
||||
where: {
|
||||
id: tokenPayload.sid,
|
||||
status: 'INITIATED',
|
||||
userId: context.viewerUserId,
|
||||
projectId,
|
||||
objectKey,
|
||||
uploadJti: tokenPayload.jti,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
reservationId: true,
|
||||
billedUserId: true,
|
||||
thumbnailObjectKey: true,
|
||||
},
|
||||
});
|
||||
if (!uploadSession) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
if (thumbnailObjectKey && thumbnailObjectKey !== uploadSession.thumbnailObjectKey) {
|
||||
return apiErrors.badRequest('Invalid thumbnail object key');
|
||||
}
|
||||
|
||||
const cancelled = await db.videoUploadSession.updateMany({
|
||||
where: {
|
||||
id: uploadSession.id,
|
||||
status: 'INITIATED',
|
||||
},
|
||||
data: {
|
||||
status: 'CANCELLED',
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (cancelled.count !== 1) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
deleteVideoObject(objectKey),
|
||||
uploadSession.thumbnailObjectKey.startsWith('images/')
|
||||
? deleteR2Object(uploadSession.thumbnailObjectKey)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
} catch (error) {
|
||||
logError('Failed to delete pending R2 asset video object:', error);
|
||||
}
|
||||
|
||||
await releaseStorageReservation(
|
||||
uploadSession.reservationId,
|
||||
uploadSession.billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error cleaning up pending R2 asset video upload:', error);
|
||||
return apiErrors.internalError('Failed to cleanup pending upload');
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { db } from '@/lib/db';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
import { deriveGuestUploadContext, verifyGuestUploadToken } from '@/lib/guest-upload-token';
|
||||
import { readBunnyUploadGrant } from '@/lib/bunny-upload-token';
|
||||
import { deriveGuestUploadContext, readGuestUploadGrant } from '@/lib/guest-upload-token';
|
||||
import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||
@@ -16,22 +16,29 @@ import {
|
||||
SAFE_BUNNY_VIDEO_ID,
|
||||
SAFE_IMAGE_PROXY_PATH,
|
||||
SAFE_AUDIO_PROXY_PATH,
|
||||
SAFE_VIDEO_PROXY_PATH,
|
||||
canDeleteAssetForViewer,
|
||||
extractImageFileNameFromProxyUrl,
|
||||
extractImageKeyFromProxyUrl,
|
||||
extractAudioKeyFromProxyUrl,
|
||||
extractAudioFileNameFromProxyUrl,
|
||||
extractVideoFileNameFromProxyUrl,
|
||||
getVideoAssetAccessContext,
|
||||
sanitizeAssetDisplayName,
|
||||
} from '@/lib/video-assets';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
||||
import {
|
||||
enforceStorageQuota,
|
||||
reserveStorageQuota,
|
||||
releaseStorageReservation,
|
||||
PLAN_STORAGE_LIMIT_BYTES,
|
||||
getStorageContextForUser,
|
||||
storageExceededResponse,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
type StorageContext,
|
||||
type UploadReservationPurpose,
|
||||
} from '@/lib/storage-quota';
|
||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
||||
import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
|
||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
|
||||
// Sentinel thrown inside a Prisma transaction when a fake reservationId is
|
||||
@@ -269,7 +276,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
asset,
|
||||
// R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio
|
||||
context.canDownloadAssets ||
|
||||
(asset.provider === VideoAssetProvider.R2_AUDIO && context.hasViewAccess),
|
||||
((asset.provider === VideoAssetProvider.R2_AUDIO ||
|
||||
asset.provider === VideoAssetProvider.R2_VIDEO) &&
|
||||
context.hasViewAccess),
|
||||
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
|
||||
)
|
||||
),
|
||||
@@ -293,6 +302,24 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
// POST /api/videos/[videoId]/assets
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
let reservationId: string | null = null;
|
||||
// What the reservation above was opened for, and who it is billed to. A hold is
|
||||
// only ever consumed by the flow that opened it: the id below can arrive in the
|
||||
// request body, and every hold an account owns is billed to the same user, so
|
||||
// the id alone would let an image being attached release a video upload that
|
||||
// was still in flight.
|
||||
let reservationPurpose: UploadReservationPurpose | null = null;
|
||||
let reservationBilledUserId: string | null = null;
|
||||
// Carried out of the try so the quota refusal in the catch can be worded for
|
||||
// the account it is refusing, rather than telling a trial to delete files.
|
||||
let storageForRefusal: StorageContext | null = null;
|
||||
let finalizedR2AssetSession: {
|
||||
sessionId: string;
|
||||
reservationId: string | null;
|
||||
billedUserId: string;
|
||||
objectKey: string;
|
||||
viewerUserId: string;
|
||||
projectId: string;
|
||||
} | null = null;
|
||||
try {
|
||||
const limited = await rateLimit(request, 'asset-create');
|
||||
if (limited) return limited;
|
||||
@@ -309,7 +336,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
provider !== VideoAssetProvider.R2_IMAGE &&
|
||||
provider !== VideoAssetProvider.YOUTUBE &&
|
||||
provider !== VideoAssetProvider.BUNNY &&
|
||||
provider !== VideoAssetProvider.R2_AUDIO
|
||||
provider !== VideoAssetProvider.R2_AUDIO &&
|
||||
provider !== VideoAssetProvider.R2_VIDEO
|
||||
) {
|
||||
return apiErrors.badRequest('Invalid provider');
|
||||
}
|
||||
@@ -328,6 +356,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
let assetSizeBytes = BigInt(0);
|
||||
|
||||
const billedUserId = context.video.project.workspace.ownerId;
|
||||
reservationBilledUserId = billedUserId;
|
||||
|
||||
if (provider === VideoAssetProvider.R2_IMAGE) {
|
||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||
@@ -345,8 +374,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
// the client already supplied a reservationId (new upload flow) the
|
||||
// existing reservation is consumed in the transaction below. For the
|
||||
// backward-compat path (no reservationId) we create one here.
|
||||
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.IMAGE;
|
||||
if (!reservationId) {
|
||||
const reserveResult = await reserveStorageQuota(billedUserId, assetSizeBytes);
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
billedUserId,
|
||||
assetSizeBytes,
|
||||
UPLOAD_RESERVATION_PURPOSES.IMAGE
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
reservationId = reserveResult.reservationId;
|
||||
}
|
||||
@@ -369,8 +403,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
assetSizeBytes = audioCheck.sizeBytes;
|
||||
|
||||
// Same reservation logic as R2_IMAGE above
|
||||
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.AUDIO;
|
||||
if (!reservationId) {
|
||||
const reserveResult = await reserveStorageQuota(billedUserId, assetSizeBytes);
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
billedUserId,
|
||||
assetSizeBytes,
|
||||
UPLOAD_RESERVATION_PURPOSES.AUDIO
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
reservationId = reserveResult.reservationId;
|
||||
}
|
||||
@@ -400,6 +439,60 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
kind = 'VIDEO';
|
||||
}
|
||||
|
||||
if (provider === VideoAssetProvider.R2_VIDEO) {
|
||||
if (!context.viewerUserId) {
|
||||
return apiErrors.forbidden('R2 video asset uploads require sign-in');
|
||||
}
|
||||
|
||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||
const objectKey = typeof body?.objectKey === 'string' ? body.objectKey.trim() : '';
|
||||
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
||||
thumbnailUrl = typeof body?.thumbnailUrl === 'string' ? body.thumbnailUrl.trim() : null;
|
||||
|
||||
if (!SAFE_VIDEO_PROXY_PATH.test(sourceUrl)) {
|
||||
return apiErrors.badRequest('Video URL must reference an uploaded video file');
|
||||
}
|
||||
if (!objectKey || !uploadToken) {
|
||||
return apiErrors.badRequest('objectKey and uploadToken are required');
|
||||
}
|
||||
if (thumbnailUrl && !SAFE_IMAGE_PROXY_PATH.test(thumbnailUrl)) {
|
||||
return apiErrors.badRequest('Thumbnail URL must reference an uploaded image file');
|
||||
}
|
||||
|
||||
const finalizeResult = await finalizeR2VideoUpload({
|
||||
userId: context.viewerUserId,
|
||||
projectId: context.video.projectId,
|
||||
videoUrl: sourceUrl,
|
||||
objectKey,
|
||||
uploadToken,
|
||||
});
|
||||
if (!finalizeResult.ok) {
|
||||
if (finalizeResult.status === 403) {
|
||||
return apiErrors.forbidden(finalizeResult.error);
|
||||
}
|
||||
return apiErrors.badRequest(finalizeResult.error);
|
||||
}
|
||||
|
||||
assetSizeBytes = finalizeResult.sizeBytes;
|
||||
reservationId = finalizeResult.reservationId;
|
||||
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.R2_VIDEO;
|
||||
if (!thumbnailUrl) {
|
||||
thumbnailUrl = finalizeResult.thumbnailProxyUrl;
|
||||
}
|
||||
|
||||
const fileName = extractVideoFileNameFromProxyUrl(sourceUrl);
|
||||
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Video');
|
||||
kind = 'VIDEO';
|
||||
finalizedR2AssetSession = {
|
||||
sessionId: finalizeResult.sessionId,
|
||||
reservationId: finalizeResult.reservationId,
|
||||
billedUserId: finalizeResult.billedUserId,
|
||||
objectKey: finalizeResult.objectKey,
|
||||
viewerUserId: context.viewerUserId,
|
||||
projectId: context.video.projectId,
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === VideoAssetProvider.BUNNY) {
|
||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||
providerVideoId =
|
||||
@@ -427,14 +520,22 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
if (context.viewerUserId) {
|
||||
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
|
||||
const grant = readBunnyUploadGrant(uploadToken, {
|
||||
userId: context.viewerUserId,
|
||||
projectId: context.video.projectId,
|
||||
videoId: providerVideoId,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
if (!grant) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
|
||||
// Charged from now on the size the upload was admitted on: Bunny reports
|
||||
// nothing until it has finished encoding, and an asset that reads as zero
|
||||
// bytes for an hour is an hour of uploads measured against a total that
|
||||
// does not include it.
|
||||
assetSizeBytes = grant.declaredSizeBytes ?? BigInt(0);
|
||||
reservationId = grant.reservationId;
|
||||
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.BUNNY;
|
||||
} else {
|
||||
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||
@@ -442,15 +543,28 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.forbidden('Missing trusted client IP header');
|
||||
}
|
||||
|
||||
const isValidGuestUploadToken = verifyGuestUploadToken(uploadToken, {
|
||||
// Read rather than merely verified, for the same reason as above: a guest
|
||||
// upload that reads as zero bytes until Bunny finishes encoding is an hour
|
||||
// of the owner's quota spent on nothing. The grant is bound to this Bunny
|
||||
// video, so the size and the hold it names belong to this upload and no
|
||||
// other.
|
||||
const guestGrant = readGuestUploadGrant(
|
||||
uploadToken,
|
||||
{
|
||||
projectId: context.video.projectId,
|
||||
videoId: context.video.id,
|
||||
intent: 'bunny',
|
||||
context: expectedContext,
|
||||
});
|
||||
if (!isValidGuestUploadToken) {
|
||||
},
|
||||
providerVideoId
|
||||
);
|
||||
if (!guestGrant) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
|
||||
assetSizeBytes = guestGrant.declaredSizeBytes ?? BigInt(0);
|
||||
reservationId = guestGrant.reservationId;
|
||||
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.BUNNY;
|
||||
}
|
||||
|
||||
displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`);
|
||||
@@ -462,24 +576,31 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
kind = 'VIDEO';
|
||||
|
||||
const quotaError = await enforceStorageQuota(billedUserId, BigInt(0));
|
||||
const quotaError = await enforceStorageQuota(billedUserId, assetSizeBytes);
|
||||
if (quotaError) return quotaError;
|
||||
}
|
||||
|
||||
// Pre-fetch Bunny storage BEFORE entering the transaction to avoid making an
|
||||
// HTTP call while holding a DB connection open (connection-pool exhaustion
|
||||
// risk under adversarial load). Mirrors the discipline in reserveStorageQuota.
|
||||
// Only needed for R2 providers where the invalid-reservation fallback quota
|
||||
// check requires Bunny usage data.
|
||||
const preFetchedBunnyData =
|
||||
provider === VideoAssetProvider.R2_IMAGE || provider === VideoAssetProvider.R2_AUDIO
|
||||
? await getCachedUserBunnyStorage()
|
||||
: null;
|
||||
// Needed by every provider that can reach the invalid-reservation fallback
|
||||
// quota check below, Bunny included. Leaving Bunny out read its own storage as
|
||||
// zero, and on an account whose storage is all Bunny that made the fallback a
|
||||
// check that could not fail.
|
||||
const preFetchedBunnyBytes =
|
||||
provider === VideoAssetProvider.YOUTUBE ? null : await getUserBunnyStorageBytes(billedUserId);
|
||||
|
||||
// The ceiling this account is actually held to, read for the fallback below.
|
||||
// It used to compare against the plan limit, which is 200 GiB whoever is
|
||||
// asking: a caller who quoted a reservation id that no longer existed was
|
||||
// measured against the paid ceiling even on a trial worth 3 GiB.
|
||||
const storage = await getStorageContextForUser(billedUserId);
|
||||
storageForRefusal = storage;
|
||||
|
||||
// Create the VideoAsset and atomically consume the upload reservation (if any)
|
||||
// so the spot is never double-counted.
|
||||
const created = await db.$transaction(async (tx) => {
|
||||
if (reservationId) {
|
||||
if (reservationId && reservationPurpose) {
|
||||
// Acquire the per-user advisory lock unconditionally so both the happy path
|
||||
// (valid reservation) and the fallback path (fake/expired reservation ID) are
|
||||
// serialised — eliminating the TOCTOU race in the deleted.count === 0 branch.
|
||||
@@ -493,7 +614,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
// we fall back to a standard (non-locked) quota check so the bypass attempt
|
||||
// is caught rather than silently allowed.
|
||||
const deleted = await tx.uploadReservation.deleteMany({
|
||||
where: { id: reservationId, billedUserId, expiresAt: { gt: new Date() } },
|
||||
where: {
|
||||
id: reservationId,
|
||||
billedUserId,
|
||||
purpose: reservationPurpose,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
if (deleted.count === 0) {
|
||||
// Reservation didn't exist — enforce quota the normal way inside the tx.
|
||||
@@ -503,7 +629,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||
FROM video_assets
|
||||
WHERE "billedUserId" = ${billedUserId}
|
||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
|
||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
|
||||
`;
|
||||
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
|
||||
@@ -511,16 +637,35 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
WHERE "billedUserId" = ${billedUserId}
|
||||
AND "expiresAt" > NOW()
|
||||
`;
|
||||
const bunnyData = preFetchedBunnyData ?? {};
|
||||
const totalUsed =
|
||||
(r2Row?.total ?? BigInt(0)) +
|
||||
(resRow?.total ?? BigInt(0)) +
|
||||
BigInt(bunnyData[billedUserId] ?? 0);
|
||||
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) {
|
||||
BigInt(preFetchedBunnyBytes ?? 0);
|
||||
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storage.limitBytes) {
|
||||
throw new QuotaExceededInTxError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalizedR2AssetSession) {
|
||||
const consumed = await tx.videoUploadSession.updateMany({
|
||||
where: {
|
||||
id: finalizedR2AssetSession.sessionId,
|
||||
status: 'INITIATED',
|
||||
userId: finalizedR2AssetSession.viewerUserId,
|
||||
projectId: finalizedR2AssetSession.projectId,
|
||||
objectKey: finalizedR2AssetSession.objectKey,
|
||||
},
|
||||
data: {
|
||||
status: 'FINALIZED',
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (consumed.count !== 1) {
|
||||
throw new Error('Upload session already consumed');
|
||||
}
|
||||
}
|
||||
|
||||
return tx.videoAsset.create({
|
||||
data: {
|
||||
videoId: context.video.id,
|
||||
@@ -572,9 +717,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
if (error instanceof QuotaExceededInTxError) {
|
||||
return apiErrors.storageExceeded() as NextResponse;
|
||||
return storageForRefusal
|
||||
? storageExceededResponse(storageForRefusal)
|
||||
: (apiErrors.storageExceeded() as NextResponse);
|
||||
}
|
||||
await releaseStorageReservation(reservationId);
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
reservationBilledUserId,
|
||||
reservationPurpose ?? undefined
|
||||
);
|
||||
logError('Error creating video asset:', error);
|
||||
return apiErrors.internalError('Failed to create asset');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { canDownloadProjectMedia } from '@/lib/project-download';
|
||||
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
@@ -48,6 +49,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -71,6 +73,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
voiceUrl: true,
|
||||
voiceDuration: true,
|
||||
imageUrl: true,
|
||||
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
@@ -190,10 +193,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const canCommentWithMembership = access.hasAccess;
|
||||
const canCommentWithShareLink =
|
||||
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||
const canDownloadWithMembership = access.hasAccess;
|
||||
const canDownloadWithMembership = canDownloadProjectMedia(video.project, access);
|
||||
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
||||
const canUploadAssets = canCommentWithMembership || canCommentWithShareLink;
|
||||
const canDownloadAssets = !!session?.user?.id && (access.hasAccess || shareAccess.hasAccess);
|
||||
const canDownloadAssets =
|
||||
(access.hasAccess || shareAccess.hasAccess) &&
|
||||
(canDownloadWithMembership || canDownloadWithShareLink);
|
||||
const response = successResponse({
|
||||
...videoData,
|
||||
versions,
|
||||
@@ -202,6 +207,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
name: project.name,
|
||||
ownerId: project.ownerId,
|
||||
visibility: project.visibility,
|
||||
allowDownloads: project.allowDownloads,
|
||||
},
|
||||
isAuthenticated: !!session?.user?.id,
|
||||
currentUserId: session?.user?.id || null,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/lib/invitations';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
||||
|
||||
type RouteParams = { params: Promise<{ workspaceId: string }> };
|
||||
|
||||
@@ -168,9 +169,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.badRequest('Email is required');
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(normalizedEmail)) {
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
if (!isValidEmailAddress(normalizedEmail)) {
|
||||
return apiErrors.validationError('Invalid email format');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { rateLimit } from '@/lib/rate-limit';
|
||||
import { buildBillingAccessWhereInput, getWorkspaceCreationEligibility } from '@/lib/billing';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
// GET /api/workspaces - List all workspaces for the authenticated user
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -136,6 +137,12 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
await recordEvent({
|
||||
name: 'WORKSPACE_CREATED',
|
||||
dedupeKey: eventKey('WORKSPACE_CREATED', workspace.id),
|
||||
userId: workspace.ownerId,
|
||||
});
|
||||
|
||||
const response = successResponse(workspace, 201);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import Link from 'next/link';
|
||||
import { Video, UserPlus, LogIn, MailWarning } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { InvitationPreview } from '@/lib/invitations';
|
||||
|
||||
interface InvitationLandingProps {
|
||||
token: string;
|
||||
preview: InvitationPreview | null;
|
||||
}
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||
<div className="w-full max-w-md">
|
||||
<Link href="/" className="flex items-center justify-center gap-2 mb-8">
|
||||
<Video className="h-8 w-8 text-primary" />
|
||||
<span className="font-bold text-2xl">OpenFrame</span>
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnusableInvitation({ title, message }: { title: string; message: string }) {
|
||||
return (
|
||||
<Shell>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="flex items-center justify-center gap-2">
|
||||
<MailWarning className="h-5 w-5 text-amber-500" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription>{message}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/login">Sign in</Link>
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Ask whoever invited you to send a new invitation link.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Too many unauthenticated invitation lookups from this client — nothing was queried. */
|
||||
export function InvitationRateLimited() {
|
||||
return (
|
||||
<UnusableInvitation
|
||||
title="Too many attempts"
|
||||
message="We couldn't check this invitation right now. Please wait a few minutes and open the link again."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Signed in, but with an account whose address the invitation was not issued to. */
|
||||
export function InvitationAccountMismatch({
|
||||
invitedEmail,
|
||||
signedInEmail,
|
||||
}: {
|
||||
invitedEmail: string;
|
||||
signedInEmail: string;
|
||||
}) {
|
||||
return (
|
||||
<Shell>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="flex items-center justify-center gap-2">
|
||||
<MailWarning className="h-5 w-5 text-amber-500" />
|
||||
Wrong account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This invitation was sent to <strong>{invitedEmail}</strong>, but you are signed in as{' '}
|
||||
<strong>{signedInEmail}</strong>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/signout">Sign out and switch account</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/dashboard">Back to dashboard</Link>
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
After signing out, open the invitation link from your email again.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvitationLanding({ token, preview }: InvitationLandingProps) {
|
||||
const acceptPath = `/invitations/accept?token=${encodeURIComponent(token)}`;
|
||||
const loginHref = `/login?callbackUrl=${encodeURIComponent(acceptPath)}`;
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<UnusableInvitation
|
||||
title="Invitation not found"
|
||||
message="This invitation link is invalid. It may have been revoked or replaced by a newer one."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === 'CANCELED') {
|
||||
return (
|
||||
<UnusableInvitation
|
||||
title="Invitation revoked"
|
||||
message="This invitation is no longer valid."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === 'EXPIRED' || preview.isExpired) {
|
||||
return (
|
||||
<UnusableInvitation
|
||||
title="Invitation expired"
|
||||
message={`The invitation sent to ${preview.email} has expired.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const registerHref =
|
||||
`/register?invitationToken=${encodeURIComponent(token)}` +
|
||||
`&email=${encodeURIComponent(preview.email)}` +
|
||||
`&callbackUrl=${encodeURIComponent(acceptPath)}`;
|
||||
|
||||
const alreadyAccepted = preview.status === 'ACCEPTED';
|
||||
const targetLabel = preview.targetName
|
||||
? `${preview.targetName} (${preview.scopeLabel})`
|
||||
: `a ${preview.scopeLabel}`;
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>You've been invited</CardTitle>
|
||||
<CardDescription>
|
||||
{preview.inviterName} invited you to join <strong>{targetLabel}</strong> on OpenFrame as{' '}
|
||||
{preview.roleLabel}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md border bg-muted/40 p-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
This invitation was sent to{' '}
|
||||
<strong className="text-foreground">{preview.email}</strong>.{' '}
|
||||
{preview.hasAccount || alreadyAccepted ? 'Sign in with' : 'Use'} that address to
|
||||
accept it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{preview.hasAccount || alreadyAccepted ? (
|
||||
<>
|
||||
<Button asChild className="w-full">
|
||||
<Link href={loginHref}>
|
||||
<LogIn className="h-4 w-4 mr-2" />
|
||||
Sign in to accept
|
||||
</Link>
|
||||
</Button>
|
||||
{!alreadyAccepted && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Wrong address?{' '}
|
||||
<Link href={registerHref} className="text-primary hover:underline">
|
||||
Create an account instead
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You don't have an OpenFrame account yet. Create one to open this{' '}
|
||||
{preview.scopeLabel} — we'll bring you right back here once you're signed
|
||||
in.
|
||||
</p>
|
||||
<Button asChild className="w-full">
|
||||
<Link href={registerHref}>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Create your account
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link href={loginHref} className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { acceptInvitationTokenForUser } from '@/lib/invitations';
|
||||
import { acceptInvitationTokenForUser, getInvitationPreviewByToken } from '@/lib/invitations';
|
||||
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
|
||||
import {
|
||||
InvitationAccountMismatch,
|
||||
InvitationLanding,
|
||||
InvitationRateLimited,
|
||||
} from './invitation-landing';
|
||||
|
||||
interface InvitationAcceptPageProps {
|
||||
searchParams: Promise<{
|
||||
@@ -19,14 +25,22 @@ export default async function InvitationAcceptPage({ searchParams }: InvitationA
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
const callbackUrl = `/invitations/accept?token=${encodeURIComponent(token)}`;
|
||||
redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`);
|
||||
// Signed-out visitors get the invitation itself instead of a bare login form:
|
||||
// most of them have no account yet and need to be told to create one. This is the
|
||||
// only unauthenticated read of invitation data, so it is IP-throttled.
|
||||
if (!(await isInvitationPreviewAllowed(token))) {
|
||||
return <InvitationRateLimited />;
|
||||
}
|
||||
|
||||
const preview = await getInvitationPreviewByToken(token);
|
||||
return <InvitationLanding token={token} preview={preview} />;
|
||||
}
|
||||
|
||||
const invitation = await db.invitation.findUnique({
|
||||
where: { token },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
status: true,
|
||||
scope: true,
|
||||
workspaceId: true,
|
||||
@@ -63,7 +77,14 @@ export default async function InvitationAcceptPage({ searchParams }: InvitationA
|
||||
redirect('/dashboard?invite=expired');
|
||||
}
|
||||
if (result === 'forbidden') {
|
||||
redirect('/dashboard?invite=wrong_account');
|
||||
// Signed in with a different address than the one invited — say so instead of
|
||||
// dropping the user on the dashboard with no explanation.
|
||||
return (
|
||||
<InvitationAccountMismatch
|
||||
invitedEmail={invitation?.email ?? 'another address'}
|
||||
signedInEmail={userEmail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (result === 'not_found' && invitation?.status === 'ACCEPTED') {
|
||||
|
||||
+23
-2
@@ -2,6 +2,10 @@ import type { Metadata } from 'next';
|
||||
import { Geist_Mono, JetBrains_Mono } from 'next/font/google';
|
||||
import { Toaster } from 'sonner';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import {
|
||||
buildRuntimePublicConfig,
|
||||
RUNTIME_PUBLIC_CONFIG_ELEMENT_ID,
|
||||
} from '@/lib/runtime-public-config';
|
||||
import { seoConfig } from '@/lib/seo';
|
||||
import './globals.css';
|
||||
|
||||
@@ -120,10 +124,27 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="antialiased min-h-screen bg-background font-sans">
|
||||
{/* Not executed, only parsed by readRuntimePublicConfig(). It carries the
|
||||
public settings the browser cannot get from NEXT_PUBLIC_* variables,
|
||||
which are frozen into the bundle when the image is built. */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
id={RUNTIME_PUBLIC_CONFIG_ELEMENT_ID}
|
||||
type="application/json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(buildRuntimePublicConfig()).replace(/</g, '\\u003c'),
|
||||
}}
|
||||
/>
|
||||
{/* One script per object: single-object payloads with a top-level
|
||||
@context survive naive JSON-LD consumers that choke on arrays. */}
|
||||
{structuredData.map((data) => (
|
||||
<script
|
||||
key={String(data['@type'])}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(data).replace(/</g, '\\u003c'),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||
<svg aria-hidden="true" className="fixed h-0 w-0">
|
||||
<filter id="openframe-noise">
|
||||
|
||||
@@ -107,7 +107,45 @@ function ToggleButton({
|
||||
|
||||
// ─── Step 1: Welcome ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepWelcome({ userName, onNext }: { userName: string; onNext: () => void }) {
|
||||
// Asked here rather than on the registration form. The whole point of measuring
|
||||
// this funnel is the signup conversion rate, and a question added to the form
|
||||
// would move the number being measured.
|
||||
const SOURCE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: 'GITHUB', label: 'GitHub' },
|
||||
{ value: 'YOUTUBE', label: 'YouTube' },
|
||||
{ value: 'GOOGLE', label: 'A search engine' },
|
||||
{ value: 'REVIEW_LINK', label: 'A review or comparison site' },
|
||||
{ value: 'REFERRAL', label: 'Someone recommended it' },
|
||||
{ value: 'COMMUNITY', label: 'Reddit, X, Discord or a forum' },
|
||||
{ value: 'OUTBOUND', label: 'An email from us' },
|
||||
{ value: 'OTHER', label: 'Somewhere else' },
|
||||
];
|
||||
|
||||
function StepWelcome({
|
||||
userName,
|
||||
askSource,
|
||||
onNext,
|
||||
}: {
|
||||
userName: string;
|
||||
askSource: boolean;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const [source, setSource] = useState<string>('');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const handleNext = () => {
|
||||
// Never blocks the wizard. An unanswered or failed question costs one row in
|
||||
// a cross-check column; a broken Get Started button costs the account.
|
||||
if (askSource && source) {
|
||||
void fetch('/api/onboarding/source', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source, note: source === 'OTHER' ? note : undefined }),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-8">
|
||||
<div className="mx-auto w-24 h-24 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
@@ -122,7 +160,36 @@ function StepWelcome({ userName, onNext }: { userName: string; onNext: () => voi
|
||||
manage versions, and streamline approvals — all in one place.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base">
|
||||
|
||||
{askSource && (
|
||||
<div className="mx-auto max-w-sm space-y-3 text-left">
|
||||
<Label htmlFor="acquisition-source" className="text-sm text-muted-foreground">
|
||||
How did you hear about us? (optional)
|
||||
</Label>
|
||||
<Select value={source} onValueChange={setSource}>
|
||||
<SelectTrigger id="acquisition-source" className="w-full">
|
||||
<SelectValue placeholder="Pick one" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOURCE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{source === 'OTHER' && (
|
||||
<Input
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
maxLength={200}
|
||||
placeholder="Where, roughly?"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base">
|
||||
Get Started
|
||||
<ChevronRight className="h-5 w-5 ml-1" />
|
||||
</Button>
|
||||
@@ -691,10 +758,12 @@ export function OnboardingWizard({
|
||||
userName,
|
||||
canCreateWorkspace,
|
||||
availableWorkspaces,
|
||||
askAcquisitionSource,
|
||||
}: {
|
||||
userName: string;
|
||||
canCreateWorkspace: boolean;
|
||||
availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>;
|
||||
askAcquisitionSource: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
@@ -761,7 +830,9 @@ export function OnboardingWizard({
|
||||
{/* Step content */}
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardContent className="pt-10 pb-10 px-10">
|
||||
{currentStep === 1 && <StepWelcome userName={userName} onNext={goNext} />}
|
||||
{currentStep === 1 && (
|
||||
<StepWelcome userName={userName} askSource={askAcquisitionSource} onNext={goNext} />
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<StepWorkspace
|
||||
canCreateWorkspace={canCreateWorkspace}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
|
||||
import { db } from '@/lib/db';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { OnboardingWizard } from './onboarding-wizard';
|
||||
|
||||
@@ -43,6 +44,7 @@ export default async function OnboardingPage() {
|
||||
<OnboardingWizard
|
||||
userName={userName}
|
||||
canCreateWorkspace={billing.workspaceCreation.canCreateWorkspace}
|
||||
askAcquisitionSource={isProductAnalyticsEnabled()}
|
||||
availableWorkspaces={creatableWorkspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
|
||||
+11
-1
@@ -1,8 +1,18 @@
|
||||
import { after } from 'next/server';
|
||||
import { LandingPage } from '@/components/LandingPage';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
const isLoggedIn = Boolean(session?.user);
|
||||
|
||||
return <LandingPage isLoggedIn={Boolean(session?.user)} />;
|
||||
// Signed-in users land here too, and counting them would put existing
|
||||
// customers at the top of the acquisition funnel.
|
||||
if (!isLoggedIn) {
|
||||
const visitor = await readPageVisitor();
|
||||
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
|
||||
}
|
||||
|
||||
return <LandingPage isLoggedIn={isLoggedIn} />;
|
||||
}
|
||||
|
||||
@@ -289,7 +289,8 @@ export default function PrivacyPolicyPage() {
|
||||
</p>
|
||||
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
|
||||
<p className="font-medium text-foreground">IPEK TECH LLC</p>
|
||||
<p>Wyoming, United States</p>
|
||||
<p>30 North Gould Street, Suite N</p>
|
||||
<p>Sheridan, WY 82801, United States</p>
|
||||
<p>
|
||||
Email:{' '}
|
||||
<a href="mailto:[email protected]" className="text-primary hover:underline">
|
||||
|
||||
+18
-7
@@ -30,7 +30,7 @@ export default function RefundPolicyPage() {
|
||||
|
||||
<main className="mx-auto max-w-[900px] px-4 py-12 sm:px-6 lg:px-8">
|
||||
<h1 className="text-3xl font-semibold tracking-tight mb-2">Refund Policy</h1>
|
||||
<p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p>
|
||||
<p className="text-sm text-muted-foreground mb-10">Last updated: July 30, 2026</p>
|
||||
|
||||
<div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80">
|
||||
<section>
|
||||
@@ -47,13 +47,13 @@ export default function RefundPolicyPage() {
|
||||
<p>
|
||||
All new accounts are eligible for a{' '}
|
||||
<strong className="text-foreground">7-day free trial</strong> with full access to paid
|
||||
features. We strongly encourage you to evaluate the Service fully during this period
|
||||
before subscribing.
|
||||
features. The trial requires no credit card and collects no payment. We strongly
|
||||
encourage you to evaluate the Service fully during this period before subscribing.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
You may cancel at any time during your free trial without being charged. If you do not
|
||||
cancel before the trial ends, your chosen plan will automatically activate and payment
|
||||
will be collected.
|
||||
The trial never activates a paid plan by itself. When it ends, paid features stop
|
||||
until you choose to subscribe, and a subscription is charged as soon as you complete
|
||||
checkout.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -77,6 +77,16 @@ export default function RefundPolicyPage() {
|
||||
for the current billing period. You will continue to have access to the Service until
|
||||
the end of your current paid period.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
This no-refund rule does not apply if we terminate your account for a reason other
|
||||
than your breach of the Terms, or if we discontinue the Service or your plan. In those
|
||||
cases we refund the unused portion of any fees you have prepaid, as described in
|
||||
Section 13 of our{' '}
|
||||
<Link href="/terms" className="text-primary hover:underline">
|
||||
Terms of Service
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -168,7 +178,8 @@ export default function RefundPolicyPage() {
|
||||
<p>For billing questions or refund requests:</p>
|
||||
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
|
||||
<p className="font-medium text-foreground">IPEK TECH LLC</p>
|
||||
<p>Wyoming, United States</p>
|
||||
<p>30 North Gould Street, Suite N</p>
|
||||
<p>Sheridan, WY 82801, United States</p>
|
||||
<p>
|
||||
Email:{' '}
|
||||
<a href="mailto:[email protected]" className="text-primary hover:underline">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { LogOut } from 'lucide-react';
|
||||
@@ -29,7 +30,7 @@ export default function SignOutPage() {
|
||||
{loading ? 'Signing out...' : 'Sign out'}
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<a href="/dashboard">Cancel</a>
|
||||
<Link href="/dashboard">Cancel</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { comparisonPages } from '@/lib/marketing/comparison-pages';
|
||||
import { seoConfig } from '@/lib/seo';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
@@ -11,6 +12,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: 'daily',
|
||||
priority: 1,
|
||||
},
|
||||
...comparisonPages.map((page) => ({
|
||||
url: `${seoConfig.url}/${page.slug}`,
|
||||
lastModified,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.8,
|
||||
})),
|
||||
{
|
||||
url: `${seoConfig.url}/login`,
|
||||
lastModified,
|
||||
@@ -23,5 +30,23 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${seoConfig.url}/terms`,
|
||||
lastModified,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${seoConfig.url}/privacy`,
|
||||
lastModified,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${seoConfig.url}/refund`,
|
||||
lastModified,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+60
-18
@@ -30,7 +30,7 @@ export default function TermsOfServicePage() {
|
||||
|
||||
<main className="mx-auto max-w-[900px] px-4 py-12 sm:px-6 lg:px-8">
|
||||
<h1 className="text-3xl font-semibold tracking-tight mb-2">Terms of Service</h1>
|
||||
<p className="text-sm text-muted-foreground mb-10">Last updated: April 10, 2026</p>
|
||||
<p className="text-sm text-muted-foreground mb-10">Last updated: July 30, 2026</p>
|
||||
|
||||
<div className="prose prose-sm prose-invert max-w-none space-y-8 text-sm leading-relaxed text-foreground/80">
|
||||
<section>
|
||||
@@ -58,7 +58,7 @@ export default function TermsOfServicePage() {
|
||||
OpenFrame is a video review and approval platform that enables creative professionals
|
||||
and their clients to collaborate on video projects through timestamped comments,
|
||||
annotations, version management, and approval workflows. The Service is offered on a
|
||||
subscription basis with a free trial period.
|
||||
subscription basis with a free trial period that requires no payment details.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -96,11 +96,12 @@ export default function TermsOfServicePage() {
|
||||
5. Subscriptions and Free Trial
|
||||
</h2>
|
||||
<p>
|
||||
Certain features of the Service require a paid subscription. We offer a{' '}
|
||||
<strong className="text-foreground">7-day free trial</strong> for new accounts, during
|
||||
which you may access paid features at no charge. At the end of the trial period, your
|
||||
subscription will automatically convert to a paid plan unless you cancel before the
|
||||
trial ends.
|
||||
Certain features of the Service require a paid subscription. Every new account
|
||||
receives a <strong className="text-foreground">7-day free trial</strong>, which starts
|
||||
once the account's email address is verified and requires no credit card. The
|
||||
trial does not convert into a paid plan on its own and nothing is charged while it
|
||||
runs. When it ends, access to paid features stops until you choose to subscribe, and a
|
||||
subscription is billed as soon as you complete checkout.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Subscription fees are billed in advance on a monthly or annual basis depending on the
|
||||
@@ -133,8 +134,8 @@ export default function TermsOfServicePage() {
|
||||
or regulation.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
We may remove or suspend access to User Content that violates these Terms at our sole
|
||||
discretion.
|
||||
We may remove or suspend access to User Content where we reasonably determine that it
|
||||
violates these Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -150,7 +151,8 @@ export default function TermsOfServicePage() {
|
||||
<li>Interfere with or disrupt the integrity or performance of the Service.</li>
|
||||
<li>
|
||||
Reverse engineer, decompile, or attempt to extract the source code of the Service
|
||||
(except where permitted by applicable open-source licenses).
|
||||
(except where permitted by the applicable source license, such as the Functional
|
||||
Source License covering the platform code).
|
||||
</li>
|
||||
<li>Use the Service to send spam or unsolicited communications.</li>
|
||||
<li>
|
||||
@@ -174,8 +176,11 @@ export default function TermsOfServicePage() {
|
||||
intellectual property laws.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
The core platform code is made available as open-source software; please refer to the
|
||||
applicable license in the GitHub repository for details on permitted use.
|
||||
The core platform code is made available under the Functional Source License
|
||||
(FSL-1.1-ALv2): the source code is publicly available and may be self-hosted, and each
|
||||
release becomes available under the Apache 2.0 license two years after its
|
||||
publication. Please refer to the license in the GitHub repository for details on
|
||||
permitted use.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -232,11 +237,47 @@ export default function TermsOfServicePage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">13. Termination</h2>
|
||||
<p>
|
||||
We may suspend or terminate your access to the Service at any time, with or without
|
||||
cause and with or without notice, if we believe you have violated these Terms or for
|
||||
any other reason at our sole discretion.
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">
|
||||
13. Suspension and Termination
|
||||
</h2>
|
||||
<p>We may suspend or terminate your access to the Service if:</p>
|
||||
<ul className="mt-3 list-disc pl-5 space-y-2">
|
||||
<li>
|
||||
you materially breach these Terms, including the Acceptable Use restrictions in
|
||||
Section 7, and do not remedy the breach within ten (10) days of our written notice.
|
||||
No cure period applies to a repeated breach of the same obligation;
|
||||
</li>
|
||||
<li>
|
||||
your use of the Service is unlawful, infringes a third party’s rights, or
|
||||
exposes us or other users to legal liability or a security risk, in which case we
|
||||
may act immediately and without prior notice;
|
||||
</li>
|
||||
<li>
|
||||
your subscription fees remain unpaid fourteen (14) days after their due date and we
|
||||
have notified you; or
|
||||
</li>
|
||||
<li>we are required to do so by law or by a competent authority.</li>
|
||||
</ul>
|
||||
<p className="mt-3">
|
||||
Where we reasonably suspect the conduct described in the second bullet above, we may
|
||||
suspend your account while we investigate. We will tell you why, and we will restore
|
||||
access if the suspicion is not borne out.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
If we terminate your account for any reason other than the ones listed above, or if we
|
||||
discontinue the Service or your plan, we will give you at least thirty (30)
|
||||
days’ written notice, refund the unused portion of any fees you have prepaid
|
||||
(notwithstanding Section 5 and our{' '}
|
||||
<Link href="/refund" className="text-primary hover:underline">
|
||||
Refund Policy
|
||||
</Link>
|
||||
), and keep your account accessible for export during that period.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Except where the law requires otherwise or the content itself is unlawful, your User
|
||||
Content will remain available for export for fifteen (15) days after termination,
|
||||
after which it may be permanently deleted. Write to us within that window if you need
|
||||
a copy and can no longer reach it yourself.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
You may cancel your account at any time through your billing settings or by contacting
|
||||
@@ -277,7 +318,8 @@ export default function TermsOfServicePage() {
|
||||
<p>If you have any questions about these Terms, please contact us:</p>
|
||||
<div className="mt-3 border border-border bg-card/40 p-4 text-sm space-y-1">
|
||||
<p className="font-medium text-foreground">IPEK TECH LLC</p>
|
||||
<p>Wyoming, United States</p>
|
||||
<p>30 North Gould Street, Suite N</p>
|
||||
<p>Sheridan, WY 82801, United States</p>
|
||||
<p>
|
||||
Email:{' '}
|
||||
<a href="mailto:[email protected]" className="text-primary hover:underline">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { VideoPageContent } from '@/components/video-page-content';
|
||||
import { ShareLinkBootstrap } from '@/components/share-link-bootstrap';
|
||||
import { ShareLinkUnlock } from '@/components/share-link-unlock';
|
||||
import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
interface WatchPageProps {
|
||||
params: Promise<{ videoId: string }>;
|
||||
@@ -19,5 +20,11 @@ export default async function WatchPage({ params, searchParams }: WatchPageProps
|
||||
return <ShareLinkUnlock videoId={videoId} />;
|
||||
}
|
||||
|
||||
return <VideoPageContent mode="watch" videoId={videoId} />;
|
||||
return (
|
||||
<VideoPageContent
|
||||
mode="watch"
|
||||
videoId={videoId}
|
||||
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash } from 'crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { checkRateLimit, getClientIp, rateLimit, rateLimitHeaders } from '@/lib/rate-limit';
|
||||
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
|
||||
import { getPublicOrigin, isTrustedSameOriginRequest } from '@/lib/request-origin';
|
||||
import { MAX_SHARE_PASSWORD_LENGTH, validateShareLinkAccess } from '@/lib/share-links';
|
||||
import {
|
||||
createPendingShareValue,
|
||||
@@ -48,7 +48,7 @@ function validateSameOriginRequest(request: NextRequest): NextResponse | null {
|
||||
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const { videoId } = await params;
|
||||
const cleanWatchUrl = new URL(`/watch/${videoId}`, request.nextUrl.origin);
|
||||
const cleanWatchUrl = new URL(`/watch/${videoId}`, getPublicOrigin(request));
|
||||
const legacyShareToken = request.nextUrl.searchParams.get('shareToken');
|
||||
|
||||
// Keep GET route for backwards compatibility, but never establish session from GET.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user