166 Commits
Author SHA1 Message Date
Yusuf İpek 9ca56c4226 Merge pull request #77 from yusufipk/fix/stripe-billing-lifecycle
fix(billing): stop collection after unpaid cancellation and preserve paid access
2026-09-08 16:56:06 +03:00
yusufipek 1c24336b6a fix(billing): serialize reconciliation and record accepted cancellations 2026-09-08 16:32:11 +03:00
yusufipek 57061b5a5d fix(billing): preserve entitlements and bound cancellation cleanup
Integrate the latest cancellation-reason flow from master. Keep paid periods and independent trials intact, stop collection without erasing historical or mixed receivables, and make scheduled and partial cancellations recoverable. Add regression coverage for invoice boundaries, entitlement expiry, cancellation selection and concurrent reason writes.
2026-09-08 15:49:37 +03:00
Yusuf İpek 6d283f0b60 Merge pull request #80 from yusufipk/codex/video-review-metadata
fix(seo): clarify video review metadata
2026-09-08 15:22:40 +03:00
yusufipek 7019676398 fix(seo): clarify video review metadata 2026-09-08 15:18:53 +03:00
yusufipek 2c890314c1 fix(billing): show paid cancellation dates instead of leftover trial 2026-09-08 15:17:19 +03:00
Yusuf İpek 36b2e5c905 Merge pull request #79 from yusufipk/claude/landing-page-design-refresh-c78363
feat(landing): refresh product-focused landing page
2026-09-08 15:08:33 +03:00
yusufipek a8607e8254 feat(landing): refresh product-focused landing page 2026-09-08 15:04:25 +03:00
Yusuf İpek 53c7899659 Merge pull request #78 from yusufipk/claude/lifecycle-messages-cancellation-a0142a
feat(billing): cancel in-app with a one-question reason
2026-09-08 14:21:18 +03:00
Yusuf İpek 6ad22508fe test(api): classify the billing cancel route in the auth matrix 2026-09-08 14:12:46 +03:00
yusufipek c0809e23bd test(billing): cover the Stripe field locations this change depends on
Every subscription fixture in the suite carries current_period_end at the top
level, which is the location the pinned API version no longer uses. So the item
level read, the reason this code exists, had no test at all and every other case
passed through the legacy fallback instead.

Covers both locations for the period and for the invoice's subscription link,
the null case the webhook relies on to leave a one-off invoice alone, and the
retry-window bound through the payload shape production actually sends.
2026-09-08 14:07:48 +03:00
Yusuf İpek 7aeda83eb6 feat(billing): cancel in-app with a one-question reason
Add a "Cancel subscription" button beside "Manage Subscription" in Settings.
It opens a dialog with one optional question (five answers, no default, a
note box under the two that want detail), then schedules the Stripe
subscription to end at the close of the current period without a trip to
the portal. The answer is stored in a new subscription_cancellations table
and shown, with an all-time tally, on the admin dashboard; the category is
also mirrored onto Stripe's cancellation feedback, the free text stays local.

The cancel route claims the local cancel flag with a conditional update
before calling Stripe, so two racing requests cannot both write a reason
row, and hands the claim back when Stripe refuses. A subscription Stripe no
longer knows answers 409 with a pointer to the portal instead of a 500. The
route carries an account-keyed rate limit on top of the shared IP one.

Two fixes found on the way: the pinned Stripe API version reports
current_period_end on the subscription item rather than the subscription, so
the sync stored null for every period end; a shared helper now reads the item
first. And the RadioGroup styles targeted a data-checked attribute radix
never writes, so the checked state was invisible in the light theme.
2026-09-08 14:05:16 +03:00
yusufipek d5cb288719 test(api): register the billing cancel route in the auth matrix
The api suite enumerates every route module under app/api and requires each
one to be classified as session-guarded or deliberately public. The new cancel
route was neither, so the suite failed on an unclassified module and on the
module count. It takes the same shape as the other billing routes: a session
plus a same-origin header.
2026-09-08 13:58:54 +03:00
yusufipek 85855a6d52 fix(billing): close the gaps the code and security reviews found
Follow-up on the same change, from a high-effort code review and security
review run over the diff.

Access gate:
- Scope both period-end guards to the period-end branch of hasBillingAccess
  instead of the top of the function. A cutoff is only ever cleared by a Stripe
  sync, so checking it first meant a stale one from a lapsed subscription
  outranked a freshly started cardless trial: the account burned its
  once-per-account trial and got nothing. buildBillingAccessWhereInput mirrors
  the same shape.
- Refuse a period end carried by an INCOMPLETE or INCOMPLETE_EXPIRED
  subscription, the rejection isPaidTier already makes. The cutoff is
  deliberately left null while a trial is live, so a trial user who abandoned a
  checkout kept the failed subscription's period once the trial ran out.
- Apply the cutoff in isPaidTier too, so it cannot say "paid" for a period
  where hasBillingAccess says access is over. That split left a locked-out
  account with no banner explaining it and able to create workspaces it could
  not then see. Both callers now select the field.

Lifecycle:
- Cancel through syncStripeCustomerSubscriptions rather than writing the single
  cancelled subscription, so a customer holding a second live subscription is
  not locked out of an account they are still being billed for.
- Ignore invoice events with no subscription. A one-off invoice against a
  customer record left by an abandoned checkout was marking the account
  canceled and booking a churn event for a subscription that never existed.
- Fall back to a window measured from now when a subscription behind on payment
  reports no period start, rather than falling through to "access ended", which
  locked out the customer that branch exists to keep in.
- Let a paused subscription run to its period end; it was being ended at once.
- Collapse BLOCKING_STRIPE_STATUSES into LIVE_STRIPE_STATUSES and include
  incomplete. The two sets were identical, which offered a Cancel button that
  always returned "No subscription to cancel" and left the Stripe-side checkout
  guard weaker than the mirror check it backs up.

UI and ops:
- cancelIsImmediate from the API, so the confirmation says what will actually
  happen to an incomplete subscription instead of promising the period end.
- The access banner reads "ended on" once the date has passed.
- The resync script selects the way the write path selects, over the customer's
  whole set. Filtering to live subscriptions first made the dry run disagree
  with the real run and skipped canceled and incomplete customers entirely,
  who are exactly the stale mirrors the script exists for.

Three existing tests asserted the behaviour this fixes: that a canceled
subscription keeps access to its reported period end, and that the cutoff is
ignored while that period runs. Both rest on the premise that a future period
end means a paid period, which is what is not true. They now assert the bound,
alongside new cases for the retry window, the trial-versus-stale-cutoff
ordering, and a never-paid period.
2026-09-08 13:51:59 +03:00
yusufipek fe1faeced4 fix(billing): pin the Stripe API version and stop unpaid periods granting access
The Stripe client was built without an apiVersion, so the SDK followed whatever
version it shipped with. Two fields moved in the Basil API version: the billing
period went from the subscription onto its items, and the invoice link to its
subscription went under parent.subscription_details. Both reads returned
undefined without failing, which left stripeCurrentPeriodEnd null for every
subscriber and left the app with no invoice handling at all. A customer whose
card failed saw nothing about the invoice that was still retrying, and a
cancellation did nothing to stop those retries.

- Pin the API version, with `satisfies` so an SDK bump is a compile error here
  before it is a null read in production.
- Read the period off subscription items and the subscription off invoice
  parents, keeping the legacy fields as a fallback for older payloads.
- Handle invoice.paid, invoice.payment_failed, invoice.voided and
  invoice.marked_uncollectible through the existing customer-wide resync, so
  the mirror reflects payment health during dunning rather than after it.
- Add an in-app cancellation route: at period end when the subscription is
  paid, immediately plus voiding the open invoices when it is not, because
  cancelling alone does not stop collection on an invoice already issued.
- Ask Stripe, not just the local mirror, before opening checkout.
- Show the open invoice, the retry date and a payment-method-update shortcut in
  settings, and put a confirmation in front of cancellation.

Access no longer rests on the reported period alone. Stripe advances the period
when it issues the renewal invoice, paid or not, and the period survives
cancellation, so once the period field started being read correctly that check
would have handed a full free month to anyone whose renewal failed, and the new
cancel route would have let them void the invoice and keep the month. Access now
follows the subscription status, billingAccessEndedAt is enforced as a hard
cutoff in both hasBillingAccess and the query that mirrors it, and a subscription
behind on payment keeps access for Stripe's retry window rather than for the
period it never paid for.
2026-09-08 13:30:42 +03:00
Yusuf İpek d5d2f0535e Merge pull request #75 from yusufipk/claude/openframe-mouse-overlay-bug-66603b
fix(player): re-arm the cursor idle timer when playback changes
2026-09-08 13:23:44 +03:00
Yusuf İpek 42b974e422 Merge pull request #76 from yusufipk/claude/webm-to-wav-conversion-0169ff
feat(voice-notes): convert recordings to WAV on download
2026-09-08 13:20:12 +03:00
yusufipek 7357f24831 refactor(player): share the cursor idle hook and stop waking on element pauses
Move the cursor idle logic into useCursorIdle and use it from both the video
page and the compare page, which carried its own copy. Only pointer activity
wakes the cursor now: a pause/play pair the element emits on its own (a
rebuffer, a source switch) leaves the idle state alone instead of bringing
the chrome back for a second. The fullscreen-while-paused arming is gone,
nothing rendered it. The scrub test now leaves the player before pressing
the timeline, as the real layout forces.
2026-09-08 13:17:34 +03:00
yusufipek 142dee0c06 feat(voice-notes): convert recordings to WAV on download
MediaRecorder gives us WebM/Opus, and that is exactly what we stored and served back. Browsers and desktop players read it, but no editing suite does: DaVinci Resolve, Premiere and Final Cut all refuse the container outright, so a voice note downloaded byte-for-byte was useless to the editor it was recorded for.

The browser already decodes these formats in order to play them, so the conversion costs nothing but a RIFF header. lib/audio-to-wav.ts decodes through an OfflineAudioContext and writes interleaved 16-bit PCM. This runs at download time rather than at record time, so the stored object stays the small Opus file, uploads keep their 10MB limit, and self-hosted installs gain no server-side ffmpeg dependency.

Voice comments had no download control at all, only a play button, so reviewers were saving files straight off the audio element and getting a bare UUID. They now get a download button on both comments and replies, named after the reviewer and the frame they were talking about, gated on the same download permission as the video and asset downloads. Audio assets get a WAV / Original menu.

Files already in an editable container (wav, mp3, m4a) are handed over untouched: audio assets are not only recordings, and decoding an uploaded master back out would resample it to 48 kHz and requantise it to 16 bit for no gain. When a browser cannot decode the stored format at all, the original is saved and the user is told.
2026-09-08 13:15:28 +03:00
yusufipek b2070c1030 fix(player): re-arm the cursor idle timer when playback changes
The idle countdown that hides the cursor and the play/pause overlay was only
started from mousemove. A cursor that stayed still over the player while a
click, a key or a scrub release started playback never got a countdown, so
the overlay stayed on the video until the mouse moved again.

Arm the timer from one place and rerun it whenever playback or fullscreen
changes, keeping the cursor-over-player state in a ref so the same rule
applies from every entry point.
2026-09-08 12:55:30 +03:00
Yusuf İpek 79bba5e7a1 Merge pull request #74 from yusufipk/claude/compress-video-landing-page-08cc57
feat(landing): replace the hero image with the flow video
2026-09-01 15:28:59 +03:00
Yusuf İpek ab03f7c378 Merge pull request #73 from yusufipk/fix/download-unload-guard
feat(billing): defer the cardless trial for invited collaborators
2026-09-01 15:24:50 +03:00
yusufipek 43cc54c0ae feat(landing): drop the toolbar overlay and gradient from the hero video 2026-09-01 15:23:17 +03:00
yusufipek 54e99cb4ab test(api): register billing/trial in the auth matrix 2026-09-01 15:17:51 +03:00
yusufipek 5f061d1b09 feat(landing): replace the hero image with the flow video 2026-09-01 15:12:40 +03:00
yusufipek 59a64141ee chore(lint): ignore .claude worktree checkouts in eslint
A worktree parked under .claude/worktrees is a separate checkout; eslint
scanning it fails bun run check on files outside this tree.
2026-09-01 15:09:53 +03:00
yusufipek 4b3c3934dd feat(billing): defer the cardless trial for invited collaborators
An account that signs up through an invitation works on the inviter's
billing, so handing it a trial at signup spent its only trial before it
owned anything. The trial is now held back for collaborators and claimed
only explicitly: a Start Free Trial button on the new-workspace and
billing screens calls the new POST /api/billing/trial endpoint, which
grants the once-per-account trial atomically. Nothing starts the clock
as a side effect, and pure collaborators no longer see a trial-ending
banner about work that is not theirs.
2026-09-01 15:07:54 +03:00
Yusuf İpek c5c9da1e30 Merge pull request #70 from yusufipk/claude/landing-page-removal-c07551
feat(landing): remove the Fair Source badge from the hero
2026-08-25 07:44:00 +03:00
yusufipek 07a6bfbef4 feat(landing): remove the Fair Source badge from the hero 2026-08-25 07:37:30 +03:00
Yusuf İpek d894eeb0e4 Merge pull request #69 from yusufipk/fix/download-unload-guard
fix(download): warn before the tab closes mid-download
2026-08-22 13:02:32 +03:00
yusufipek cba8163286 fix(download): warn before the tab closes mid-download
Bunny and direct downloads are pulled through fetch() so we can save them
under our own filename. The browser does not treat that as a download, so
closing the tab discarded everything received so far without a word.

Register a reference counted beforeunload guard while those transfers are
in flight, and while a project manifest is being pulled file by file.
Browser owned downloads (same-origin proxy, the over-10GB fallback, asset
downloads) survive a tab close on their own and stay unguarded.
2026-08-22 12:50:45 +03:00
Yusuf İpek 74e4b4353e Merge pull request #68 from yusufipk/feat/version-subtitles
feat(player): let editors upload subtitles for a version
2026-08-22 08:16:35 +03:00
yusufipek a709ca8544 fix(subtitles): escape a rejected cue tag instead of deleting it
Deleting a tag whole is what lets a filter like this be reassembled around: strip the `<b>` out of `<scr<b>ipt>`
and the two halves close up into a tag nobody wrote. The leftovers are escaped one character at a time instead,
which also covers `-->` in cue text without a second multi-character replacement.

Both are what CodeQL flagged on the branch, js/incomplete-multi-character-sanitization and js/bad-tag-filter.
Neither was reachable as an injection, because the file is served as text/vtt and a cue is parsed by the WebVTT
cue-text parser rather than as HTML, but a sanitiser that cannot be reassembled around is the cheaper thing to own.
2026-08-22 08:01:12 +03:00
yusufipek d981d98cf5 feat(player): let editors upload subtitles for a version
Subtitle tracks hang off a version rather than off a video, because re-editing a cut shifts every cue. The file
always lands in our own S3-compatible storage whatever hosts the video, so a Bunny-hosted cut and an R2 one take the
same path: both already play through our own video element, so a track element is all it takes.

Uploads are normalised before they are stored. Whatever arrives, SRT or WebVTT, is parsed into cues and
re-serialised as a canonical WebVTT file, and anything we did not understand is dropped rather than passed through.
That is what makes it safe to serve a user-supplied text file from our own origin. Files saved out of Windows
editors are decoded as windows-1254 or windows-1252 when they are not valid UTF-8, rather than refused.

A YouTube version cannot carry an uploaded track, so the same CC menu drives YouTube's own captions through the
iframe module API. The embed hides YouTube's controls, so until now those captions were unreachable even when the
video had them.

Uploading and deleting take the editor permission rather than the commenter one: a subtitle is part of the
delivered cut, not a comment attachment.
2026-08-22 07:51:46 +03:00
Yusuf İpek 1f3c6b3f1e Merge pull request #67 from yusufipk/chore/version-0-1-1
chore(release): set the package version to 0.1.1
2026-08-20 17:21:40 +03:00
yusufipek 6f575e48bf chore(release): set the package version to 0.1.1
The tag and the manifest had drifted: v0.1.1 ships the runtime Bunny CDN
config, while package.json still read 0.1.0. bun.lock records no version for
the root workspace, so a frozen install is unaffected.
2026-08-20 17:11:16 +03:00
Yusuf İpek 9826c117dd Merge pull request #66 from yusufipk/fix/bunny-cdn-runtime-config
fix(bunny): read the CDN host from runtime config so Docker images can play video
2026-08-20 16:19:15 +03:00
yusufipek 0ff8b42b4a fix(bunny): read the CDN host from runtime config so Docker images can play video
NEXT_PUBLIC_BUNNY_CDN_URL is inlined into the client bundle at build time, and
the published image is built by CI without it, so the browser had no host to
build a playlist URL from no matter what the operator set in .env.docker. The
player read the empty URL as a stream that had not finished encoding and sat on
'Video Is Processing', retrying forever.

The server knows the value on every request, so the root layout now serialises
the public settings into a JSON script tag and the browser reads them from
there, falling back to the build-time variable for source builds. The
direct-download allow list came through the same broken path and moves with it.

Closes #60
2026-08-20 15:40:21 +03:00
Yusuf İpek 1f2294c7ac Merge pull request #65 from yusufipk/chore/trim-comment-composer-hint
chore(comments): drop the image paste hint from the composer
2026-08-20 15:22:46 +03:00
yusufipek bcaf9e0c57 chore(comments): drop the image paste hint from the composer 2026-08-20 15:04:17 +03:00
Yusuf İpek 68d23ef1e6 Merge pull request #64 from yusufipk/fix/silent-speed-threshold
fix(player): label only 16x as silent in the speed picker
2026-08-20 11:43:53 +03:00
Yusuf İpek 14ddf19d58 Merge pull request #63 from yusufipk/feat/multi-image-comment-attachments
feat(comments): carry a batch of screenshots on one comment
2026-08-20 11:43:38 +03:00
yusufipek b040fe0dc9 fix(player): label only 16x as silent in the speed picker
The "no audio" note was attached to everything past 4x on a guess about
where the browsers stop pitch-correcting. Playing the ladder through
confirms audio survives 6x and 8x; 16x, the rate Chrome and Firefox clamp
to, is the only silent one. Move the threshold up so the two fast rates
that do carry sound stop advertising otherwise.
2026-08-20 11:36:39 +03:00
yusufipek b9e2006e34 feat(comments): carry a batch of screenshots on one comment
A comment held one image, and the paste handler took the first item off the
clipboard and dropped the rest. Reviewing a cut usually means several
screenshots about the same moment, which meant one comment per screenshot or
one screenshot and a paragraph describing the others. Editing a comment could
not attach anything at all: the edit box had no paste handler, no file picker
and no way to remove what was already there.

A comment now carries up to five images, in the composer, in a reply and in
the editor. One paste stages every image on the clipboard, the file picker
takes a multiple selection, and a drop lands on whichever editor is open. Over
the cap the extras are refused out loud rather than dropped quietly. A single
image still fills the width; several tile into a grid, and either opens full
screen on click.

The images move into their own table. `comments.imageUrl` stays and follows the
first of them, so a reader that has not been updated keeps working, and the
migration copies the existing attachments across so the new table is complete
from the first read. Every path that resolves a URL back to a comment now asks
the new table: R2 cleanup, the orphan sweep, the storage accounting and the
reference checks that decide whether an object can be deleted. Left on the old
column they would have treated images two through five as unreferenced and
swept them.

Detaching an image while editing only breaks the link. The file stays in R2 and
in the assets pane, which is where it is deleted from and where its bytes are
already billed.
2026-08-20 11:01:33 +03:00
Yusuf İpek 689f5bc81b Merge pull request #62 from yusufipk/worktree-bunny-playback-speed
feat(player): raise the playback speed ceiling off YouTube's limit
2026-08-20 11:00:26 +03:00
yusufipek 7b7b1d21b2 feat(player): raise the playback speed ceiling off YouTube's limit
A single speed ladder fed both players, so the 2x cap that YouTube's
iframe API enforces also applied to Bunny and R2, which are plain <video>
elements the browser will play far faster. Pick the ladder per provider:
YouTube keeps 0.25x-2x, the native ones go up to 16x, where Chrome and
Firefox clamp playbackRate. The picker labels everything past 4x as
"no audio", since that is where the browsers stop pitch-correcting and
drop the audio track.
2026-08-20 10:50:42 +03:00
Yusuf İpek 4d9d35164f Merge pull request #61 from yusufipk/feat/upload-ceiling-from-quota
feat(uploads): size one upload against the account's own quota
2026-08-20 09:04:32 +03:00
yusufipek 7b60f3bf76 feat(uploads): size one upload against the account's own quota
The per-file ceiling was a flat 5 GiB from the environment, which is both
too small for a paying account with 200 GB of storage and unaware of what
an upload actually costs. The provider derives its own renditions from the
file (1080p, 720p and down) and bills them to the same account, so a file
allowed to fill the quota exactly is over it by the time it finishes
processing.

The ceiling is now 80% of whatever limit the account is held to: 160 GB on
the plan, 2.4 GB on a cardless trial, and it moves on its own when either
number changes. OPENFRAME_MAX_VIDEO_UPLOAD_BYTES keeps working as an
absolute cap for a host that wants one, where the lower of the two applies,
and an instance running without billing has no quota to divide and falls
back to the flat 5 GiB. The refusal now names the ceiling, which the old
one left the client to guess.

Finalize re-checks only the host cap. Re-deriving the account's ceiling
there would delete a finished upload over a plan that lapsed while the
bytes were in flight, and an upload larger than what was declared is
already caught by the declared-size check beside it.
2026-08-20 08:52:26 +03:00
Yusuf İpek 1d65ed348e Merge pull request #59 from yusufipk/fix/admin-cardless-trial-visibility
fix(admin): show the cardless trial as a trial in the admin panel
2026-08-19 08:10:29 +03:00
yusufipek 60c208b3b3 fix(admin): show the cardless trial as a trial in the admin panel
The cardless trial writes trialEndsAt and nothing else, because there is no
Stripe subscription behind it to report trialing. subscriptionStatus stays
FREE, so every admin view reading that column alone showed a live trial as a
free account: On Trial sat at zero, Free Users counted the trials, the user
table badged them Free with an open-ended Active access, the Trialing filter
returned nobody and the growth scoreboard left them out of the paid accounts
table.

Access has always been resolved from the date (hasBillingAccess), so the
display now follows the same date through getEffectiveBillingStatus. Only FREE
is overridden: any other status means Stripe has an opinion worth showing.
2026-08-18 18:55:41 +03:00
Yusuf İpek bf46860feb Merge pull request #58 from yusufipk/fix/theme-menu-spacing
fix(theme): stop the theme menu from crowding its own top edge
2026-08-18 12:25:22 +03:00
yusufipek 2bc07c47e6 fix(theme): stop the theme menu from crowding its own top edge
The menu had no inner padding, so the first row's icon sat flush against
the popover border and the whole box read as clipped under the header.
It now carries the same padding, offset and fixed width as the account
menu next to it, and the System row uses a lucide icon instead of a
colour emoji that broke the icon column's alignment.
2026-08-18 12:21:02 +03:00
Yusuf İpek a16f7cd856 Merge pull request #57 from yusufipk/fix/new-video-page-upgrade-link
fix(upload): give the Add Video page the upgrade link too
2026-08-18 12:17:39 +03:00
yusufipek a055f4a8f0 fix(upload): give the Add Video page the upgrade link too
The trial ceiling refusal is drawn twice: the drag-and-drop uploader toasts it,
and the Add Video page writes it into the form as submitError. Only the first
one was routed through the error code, so the page that most uploads go through
printed "Upgrade to get 200 GB" with nothing to click.

submitError now carries whether the failure was the trial ceiling, set in the
same call as the message so the link cannot outlive it, and the three places
that set it hand over the failure they caught.
2026-08-18 12:13:09 +03:00
Yusuf İpek 98df4e783a Merge pull request #56 from yusufipk/fix/upload-upgrade-link
fix(upload): give a full trial account the upgrade link where it reads the refusal
2026-08-18 12:05:03 +03:00
yusufipek f9f08a021d fix(upload): give a full trial account the upgrade link where it reads the refusal
The video uploader threw the API error away and toasted the bare message, so
the one caller most likely to hit the trial storage ceiling was the one that
lost the way out of it. Route it through toastApiError, which keeps the error
code and attaches the action, and repeat the link in the queue row so it
survives the toast timing out.

The button now says Upgrade rather than See plans, matching the verb the
message itself uses.
2026-08-18 11:57:17 +03:00
Yusuf İpek 09af61b531 Merge pull request #55 from yusufipk/feat/storage-upgrade-nudge
fix(storage): count a finished Bunny upload, and offer the upgrade when a trial is full
2026-08-18 11:47:16 +03:00
yusufipek 6561e0817e feat(storage): point a full trial account at the upgrade rather than at nothing
Being told "storage limit exceeded" when the limit is the free trial's three
gigabytes is a dead end. The account is not full because it stores a lot; it is
capped because it has not subscribed, and deleting files buys back very little.

The refusal now says which ceiling it is and carries its own error code, so the
toast can offer a link to the billing settings on the trial ceiling and stay
quiet on the paid one, where subscribing changes nothing.

The code had to survive the trip to the toast, which meant the upload helpers
throwing something that carries it rather than a bare Error. Two places were
dropping the server's message on the floor entirely: adding a version reported
"Failed to initialize upload" whatever the server said, and every asset upload
in the pane rewrote its own failure text.
2026-08-18 11:42:30 +03:00
yusufipek 63288761ed fix(storage): count a finished Bunny upload the moment it lands
Two reasons the number on the storage page could read as nothing.

The per-user Bunny figure was computed inside a two minute cache. The declared
size lands on the row in the same transaction that deletes the reservation, so
for up to two minutes an upload that had just succeeded counted as nothing:
usage fell back towards zero and the next upload was measured against a total
that ignored the one before it. The call to Bunny stays cached, because it is
the slow half and its answer is the same for everybody. The join against our own
rows is now read fresh, per user, on every check.

A failed call to Bunny returned an empty map before it had looked at a single
row, so an account with gigabytes of declared uploads read as empty whenever
Bunny was unreachable. Bunny's figure being gone is not a reason to forget the
sizes we wrote down ourselves.

The rule for which of the two numbers to charge is unchanged, and the comment
above it now says why rather than guessing. What Bunny reports mid-encode is
partial: storageSize counts what has been written so far and climbs as each
rendition lands. A six minute cut uploaded at 2.5 GB read as 475 MB halfway
through and settled above 3 GB once it finished, because Bunny keeps the
original alongside every rendition. Taking the larger of the declared size and
Bunny's is right at every point on that curve; taking Bunny's whenever it is
non-zero would hand most of the quota back in the middle of an encode.

The settings card also claimed a 200 GB limit while showing a 3 GB one, and told
a trial account to delete files or contact support.
2026-08-18 11:42:30 +03:00
Yusuf İpek 2c4c6101d5 Merge pull request #54 from yusufipk/fix/bunny-upload-reservation
fix(uploads): count a Bunny upload from the moment it is admitted
2026-08-18 11:12:30 +03:00
yusufipek e78911e850 fix(comments): say the account is full instead of blaming the comment
The attachment goes up before the comment does, so a full account fails on
the image and never reaches the comment at all. Both that failure and a
rejected comment came out as "Failed to add comment", which tells the
uploader to try again, and trying again is the one thing that cannot work
when there is no room left.

Both now read out what the server said. A network fault, which has no message
anybody wants to see, still falls back to the old line.
2026-08-18 11:07:25 +03:00
yusufipek 00f1d430b8 fix(uploads): stop a storage hold from being dropped by whoever can name it
A reservation id was never a secret and could not have been one. An upload
token is base64url(payload) followed by its signature, so a client can read
every claim out of its own token, and the two R2 init routes hand their
reservation ids to the client outright. The asset route takes a reservation
id from the request body and deleted it on the strength of that id and the
billed user alone, and every hold an account owns is billed to the same user.

So a caller could start a Bunny upload, read the id out of the token they
were just given, quote it while attaching a one byte image or even a bare
YouTube link, and have the quota handed back while the upload carried on.
Repeat and a trial worth three gigabytes uploads as much as it likes for as
long as Bunny takes to report a figure of its own. Signing the id rather than
handing it over bought nothing, because signing is not hiding.

A hold now records what it was opened for and is only ever consumed by that
flow, so naming one is no longer enough to drop it.

Guests hold against the workspace owner's quota rather than their own and had
no way to give it back: the release was gated on being signed in. Declaring a
size and walking away cost the guest nothing and cost the owner their whole
remaining allowance for two hours. The guest grant now carries the reservation
and the declared size, bound to the Bunny video as well as to ours, so
cancelling gives the quota back and costs them the upload it stood for. What a
guest can hold without cancelling lapses in half an hour rather than two hours.

The in-transaction fallback check counted the account's Bunny storage as zero
on a Bunny upload, because the figure was only prefetched for R2 providers and
that branch was unreachable for Bunny until this PR made it reachable. On an
account whose storage is all Bunny that was a check that could not fail. It is
prefetched for every provider that can reach the fallback now.
2026-08-18 11:07:18 +03:00
Yusuf İpek 7b36e027ff Merge pull request #53 from yusufipk/feat/admin-api-token
feat(admin): let a token read the growth scoreboard without a session
2026-08-18 10:40:05 +03:00
yusufipek 4ff801738c fix(uploads): count a Bunny upload from the moment it is admitted
A Bunny init asked the quota whether it could store zero bytes, which is a
question with only one answer. Nothing an upload was about to consume was
visible to the next request, so every init inside the same window read the
same total and every one of them passed, and an upload that could never
fit was only refused after it had been sent.

The client now declares the size up front. It is checked against the
account's remaining room before Bunny is asked for anything, and held as
a reservation the next init has to see. The declaration is a claim rather
than proof, so it is signed into the upload token: the same token already
binds the video id, which is what makes the reservation safe to release
on a caller's say-so, since releasing it costs them the video it belongs
to.

The declared size is then written onto the version or asset row and the
reservation is dropped in the same transaction, because Bunny reports no
size at all for a video until it has finished encoding it. On a half hour
of footage that is most of an hour during which the upload did not appear
on the uploader's own storage page and did not count against the next
upload. Per-video accounting now takes the larger of what Bunny reports
and what was declared, so the estimate stands in until the real figure
arrives and Bunny's wins once it does.

Two smaller things came out of the same reading. The asset route's
in-transaction fallback compared against the plan limit, so a caller
quoting a reservation that no longer existed was measured against 200 GiB
even on a trial worth three. And the guest branch reserves without being
able to release early, because a guest grant is bound to our video id and
the caller's network context rather than to the Bunny video, which would
let the reservation be dropped while the upload it stands for carried on.
2026-08-18 10:35:08 +03:00
yusufipek bfe3cb28b4 feat(admin): let a token read the growth scoreboard without a session
The weekly digest reads /api/admin/growth from a script, which has no browser
and therefore no NextAuth session. The alternative was copying a session cookie
out of a browser by hand: those are JWTs with a 30-day lifetime, so a scheduled
job built on one stops working a month later and reports nothing rather than
reporting a failure.

The token path is off unless OPENFRAME_ADMIN_API_TOKEN is set, so an instance
that never sets it keeps session-only admin access. A value under 32 characters
is treated as no token at all: behind this header sit every paying account's
name, email and usage, and a short token is a guessable path to all of it.
Comparison runs over SHA-256 digests so it stays constant time without leaking
the token's length.
2026-08-18 10:22:42 +03:00
Yusuf İpek 32164db15c Merge pull request #51 from yusufipk/feat/cardless-trial
feat(billing): let people try the product before handing over a card
2026-08-18 09:57:40 +03:00
yusufipek 313cd552e6 fix(billing): stop an unpaid subscription from unlocking the paid limits
isPaidTier read a future stripeCurrentPeriodEnd as proof of payment, and
Stripe stamps a current period on an incomplete subscription all the
same. A checkout whose first charge failed therefore carried a period
end a month out with nothing paid behind it, and every ceiling the
cardless trial puts on an unpaid account (200 GB of storage, unlimited
projects, unlimited workspaces) came off with it.

Written as a deny list of the two statuses that mean no charge has ever
gone through, so a real customer whose renewal failed keeps the full
plan for the period they already paid for.

hasBillingAccess reads the same column the same way and is deliberately
left alone: being wrong there locks a paying customer out, and the SQL
in buildBillingAccessWhereInput has to move with it.
2026-08-18 09:48:10 +03:00
yusufipek 1ec5c53802 fix(auth): start the cardless trial for social signups too
The trial is granted in the credentials signup route and on the
verification link, and an account created by Google or GitHub goes
through neither: the Prisma adapter writes it directly. Since checkout
no longer offers a trial either, such an account reached a locked
product and an immediate bill, which is the opposite of what the signup
page promised it.

The address is already proven at this point, because the signIn callback
turns away an OAuth profile that reports its email as unverified, and
the grant is idempotent, so nothing here can hand out a second trial.
2026-08-18 09:48:00 +03:00
yusufipek 4c0b18e5e4 feat(marketing): say no card where the trial is actually claimed
Three places still carried the old promise. The register form, which is where
the CTA lands and where the trial is granted, said nothing about it at all, so
the landing page's claim had to be taken on faith across a page load. The
comparison profile summary still described the trial without mentioning the
card. And the CtaLink comment quoted a button label that no longer exists.

The hero and closing CTAs now read 'Open dashboard' for a signed-in visitor.
That button already pointed at /dashboard for them, and offering a free trial
to someone who is mid-subscription reads as a bug. The pricing card keeps the
short label because it sits directly under 'No credit card' and the card
narrows to about 230px at the md breakpoint, where the long label would wrap
out of a fixed-height button.
2026-08-18 09:23:21 +03:00
yusufipek 364e60146d test(e2e): expect a fresh account to reach onboarding, not billing
The registration spec asserted that a brand new account lands on /settings,
which was true only because registering left trialEndsAt empty. It is now
filled at signup, so the account has billing access and the dashboard lets it
through to the onboarding wizard. That redirect is the done condition of the
cardless trial, so the spec should hold it.
2026-08-18 09:13:42 +03:00
yusufipek 03f670cf0e Merge remote-tracking branch 'origin/master' into feat/cardless-trial 2026-08-18 09:12:32 +03:00
Yusuf İpek e98cfe20ec Merge pull request #52 from yusufipk/fix/scoreboard-tests-week-boundary
fix(tests): stop the scoreboard suite from depending on the weekday
2026-08-06 00:05:36 +03:00
yusufipk b27b37c11f fix(tests): stop the scoreboard suite from depending on the weekday
The scoreboard groups by date_trunc('week'), which starts on Monday, but the
suite seeded its events with "three days ago". On a Wednesday that walks back
into the previous week, so a returning visitor was counted once in each of two
weeks and a subscription landed outside the week the assertions read. The suite
passed Monday and Thursday through Sunday, and failed Tuesday and Wednesday.

Seed from a week boundary instead: the visitor events go into last week, which
is whole whenever the suite runs, and the subscription pair goes into this one,
which is the week those assertions read.
2026-08-05 23:57:13 +03:00
yusufipk 39e81042bb feat(billing): let people try the product before handing over a card
The trial now starts inside the product, at email verification, and Stripe
grants none at all: checkout creates a subscription that bills immediately.
Verifying an address is what buys the seven days, which is also the cheapest
abuse control there is.

An unexpired trial is treated as an entitlement the account already holds, so a
Stripe sync can add access but never retracts a trial that has not run out. That
matters most for the abandoned checkout: the resulting incomplete subscription
carries no trial_end, and writing it through would have erased the days the
account still had and locked it out.

Unpaid accounts are bounded by what they can cost us rather than by what they
can do: one workspace, one project, 3 GiB of direct uploads. YouTube imports,
share links, guests, comments and approvals stay unlimited, because those are
the parts worth trying and they cost nothing. isPaidTier() is the new seam;
hasBillingAccess() answers a different question now that access no longer
implies a card.

Signup CTAs, the pricing card, the comparison pages, the terms and the refund
policy all said the trial converts to a paid plan by itself. It no longer does,
so they say what happens instead. Settings and a banner name both dates that
matter: when the trial ends, and the fifteen days after that during which
nothing is deleted.

/admin/growth compares the two funnels on signup to paid within a fixed 30 day
window, not trial to paid. Dropping the card requirement multiplies trials, so
the old ratio can fall while more people actually pay, and reading it that way
would retire the change for the wrong reason.
2026-08-05 19:40:36 +03:00
Yusuf İpek b8d68a9196 Merge pull request #50 from yusufipk/feat/acquisition-analytics
feat(analytics): record where paying customers actually came from
2026-08-05 18:47:51 +03:00
yusufipk e3676e6142 docs(legal): print the full US business address on the site
Stripe flagged the account under Visa 1.5.1.2 and Mastercard 5.5/5.6:
the location stated on the site has to match the Stripe account. The
legal pages only said "Wyoming, United States" and the footer said
nothing at all. Both now carry the same address Stripe has on file.
2026-08-02 13:47:44 +03:00
yusufipk 33c845636c fix(analytics): sign the acquisition cookies and bound what they can write
Both cookies were read straight into database columns after nothing more than a
format check. httpOnly keeps JavaScript out of them and does nothing about curl,
so the anonymous id was a string the caller picked: enough to write a first-touch
row for a visitor who never existed, to file it under a channel of their
choosing, and to claim that id's events at signup, since the backfill matches on
the id alone.

They are now signed with an HMAC over AUTH_SECRET, through Web Crypto rather than
node:crypto because the proxy runs on the edge and the pages that read the
cookies back run in Node. The first-touch body moved to base64url on the way:
cookie values are percent-encoded and decoded by several layers that do not agree
on how many times, and a payload carrying its own percent escapes comes back
subtly different and takes the signature with it.

Signing stops a caller choosing an id, not collecting one, since dropping the
cookie and asking for the landing page again mints another. So the bot and
prefetch filters moved to where the rows are written rather than only where the
cookies are issued, which also fixes a returning visitor's prefetch of /register
recording a signup start, and a per-client hourly ceiling now sits in front of
the write. The ceiling is skipped when TRUSTED_PROXY_MODE is unset, where every
caller resolves to 127.0.0.1 and the bucket would empty on real traffic long
before it emptied on a flood.

Four smaller things around it:

- /api/events checked the flag and the origin after paying for a rate-limit
  write, so a host who never turned analytics on was still writing a row per
  anonymous POST. Both checks are free and now come first, and the limiter
  answers 204 rather than 429: a beacon has nobody to tell, and a flooder should
  not be handed the reset time.
- /api/onboarding/source was keyed by IP on an authenticated route. Without
  TRUSTED_PROXY_MODE that is five answers an hour for the whole deployment, and
  with it a shared office address locks out everyone after one colleague
  answered. Keyed by account, like /api/onboarding/complete beside it.
- The cookies took their Secure flag from request.nextUrl.protocol, which behind
  a TLS-terminating reverse proxy is the container-internal http address. It
  comes off the configured public origin now.
- sanitizeLandingPath took anything that started with a slash, including from the
  cookie, so a hand-written one could put newlines and markup into a column an
  admin table may render one day.

Also: the paid-account query had no LIMIT and returned every active account's
name and email, the growth route answered 403 where it meant 401, and the schema
claimed no free text is stored when self_reported_note holds 200 characters of it.
2026-08-01 20:29:28 +03:00
yusufipk 7ca5abd041 feat(analytics): record where paying customers actually came from
Adds first-party acquisition attribution and a sixteen-event funnel, written to
this deployment's own database and read back on /admin/growth. Nothing is sent
anywhere else, and the whole subsystem is off unless OPENFRAME_ENABLE_ANALYTICS
is set, so a self-hosted instance carries the tables empty and pays nothing.

The proxy gives a visitor an anonymous id and stores what brought them in two
first-party cookies; signup copies that onto the account and claims the events
the visitor produced before they had one, which is what joins the two halves of
the funnel. Recording happens where each step actually happens rather than in
the browser: an ad blocker cannot undercount landing views, and blocking rates
differ by channel, so an undercounted denominator would have made GitHub traffic
look like it converts better than it does.

Every event carries a dedupe key on a UNIQUE column, so "recorded exactly once"
is a property of the schema rather than of fifteen call sites. Subscription
events are derived by comparing the row being overwritten with the row being
written inside the existing Stripe sync, which makes them order-independent and
replay-safe.

The scoreboard reports step-to-step conversion with the denominator beside it,
and splits by source over a rolling 28-day window rather than a week: at this
volume a weekly per-source cell holds single digits, and a percentage computed
from three visits reads exactly as confidently as one computed from three
hundred.

"How did you hear about us?" is asked on the first onboarding screen, not on the
registration form. The number being measured is the signup conversion rate, and
a question added to that form would move it.
2026-08-01 20:00:27 +03:00
yusufipk 93e85683e9 fix(downloads): stop repeating an extension the name already carries
A voice comment's display name is the generated file name, extension
included, so appending the extension again downloaded it as
<uuid>.webm.webm. Both download paths, the single asset route and the
project zip, now append only when the name does not already end in it.
2026-07-30 23:13:04 +07:00
yusufipk e7008c571a fix(voice): record the real length and write it into the file
The recording clock counted setInterval ticks, which a background tab
throttles away: a recording that kept going looked frozen at 13 seconds
and was saved with that length. It now reads the wall clock instead.

MediaRecorder also writes WebM with no usable duration. Chrome omits the
element entirely, Firefox reserves a Duration of 0.0 it never fills in,
so players had no length to show and played past the end of the seek bar.
lib/webm-duration.ts stamps the recorded length into Segment > Info when
the recording stops, in place where the browser reserved room for it.
2026-07-30 23:13:04 +07:00
Yusuf İpek a527e9f935 Merge pull request #49 from yusufipk/docs/terms-export-window
docs(terms): match the export window to the cleanup the product runs
2026-07-30 16:25:50 +03:00
yusufipk 726c04d9cc docs(terms): match the export window to the cleanup the product runs
Termination promised thirty days of export while the storage cleanup
deletes fifteen days after billing access ends, so the product could
delete content the terms still covered. Fifteen is the window that is
actually enforced, in lib/billing.ts.

The same sentence also promised content would stay available for export,
which it does not: access ends with billing, so a user inside the window
cannot reach their own footage. It now says to ask us for a copy instead
of implying the export is self-service.
2026-07-30 20:07:36 +07:00
Yusuf İpek ea6414348f Merge pull request #48 from yusufipk/fix/expired-billing-cleanup-null-dates
fix(billing): select expired owners whose billing dates are null
2026-07-30 15:38:34 +03:00
yusufipk 733ac43172 test(api): match the bunny host instead of a substring of the url
CodeQL flags the substring form (js/incomplete-url-substring-sanitization)
because a host check on an unparsed url matches when the host appears
anywhere in it. Nothing untrusted reaches this recorder, but a loose
match could still record a delete aimed elsewhere as a Bunny delete and
pass an assertion for the wrong reason.
2026-07-30 19:33:19 +07:00
yusufipk 38d829597a test(api): pin the stripe-disabled guard to a real empty result
The guard is `{ id: { in: [] } }`, which is only safe because Prisma
renders an empty IN list as `WHERE 1=0` instead of dropping the filter.
A regression there would delete every workspace on a self-hosted
deployment, which is too expensive to leave resting on that assumption.
2026-07-30 19:30:34 +07:00
yusufipk 80ef29f787 fix(scripts): tell an empty cleanup scan apart from an unreachable one
The cleanup printed one number for expired owners, the count of
workspaces it found, so zero meant either that nobody had passed the
grace period or that everyone who had owns nothing. The first is normal
and the second means media is held alive by rows the cleanup cannot
reach, and telling them apart took a hand-written query against
production. Both counts are reported now.

Bunny and R2 results were also discarded. The workspace row is deleted
first, so a refused storage delete leaves media that nothing points at,
and nothing recorded that it happened. logCleanupWarnings already exists
for this and is now called with the per-workspace result.
2026-07-30 19:26:06 +07:00
yusufipk a3036f1a52 fix(billing): select expired owners whose billing dates are null
The expired-owner filter expressed "no billing access" as
NOT: buildBillingAccessWhereInput(now). Prisma renders that as
NOT (status IN ('ACTIVE','TRIALING') OR "trialEndsAt" > $1 OR
"stripeCurrentPeriodEnd" > $2), and a SQL comparison against NULL is
unknown rather than false, so for a row with both dates empty the OR is
NULL and NOT NULL is NULL: the row is never returned.

Both dates empty is exactly what a canceled subscriber looks like, since
markSubscriptionCanceledByCustomerId clears trialEndsAt and Stripe no
longer reports current_period_end on the subscription. The scheduled
cleanup therefore matched nobody at all while reporting success, and
media of owners fifteen days past their grace period stayed in Bunny and
R2 indefinitely.

Each branch now names NULL explicitly. Disabling Stripe also selects
nobody instead of falling through to NOT {}, which Prisma drops
entirely: that left a filter keyed on the grace period alone, so a
self-hosted deployment running the cleanup would delete workspaces of
users it never charged.

The unit tests could not catch this, because as an object the old filter
reads correctly and no SQL is produced. The new coverage lives in
tests/api and runs against Postgres.
2026-07-30 19:25:47 +07:00
yusufipk debfd73214 docs(terms): require cause for suspension and termination
Section 13 let us suspend or terminate an account "with or without cause and
with or without notice". It is common boilerplate, but for a product whose pitch
is that you can hold your own footage and leave whenever you want, it reads as a
standing right to close a paying customer for no reason.

It replaces that with four defined grounds: material breach of the Terms with a
ten day cure period, no cure period for a repeated breach of the same
obligation; unlawful use, infringement of a third party's rights, or a security
risk, where we may act immediately; fees unpaid fourteen days past due after
notice; and legal compulsion.

Defined grounds need their counterparts, so the clause also carries what we owe
in return: suspension while we investigate comes with a duty to say why and to
restore access if the suspicion does not hold, terminating for any other reason
or discontinuing the Service or a plan takes thirty days notice and a refund of
prepaid unused fees, and User Content stays available for export for thirty days
after termination.

Section 6 had the same flavour and is softened to match, from removing content
"at our sole discretion" to where we reasonably determine it violates the Terms.

That refund promise contradicted Section 5 and the Refund Policy, which both say
every fee is non-refundable including unused months. The general rule exists to
stop buyer's remorse refunds, not to let us keep a prepaid year we cut short, so
Section 13 now overrides them explicitly and the Refund Policy states the same
exception rather than leaving the two pages to disagree.
2026-07-30 17:41:43 +07:00
Yusuf İpek 425cc04f4f Merge pull request #47 from yusufipk/fix/bunny-orphan-url-reference
fix(scripts): count a Bunny id referenced by url, not only by column
2026-07-26 15:26:39 +03:00
yusufipk 4e60b61edb fix(scripts): count a Bunny id referenced by url, not only by column
A Bunny guid is stored twice per row: in `VideoVersion.videoId` and
`VideoAsset.providerVideoId` on its own, and inside `originalUrl` / `sourceUrl`
as `https://iframe.mediadelivery.net/embed/<library>/<guid>`. The lookup read
only the id columns.

They are written together so they normally agree, but this query decides what
gets deleted. A row whose id column was left empty or drifted while its url
still carried the guid would present a live video as an orphan, and the script
would delete media the product is still serving. Reading both makes a
disagreement harmless instead of destructive.

Bunny rows are now read in one pass rather than filtered per candidate id: the
url match is a substring test, and there are only as many of these rows as there
are Bunny videos in the product, so one small scan beats a LIKE per id.
2026-07-26 19:24:13 +07:00
Yusuf İpek c0e7211052 Merge pull request #46 from yusufipk/fix/orphan-cleanup-review
fix(scripts): make the orphan cleanups reviewable before they delete
2026-07-26 15:11:56 +03:00
yusufipk 1f7b77873c fix(scripts): make the orphan cleanups reviewable before they delete
Two problems with running these unattended, both found while wiring the Bunny
cleanup up to a Coolify scheduled task against production.

A dry run reported a count and nothing else. "Orphaned: 31" is not something
anyone can approve: it says how many objects would go, never which. Both scripts
now list every orphan they would delete, and print the same list when deleting,
so a real run is auditable afterwards too.

Each line carries who the object belongs to, as far as each provider can answer:

- R2 reads the owner out of `videoUploadSession`, which keeps `objectKey`
  alongside the initiating and billed user and survives an upload that never
  became a video. That is the case producing orphans, so this is an answer
  rather than a guess.
- Bunny has no equivalent. `bunny-init` sends the provider a title and nothing
  else, and an orphan by definition has no row pointing at it, so there is
  nothing authoritative to look up. The title is matched against titles still in
  the database instead, which catches the common shape (a version upload that
  failed and was retried successfully leaves a live row with the same title).
  A hit prints as "possibly", because it is a hint.

The grace periods were also too short to be safe:

- Bunny counted a video abandoned after 24 hours.
- R2 counted an object abandoned after 15 minutes, which is shorter than a slow
  multipart upload of a large file. An object still being written, or written
  but not yet finalised into a row, looked abandoned and could be deleted out
  from under the upload creating it.

Both are seven days now: long enough that no upload, retry or delayed
finalisation can still be in flight.
2026-07-26 19:08:03 +07:00
Yusuf İpek 4c2e88b1cc Merge pull request #45 from yusufipk/worktree-fix-test-findings
fix: close the findings the test suite surfaced
2026-07-26 15:03:40 +03:00
yusufipk b51e690062 fix: close the findings the test suite surfaced
The suite that landed in #43/#44 was written against existing behaviour, so a
number of tests pinned bugs rather than asserting correct behaviour. This fixes
the production code and moves each of those tests onto the fixed behaviour in
the same change.

Security:

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

Correctness:

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

Consistency and access:

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

Repository health:

- the runner image installs production dependencies only.
- a setup file for the unit project restores stubbed env centrally.
- native tsconfig path resolution replaces vite-tsconfig-paths.
- `uploadBytesWithProgress` exists once.
- admin stats bill Bunny storage to the workspace owner like every other
  quota, gate on the configured flag, wire up the single-flight guard and count
  the statuses that belonged to no bucket.
- `r2Client.destroy()` releases the presign client too.
- `prepare` tolerates a production install, where husky is absent.
2026-07-26 18:53:54 +07:00
Yusuf İpek 0ceba72d5b Merge pull request #44 from yusufipk/fix/test-suite-database-safety
fix(test): unbreak the deploy build and keep the suites off a real database
2026-07-26 12:07:35 +03:00
yusufipk 4e86969f74 test(component): restore localStorage under Node 24 and newer
Node ships an experimental Web Storage global now, which evaluates to
`undefined` unless the process was started with `--localstorage-file`. Vitest
leaves an already-present global alone when it copies jsdom's window onto
globalThis, so jsdom's own localStorage never lands and Node's empty one wins.
sessionStorage has no counterpart in Node and comes through untouched, which is
what makes the asymmetry visible.

Every test in guest-gate.test.tsx therefore failed on `localStorage.clear()` on
a developer machine, while CI stayed green on its pinned Node 22 and the
container stayed green with no node at all. That is also why the branch had to
be pushed with --no-verify once: the pre-push hook runs on the host.

The in-memory stand-in only installs when nothing else provides localStorage,
so where jsdom's implementation is in scope it is left alone.
2026-07-26 15:58:27 +07:00
yusufipk 6136817f75 fix(test): keep the suites off a developer's real database
bun loads a plain `.env` into process.env before anything runs, and
tests/helpers/env.ts read that as a deliberate export, so it beat `.env.test`
outright. `scripts/test.sh api` therefore pointed the api suites at whatever
deployment `.env` describes: `prisma db push --accept-data-loss` for the
schema, then a truncate of every table between tests. The e2e half was worse,
because playwright.config.ts built and started the app with that DATABASE_URL
and those R2 credentials, then wrote fixtures into it. CI never saw any of
this: a runner has no `.env`.

Three changes, in order of what each one catches:

- helpers/dev-env.ts drops the values bun copied out of a development env file,
  leaving `.env.test` to fill them. Only values that match the file
  character-for-character go, so a real export still wins and the per-suite
  databases of a parallel api run keep working.
- helpers/test-database.ts refuses a DATABASE_URL whose database name is not
  marked as a test one, at the single point every path into the setup passes
  through. This is the backstop, not the fix.
- playwright.config.ts blanks the variables a development env file defines and
  the config does not. Dropping them from process.env is not enough there:
  `next build` and `next start` run @next/env themselves and read the files
  again. That is also why a local e2e run could not build at all (a set
  DISABLE_RATE_LIMIT throws in lib/rate-limit.ts under NODE_ENV=production) and
  why auth.spec.ts failed on a machine with SMTP configured.

`scripts/test.sh` now creates `.env.test` from the committed example instead of
asking for a one-line copy, so the guard above is something nobody has to meet.
2026-07-26 15:49:19 +07:00
yusufipk e3fcbf30bf fix(build): move the test db bootstrap under tests/
`.dockerignore` excludes `tests`, so the production build context carries
scripts/test-db-bootstrap.ts without the tests/setup/db-global module it
imports. tsconfig includes `**/*.ts`, so the `prebuild` typecheck fails on the
missing module and every deploy since the test suites landed has died there.
CI never saw it because tests/ exists on a runner.

The script is test-only, so it belongs in the tree that is already ignored.
2026-07-26 15:48:55 +07:00
Yusuf İpek 4eff54b0a6 Merge pull request #43 from yusufipk/worktree-writing-tests
test: add unit, API, component and end-to-end test suites
2026-07-26 11:06:24 +03:00
yusufipk 4e26da62bd test(e2e): derive the storage route glob from R2_ENDPOINT
`failure-recovery.spec.ts` hardcoded `http://minio-test:9000/**`, which is the
compose hostname. CI publishes MinIO on localhost, so the pattern matched
nothing there: the PUT went through, the upload succeeded, and the test sat
waiting for an error message that was never going to appear. It passed locally
and failed on CI for a reason the diagnostic did not name.

The glob now comes from R2_ENDPOINT, and the test counts the PUTs it actually
refused and asserts the count is not zero. A pattern that matches nothing is now
a failure that says so, rather than a failure that blames the error message.

Recorded in AGENTS.md as the third way a test can be worthless, alongside a
note to run a new spec under CI conditions and not only locally.
2026-07-26 15:01:00 +07:00
yusufipk 0187db5dc7 test: close the coverage gaps the first round left
Second pass over the suite, driven by the inventory in the gaps document. Nine
agents wrote suites in parallel against private databases, then a tenth read all
of it adversarially and five of its findings were fixed.

  unit + component  2076 -> 2079 (+888 over the round)
  api                647 -> 1015
  e2e                 18 -> 29

What was closed:

- lib/route-access.ts, the page-level authorization layer, went from zero tests
  to 48. Every API route was guarded and none of the pages were.
- The five media proxy routes now have a real 2xx beside every 403. The blocker
  was the positive control, solved by stubbing r2Client.send() and leaving
  lib/r2-media-proxy.ts itself real.
- Every remaining server-side lib module: invitations, email verification, the
  upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle,
  notifications and admin stats.
- Six video-page hooks, and the chunking arithmetic extracted out of
  lib/client/r2-video-upload.ts as a pure module.
- Five end-to-end flows: workspace members, bulk operations, the admin area,
  player interaction and failure recovery.

Three things about the harness itself turned out to be wrong:

- Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every
  route reaching finalizeR2VideoUpload silently took the "not a valid video"
  branch and no test noticed.
- The auth matrix asserted only "not 2xx", which two entries satisfied without
  their guard existing. It now requires 401 or 403, which makes both
  load-bearing, and all 60 routes pass the stricter form.
- Both admin API routes had no positive control anywhere: replacing their guard
  with an unconditional refusal left the entire suite green. Found by the
  adversarial review, now covered.

Process:

- bun run test:mutation runs StrykerJS over the authorization and validation
  modules. Diagnostic, not a gate, weekly in CI rather than on a push.
- playwright.config.ts gains an opt-in webkit project for the player spec.
- AGENTS.md now requires a batch of new tests to be reviewed by somebody who
  did not write them.

Only two production files change, both deliberate: lib/auth.ts loses a verbatim
copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the
extracted arithmetic. No behaviour change in either.
2026-07-26 13:25:11 +07:00
yusufipk fe42c0836f fix(test): stop anchoring the post-register URL assertion
The login page derives callbackUrl from its own default when the
parameter is absent, and on CI it arrives as
/login?registered=true&callbackUrl=%2Fdashboard. Anchoring the pattern
with $ made that a deterministic CI-only failure while the suite passed
locally on every run, including with CI=1.

What the register flow promises is the login page plus registered=true.
The rest of the query string is not part of that contract, so the
pattern now tolerates extra parameters in any order.

The page snapshot that diagnosed this also turned up a product bug,
recorded in the findings notes rather than fixed here: the success
banner tells every new user to check their email for a verification
link, including in the self-hosted default where SMTP is unset, email
verification is off, and the account is already usable.
2026-07-26 11:41:05 +07:00
yusufipk 72159377fb fix(ci): make the suites run under node, and start MinIO as a step
Both failures were environment-specific and invisible locally.

The vitest projects only ever ran under bun here, because the containers
used for local runs have no node at all. On a GitHub runner the vitest
bin's `#!/usr/bin/env node` shebang wins, and node's ESM resolver cannot
resolve the extensionless 'next/server' that next-auth/lib/env.js
imports, so all 17 api suites died with ERR_MODULE_NOT_FOUND. The
next-auth inline rule that the unit project already carried is now
declared at the root so every project inherits it. All three projects
verified under node as well as bun.

The e2e job could never start: a GitHub Actions `services:` block cannot
pass a command to its container, and the MinIO entrypoint requires
`server /data`, so the container printed its usage text and exited.
MinIO now starts as a step with `docker run`, which means the job can no
longer run inside a container, which in turn removes the reason the
Playwright image was needed at all. The browser is installed on the
runner instead, so the image tag no longer has to be kept in lockstep
with the npm package.
2026-07-26 11:28:56 +07:00
yusufipk 1d099c68f2 test: add unit, API, component and end-to-end test suites
The repo had no automated tests. Every change was verified by hand.

Adds four layers, 2023 tests in total, runnable with one command:

- 1191 unit tests over the pure logic in lib/, including the full
  computeProjectAccess permission matrix and the billing gate
- 167 component and hook tests in jsdom, covering the hooks that hold
  real logic rather than presentational wrappers
- 647 API integration tests against a real Postgres, with only auth()
  mocked, including a data-driven sweep asserting that none of the 60
  route modules answers 2xx to an unauthenticated caller
- 18 Playwright specs driving a real browser against a real build

Infrastructure: vitest.config.ts with three projects, a disposable
Postgres and MinIO in docker-compose.test.yml, factories and helpers
under tests/, scripts/test.sh as the single entry point, a pre-push
hook running bun run verify, and CI split into check, test and e2e jobs.

The test database is built with prisma db push plus a replay of the
hand-written SQL, because prisma migrate deploy cannot build this schema
from empty: the migration history has no captured baseline. This mirrors
what scripts/docker-db-bootstrap.ts already does in production, and
tests/setup/db-global.ts carries a drift guard so a new migration fails
the run until someone reviews it.

Production code is unchanged apart from one pure-function extraction out
of use-video-player.ts, which was too large to test in jsdom.

Several tests pin behaviour that looks wrong, each marked KNOWN BUG in
place. TESTING.md section 12 records where the plan turned out to be
wrong, and AGENTS.md now states which layer a change needs a test in.
2026-07-26 11:17:26 +07:00
Yusuf İpek ab5ae5ad74 Merge pull request #42 from yusufipk/feat/invitation-signup-flow
feat(invitations): guide invited users without an account through sign-up
2026-07-25 15:43:44 +03:00
yusufipk b1aed03fca fix(invitations): throttle unauthenticated invitation lookups and harden redirects
The invitation preview surfaces (/invitations/accept and /register?invitationToken=) are the
only unauthenticated reads of invitation data, and each render costs two database queries.
They are now rate limited before the lookup can touch the database: a generous per-IP bucket
that bounds enumeration across tokens, plus a tight per-IP+token bucket that stops repeated
probing of a single invitation. Tokens are hashed before they reach the rate_limits table.

A throttled lookup says so ("we couldn't check this invitation right now") instead of claiming
the invitation is invalid, and signed-in acceptance is not gated by it.

The callback sanitizer also checked only the origin, which is not enough: an attacker can
smuggle a host into the path of an otherwise same-origin URL — new URL('https://app/​/evil.com')
keeps our origin but yields a pathname of //evil.com, which navigation sinks resolve as
protocol-relative and follow off-site. Paths are now required to be rooted at a single slash,
and the login redirect re-checks at the sink.

getClientIp is split so server components that only have `await headers()` resolve the client
IP through the same trusted-proxy logic as route handlers.
2026-07-25 19:39:16 +07:00
yusufipk 9c75ce91e1 feat(invitations): guide invited users without an account through sign-up
Clicking an invitation link while signed out dropped the visitor on a bare login form,
even though most invitees have no account yet and nothing on screen told them to create one.

Signed-out visitors now get the invitation itself: who invited them, which workspace/project,
which role, and which address it was sent to. The primary call to action follows whether an
account already exists for that address — "Create your account" when it does not, "Sign in to
accept" when it does.

The sign-up path carries the invitation forward, so a new account lands back on the invitation
and from there on the shared workspace/project instead of the onboarding wizard:
- the register link passes invitationToken, the invited email and a callbackUrl
- the register form locks the email to the invited address and shows what is being joined
- the verification email round-trips the destination through a sanitized `next` parameter
- login and verify-email keep the pending destination in their sign-in links

Signing in with a different address than the one invited now explains the mismatch instead of
silently redirecting to the dashboard.

Callback sanitization moves to lib/safe-redirect.ts so login, register, verify-email and the
verification route share one open-redirect guard.
2026-07-25 18:44:02 +07:00
Yusuf İpek 52b2c8d2a9 Merge pull request #41 from yusufipk/dependabot/npm_and_yarn/npm_and_yarn-1c4f37dfd6
chore(deps): bump next from 16.2.6 to 16.2.11 in the npm_and_yarn group across 1 directory
2026-07-25 13:31:59 +03:00
yusufipk f9ac7fb089 chore(deps): sync bun.lock for next 16.2.11
Dependabot only bumped package.json; regenerate the lockfile so the manifest and bun.lock agree.
2026-07-25 17:26:57 +07:00
dependabot[bot] 5ad01d620d chore(deps): bump next in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 16.2.6 to 16.2.11
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.6...v16.2.11)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.11
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-25 10:20:37 +00:00
Yusuf İpek bad64d6d48 Merge pull request #40 from yusufipk/worktree-dependency
fix(deps): bump sharp to 0.35.3 for the libvips CVE fixes
2026-07-25 13:19:39 +03:00
Yusuf İpek 322395551a Merge pull request #38 from yusufipk/fix/public-project-hides-workspace-admin-actions
fix(auth): keep workspace admin permissions on public projects
2026-07-25 13:18:38 +03:00
yusufipk bac6af0ded fix(deps): bump sharp to 0.35.3 for the libvips CVE fixes
sharp < 0.35.0 ships libvips 1.2.4, which carries CVE-2026-33327,
CVE-2026-33328, CVE-2026-35590 and CVE-2026-35591 (Dependabot #21).
0.35.3 bundles libvips 1.3.2 (8.18.3).

next 16.2.6 still declares sharp ^0.34.5 as an optional dependency, so a
plain bump left a nested vulnerable copy under node_modules/next that the
image optimizer would resolve first. The overrides entry pins a single
sharp across the tree; it can go once next ships >= 16.3 with sharp ^0.35.
2026-07-25 17:15:30 +07:00
Yusuf İpek 58910a5f5a Merge pull request #39 from yusufipk/worktree-frame-counter
feat(player): add frame counter when scrubbing and seeking
2026-07-25 13:10:11 +03:00
yusufipk b23f3de666 feat(player): add frame counter when scrubbing and seeking
Show a timecode + frame readout above the timeline while dragging the
playhead, and flash it for a moment on keyboard/button seeks so frame
stepping is visible too.

Position and text are written from the existing rAF/DOM path that drives
the playhead, so the readout stays smooth without extra React renders.

Two supporting fixes the count depends on:

- Seed the frame rate from the HLS manifest FRAME-RATE attribute so a
  frame number is available before playback ever starts; previously the
  rate was only ever measured from requestVideoFrameCallback and stayed
  null until the video had played.
- Snap the measured rate to the nearest broadcast standard and skip
  samples taken mid-seek. A drifting float slid the count by whole
  frames late in a long video, and re-publishing a slightly different
  float on every presented frame forced a re-render per video frame.
2026-07-25 17:07:34 +07:00
Yusuf İpek 81285681dc Merge pull request #37 from yusufipk/worktree-fix-download-notice
feat(downloads): let the download progress toast be minimized
2026-07-25 12:59:18 +03:00
yusufipk 60b2bc7369 fix(auth): keep workspace admin permissions on public projects
checkProjectAccess skipped the workspace membership lookup whenever access
was already granted another way — a PUBLIC project, or an existing project
membership — and only forced it for intents other than 'view'. The workspace
role does not just gate entry though; it feeds canEdit/isWorkspaceMember.

So a workspace ADMIN who is not the project owner lost canEdit the moment a
project was switched to public: the Add Version item on video cards, plus
canManageTags/canResolveComments/canRequestApproval/canShareVideo on the
video page, all disappeared, and the approvals endpoint returned 403. The
underlying POST routes use intent 'manage' and would still have accepted the
write, so the permission was there — only the UI was gone.

Resolve the workspace role for every signed-in non-owner. Owners already pass
every check on their own, so theirs is still only loaded when they mutate.
2026-07-25 16:58:17 +07:00
yusufipk 481728b93d feat(downloads): let the download progress toast be minimized
The download progress toast sits in the bottom-right corner on top of the
comment composer, blocking the voice-recording button and the comment box for
the whole duration of a download.

Render it through toast.custom so it can be collapsed to a small pill (percent
+ spinner) and expanded again while the download keeps running. The minimized
choice sticks for the rest of the session. The sonner <li> is click-through, so
only the panel itself covers the controls underneath.

Also dismiss the panel on failure — it had duration: Infinity and used to stay
on screen forever after an error.
2026-07-25 16:56:29 +07:00
Yusuf İpek aeee1fc68b Merge pull request #31 from eehkay/fix/compare-r2-playback
fix: play r2 direct uploads in the compare versions view
2026-07-25 12:54:20 +03:00
yusufipk 2bad0a249f refactor(video): share R2 playback URL resolution and guard drift resync
- move resolveR2PlaybackUrl into lib/video-upload-validation.ts so the compare
  view and the main video page cannot drift apart
- validate the resolved URL with isPlayableVideoUrl before it reaches <video src>
- add a per-player cooldown so a follower that cannot keep up is not seeked
  every second, which would stutter rather than correct
2026-07-25 16:48:39 +07:00
Yusuf İpek 63e467f437 Merge pull request #26 from eehkay/fix/json-ld-scripts
fix: emit one JSON-LD script per schema object
2026-07-25 11:28:40 +03:00
Yusuf İpek b50ef39329 Merge pull request #36 from yusufipk/worktree-admin-panel-filtering
feat(admin): add search and status filters to the users table
2026-07-25 11:16:58 +03:00
Yusuf İpek a14eb9fb84 Merge pull request #34 from yusufipk/fix/bigint-safe-success-response
fix(api): serialize BigInt in all API success responses
2026-07-25 11:15:27 +03:00
yusufipk f64c04b271 feat(admin): add search and status filters to the users table
The users table could only be sorted, so finding a single account or
reviewing everyone in a given billing state meant paging through the
whole list.

Add three filters that compose with each other and with sorting:
- q: case-insensitive name/email search, submitted as a plain GET form
- status: one button per BillingSubscriptionStatus (active, canceled, ...)
- access: real in-app access, including collaborators on a paying
  owner's workspace or project

Resolving that collaborator access per user meant two queries per row.
Replace it with getCollaboratorAccessUserIds, which resolves every user
in two queries total and now backs both the column and the new filter.
2026-07-25 15:15:01 +07:00
Yusuf İpek b912c1767b Merge pull request #35 from yusufipk/fix/verify-email-redirect-origin
fix(auth): build verify-email redirects from the configured public or…
2026-07-25 10:59:52 +03:00
yusufipk 5871d4d87d fix(auth): build verify-email redirects from the configured public origin
Redirects were built relative to `request.url`, which behind a reverse proxy
resolves to the container-internal address. Verification succeeded but the
browser was sent to localhost:3000, so users saw a connection error instead of
the "email verified" confirmation.

Add getPublicOrigin() (NEXTAUTH_URL, then NEXT_PUBLIC_APP_URL, falling back to
the request origin for local development) and use it for every verify-email
redirect. The legacy GET redirect in the watch session route had the same
defect and is fixed alongside it.
2026-07-25 14:57:57 +07:00
yusufipk 0faa4b4e2a fix(billing): prevent duplicate subscriptions and make webhook sync authoritative
A Stripe customer can own several subscriptions. Two defects let that happen
and corrupt the user's billing state:

1. Checkout allowed a fresh subscription whenever the user was not ACTIVE/
   TRIALING, so a PAST_DUE user started a brand-new subscription (Stripe
   Checkout always creates one) instead of recovering the existing one.
   Add hasRecoverableSubscription() (ACTIVE/TRIALING/PAST_DUE/UNPAID/
   INCOMPLETE); block checkout and route these users to the billing portal
   ('Update Payment Method') both in the API guard and the settings UI.

2. Subscription webhooks trusted the event's single subscription, so an old
   subscription's deletion could clobber a newer active one (marking the user
   CANCELED / No access). Every subscription event now re-derives state from
   the full set of the customer's Stripe subscriptions via
   syncStripeCustomerSubscriptions() + selectAuthoritativeSubscription(),
   making the sync order-independent and self-healing.
2026-07-25 14:22:32 +07:00
yusufipk b5fd73dcf2 feat(admin): show subscription status and real access in user list
Add a Subscription column to the admin user listing showing each user's
billing status (Active, Trialing, Past due, Canceled, etc.) as a badge,
plus an effective-access indicator. Access reflects real in-app access,
not just the user's own subscription: collaborators on a paying owner's
workspace/project are shown as having access 'via team' (mirrors
hasAppNavigationAccess in lib/route-access.ts). Canceled-but-not-yet-
expired and trialing users are surfaced with their access-until date.
Column is DB-sortable and gated behind isStripeBillingEnabled().
2026-07-25 13:24:55 +07:00
Yusuf İpek ae78e97fde Merge pull request #25 from eehkay/chore/commitlint-esm-config
chore: load commitlint rules by renaming config to .mjs
2026-07-22 19:52:27 +03:00
yusufipk fa1610b053 fix(api): serialize BigInt in all API success responses
successResponse() used NextResponse.json(), which calls JSON.stringify and
throws on BigInt. Prisma returns BigInt for VideoVersion.sizeBytes and
VideoAsset.sizeBytes, so any route returning one of those rows returned 500
after its database write had already committed.

#27 fixed two such endpoints by narrowing their selects, and two create
routes were already wrapped in toJsonSafe(). This closes the bug class at
the helper instead: successResponse() now serializes with a shared
bigIntReplacer, which covers every route in app/api (none construct a
NextResponse.json response directly).

The two toJsonSafe() call sites are now redundant and were removed. BigInt
values render as strings, matching what toJsonSafe already produced.
2026-07-22 23:51:18 +07:00
Yusuf İpek 29d2896cb9 Merge pull request #32 from eehkay/fix/audio-asset-proxy-and-gc-thumbnails
fix: audio asset playback and orphan-cleanup thumbnail deletion
2026-07-22 19:50:28 +03:00
Yusuf İpek 22bb6a68fb Merge pull request #27 from eehkay/fix/bigint-serialization
fix: BigInt serialization 500s in video PATCH and approval decision responses
2026-07-22 19:46:08 +03:00
eehkayandClaude Fable 5 16efba4e19 fix: audio asset playback and orphan-cleanup thumbnail deletion
The audio proxy resolved ownership only through voice comments, so
R2_AUDIO video assets (which store the same proxy path in
videoAsset.sourceUrl) always got 403s. Resolve ownership the way the
image proxy does: query comments and video assets, merge into a
unique-owning-video map, deny on ambiguity.

r2-orphan-cleanup marked videoAsset.sourceUrl as referenced but not
videoAsset.thumbnailUrl, so every R2_VIDEO asset thumbnail older than
the TTL was deleted as an orphan. Widen the query to both columns.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 13:17:13 -07:00
eehkayandClaude Fable 5 d830c9a386 fix: play r2 direct uploads in the compare versions view
The compare page predates the r2 upload provider: r2 versions fell
through to a URL-safety check that throws on app-relative upload URLs,
so their panels rendered nothing and registered no player — the shared
controls drove an empty list and nothing played.

- add an R2Panel mapping a plain video element over the app upload
  route to the shared adapter, using the same playback-url resolution
  as the main video page
- make play/pause state detection work without the YouTube API loaded
  (numeric fallback), so bunny/r2-only comparisons can pause
- re-sync panels that drift more than 350ms from the source player
  once per second so playback stays aligned, not just starts aligned

Verified against the running app: both versions play in lockstep
(0.000s measured drift), pause together, and seek together.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 10:18:22 -07:00
eehkayandClaude Fable 5 00589f3453 fix: bigint serialization 500s in video and approval responses
Two endpoints return 500 whenever they succeed, because their success
payloads include VideoVersion rows whose sizeBytes column is a BigInt
that JSON.stringify rejects:

- PATCH /api/projects/[projectId]/videos/[videoId] included all versions;
  respond with scalar video fields only, which is all any caller reads
- POST /api/approvals/[requestId]/decision included the full version row
  in the resolved request; select the scalar fields the response and
  notifications actually use

The approval bug is reachable the first time any approver responds to a
request; the decision itself commits, but the requester sees an error.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 09:17:57 -07:00
eehkayandClaude Fable 5 9595698b5b fix: emit one json-ld script per schema object
A single ld+json script holding a top-level array crashes naive
structured-data consumers (Safari extension content scripts) that read
parsed['@context'] without checking for arrays. Emit one script per
object so every payload has a top-level @context, and escape < in the
root layout like the marketing pages already did.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 09:15:57 -07:00
eehkayandClaude Fable 5 d405fc09da chore: rename commitlint config to mjs so esm rules actually load
commitlint.config.js uses export default but the package is CommonJS,
so the loader silently fell back to an empty ruleset and rejected every
commit with empty-rules. The .mjs extension loads under node and bun.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 09:15:04 -07:00
yusufipk 33008d33ad style: fix prettier formatting 2026-07-12 18:38:17 +07:00
yusufipk 6c6df3cf1d feat: hosted-first landing + accurate fair source licensing copy
- Replace unsubstantiated hero claim with client sign-off messaging
- Rename Open Source (Self-hosted) to Fair Source (Self-hosted) with FSL
  explanation (source visible, self-hostable, Apache 2.0 after two years)
- Put Hosted Cloud first in pricing with Recommended badge and trial note
- Move self-hosting hero link to GitHub, out of primary CTA path
- Add FAQ entries for FSL licensing and 7-day free trial
- Align license language across landing, README, terms, SEO, comparisons
- Fix contact email to [email protected] everywhere (mailto links,
  notification sender fallbacks) and SEO fallback domain
2026-07-12 18:32:38 +07:00
yusufipk 5821f73d38 feat: show live progress while downloading named files
Bunny/cross-origin downloads are fetched into a blob before saving, which
on large files or slow connections looked stuck (spinner only). Stream the
body through a counting transform and show real byte progress in a toast:
per-file percent for single downloads and file N/M + percent for bulk.

- Progress is measured from Content-Length + received bytes (not estimated).
- The blob is assembled by the browser from the stream (can be disk-backed),
  so we don't accumulate chunks in the JS heap.
- Only the blob path shows a toast; same-origin (R2/S3/MinIO) and the >10 GB
  fallback use the browser's native download UI.
2026-07-10 22:27:53 +07:00
yusufipk 8845c2c643 feat: name video downloads by title + version
Downloads now save as "<video title> <version label>" (or "<title> vN"
when no label), with the real extension derived from the file's content
type, instead of the CDN's generic "original" name.

- Bunny (cross-origin CDN redirect) files are fetched and saved as a named
  blob, but only up to 10 GB; larger files fall back to a plain navigation
  so the browser streams to disk without buffering in memory.
- R2 / S3 / MinIO uploads are same-origin (/api/upload/video/...), so the
  download attribute names them correctly at any size, no buffering.
- Applies to both single-video and bulk/project downloads; bulk downloads
  run sequentially so at most one file is buffered at a time.
- Shared helper in lib/client/download-file.ts.
2026-07-10 21:58:09 +07:00
yusufipk bede216081 perf: smooth playhead + live scrubbing preview
Drive the timeline progress fill and playhead directly via a
requestAnimationFrame loop (bypassing React state) so the playhead glides
at the display refresh rate during playback instead of stepping ~4x/sec.

Scrubbing now previews frames live like an editor: while dragging, the
video is seeked with coalescing (one seek in flight, chasing the latest
target) so HLS stays responsive without stale-seek pileup. Playback pauses
during a scrub and resumes on release. Dragging tracks the cursor anywhere
on the page via window listeners.
2026-07-10 21:31:36 +07:00
yusufipk 8d7d064647 feat: make asset downloads opt-in via "Include assets" toggle
Project/selected downloads now include only videos by default. Add an
"Include assets" checkbox toggle to both download dropdowns (default off)
that adds b-rolls and other attached assets to the download when enabled.

- buildProjectDownloadManifest gains an includeAssets option (default false).
- Download route reads ?assets=1 and passes it through.
2026-07-10 21:03:34 +07:00
yusufipk 57c5a127d1 feat: move videos to another project (single + bulk)
Add a "Move to project" action in the video card dropdown and the
selection-mode toolbar. Videos (with their versions, comments, assets and
video-scoped share links) can be moved into another project in the same
workspace.

- New GET/POST /api/projects/[projectId]/videos/move: GET lists manageable
  destination projects in the workspace; POST performs the move.
- Requires canEdit on both source and destination; same-workspace only.
- Move runs in an interactive transaction that re-asserts source ownership
  atomically (updateMany guarded by projectId) to avoid a TOCTOU race, and
  returns 409 on conflict. GET is rate-limited ('api').
2026-07-10 20:55:12 +07:00
yusufipk 654d3a6bc7 style: format download dropdown item (prettier) 2026-07-10 20:08:15 +07:00
yusufipk 34e72f6cbb fix: bulk video download (original quality, latest version by default)
- Accept source=auto in the version download route (was 400 Bad Request),
  so bulk/project downloads of Bunny videos no longer fail.
- Bulk/project downloads now request the original (uncompressed) Bunny file
  so quality never drops (was source=auto which could fall back to compressed).
- Project/selected downloads default to the latest version of each video and
  add a separate "All versions" option in the download dropdowns.
2026-07-10 20:04:18 +07:00
Yusuf İpek cbaecb92cc Merge pull request #24 from yusufipk/fix/comment-newline-rendering
fix: preserve newlines/paragraph breaks in rendered comments
2026-07-10 15:41:29 +03:00
yusufipk d500dcb042 fix: preserve newlines/paragraph breaks in rendered comments
Comment content was stored with newlines intact but rendered inside <p>
elements with default white-space, collapsing line breaks into single
spaces. Add whitespace-pre-wrap (and break-words) to the comment/reply
render wrappers in the comments pane and the compare-versions view.
2026-07-10 19:33:03 +07:00
yusufipk 880d0ac0fa feat: chunked (S3 multipart) uploads for R2/S3 video backend
Self-hosted instances on the R2/S3 backend could only upload a video as a
single PUT, which fails behind a Cloudflare proxy/tunnel (100MB request-body
cap) and is capped at 5GiB with no resilience. Bunny already avoids this via
tus; this brings the R2/S3 path to parity.

Files larger than a threshold (default 90MiB) are now split into parts
(default 32MiB, min 5MiB) and uploaded directly browser->R2 via presigned
UploadPart URLs, then reassembled server-side with CompleteMultipartUpload.
Each request stays under the 100MB cap, lifts the size ceiling well past
5GiB, and adds per-chunk retry. Files at/under the threshold keep the
existing single-PUT path unchanged. Bunny path is untouched.

Thresholds are env-overridable via OPENFRAME_R2_MULTIPART_THRESHOLD_BYTES
and OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES.

Verified end-to-end against real Cloudflare R2 and a local MinIO behind an
nginx 90MB cap (single 141MB PUT 413s on master; 32MB parts pass here).

Closes #22
2026-07-10 19:19:07 +07:00
yusufipk 82932c6b22 fix: scope select-all to current page
"Select all" previously selected every video across every page, which is
too easy to trigger by accident when the user only meant the videos
visible on the current page. Scope select/deselect to the current page's
videos and relabel the button to "Select page"/"Deselect page" when the
project spans multiple pages.
2026-06-27 13:46:05 +02:00
yusufipk cebdf23b38 fix(security): bump nodemailer to 9.0.1
Resolves the high-severity advisory (dependabot #20) where the
message-level raw option bypassed disableFileAccess/disableUrlAccess,
enabling arbitrary file read and SSRF. We only use the standard
createTransport/sendMail API, so the major bump is non-breaking.
2026-06-27 13:38:07 +02:00
yusufipk 95dcf92d8b fix: clamp page after bulk video delete
When every video on the current page was bulk-deleted, router.refresh()
re-queried the same out-of-range page and rendered "No videos yet" even
though earlier pages still had videos. Clamp to the last valid page based
on the remaining video count, falling back to refresh in place.
2026-06-27 13:35:08 +02:00
yusufipk 52e4169db2 feat: add project bulk download and bulk video delete
Add a "Download project" / "Download selected" flow that builds a
server-side manifest of downloadable media, plus a selection mode with
bulk delete for project videos.

Gate viewer downloads behind a new project allowDownloads setting
(default off, opt-in). Admins can always download; enabling on a public
project allows anonymous visitors to download. Enforce the setting on
every download surface (manifest, version, asset, watch, video routes)
via canDownloadProjectMedia.

Add rate limits for the manifest endpoint, host allowlisting for direct
download URLs, and configurable file/byte caps.

Closes #16
Closes #19
2026-06-27 13:24:05 +02:00
yusufipk 9613c4f2c6 fix: harden email validation and CI permissions 2026-06-14 16:59:09 +02:00
yusufipk 56fb7403cf fix: resolve CI lint and formatting failures.
Replace internal anchor tags with Next.js Link components and format the marketing comparison route page.
2026-06-14 16:42:27 +02:00
yusufipk 51257e004f Add SEO comparison landing pages and footer compare links.
Introduces dynamic marketing comparison routes, competitor data, and Compare sections on the homepage and marketing footer.
2026-06-14 16:36:26 +02:00
yusufipk d301f3d808 docs(security): prefer GitHub private vulnerability reporting 2026-06-13 23:28:22 +02:00
Yusuf İpek 0a89e52a98 Merge pull request #21 from yusufipk/cursor/be172284
feat: bulk video uploads and S3 asset video support (fixes #18)
2026-06-14 00:27:09 +03:00
yusufipk 00124bc7c2 feat: bulk video uploads and S3 asset video support (#18)
Add multi-file drag-and-drop queues for project videos and the assets pane, and route asset video uploads through S3/R2 when direct Bunny uploads are disabled.
2026-06-13 23:24:21 +02:00
yusufipk 52ace1a1a8 fix: generate CSP from runtime storage env for self-hosted MinIO
Move Content-Security-Policy generation to proxy.ts so R2_PRESIGN_ENDPOINT
is included at request time instead of being frozen at image build time.
Document reverse-proxy layouts for Docker self-hosting and copy proxy.ts
into the Docker image.

Closes #17
2026-06-12 21:21:31 +02:00
yusufipk 4bf6e821af feat: enable S3 video uploads and update related configurations
- Added support for self-hosted S3 video uploads with new environment variables: OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_MAX_VIDEO_UPLOAD_BYTES.
- Updated .env.example and .env.docker.example to reflect new configuration options.
- Enhanced Content Security Policy to include origins for S3-compatible storage.
- Updated dependencies for AWS SDK to support new features.
- Refactored upload logic to accommodate both Bunny and S3 upload providers.
- Updated documentation to clarify the usage of direct uploads and S3 configurations.
- Closes #11
2026-05-27 17:04:39 +02:00
Yusuf İpek b6de3a29aa Merge pull request #15 from yusufipk/dependabot/npm_and_yarn/npm_and_yarn-152f59e559
chore(deps): bump next from 16.2.3 to 16.2.6 in the npm_and_yarn group across 1 directory
2026-05-23 21:46:32 +03:00
yusufipk 6692db992d feat: add YouTube to connect-src in Content Security Policy 2026-05-23 20:44:52 +02:00
dependabot[bot] 1dcbb78b22 chore(deps): bump next in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 16.2.3 to 16.2.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.3...v16.2.6)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.6
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-05-12 23:06:01 +00:00
yusufipk 378ca1977b feat: enhance comment functionality with timestamp range support
- Added timestampEnd to Comment and CommentReply interfaces.
- Implemented logic for handling comment timestamp ranges in the comment composer and comments pane.
- Updated video player and player core to support frame stepping and improved seeking functionality.
- Introduced frame mode toggle for precise navigation during video playback.
- Closes #12
2026-04-25 22:58:16 +03:00
yusufipek 63f331d220 docs: add instructions for using the published Docker image Closes #10 2026-04-25 21:29:31 +03:00
417 changed files with 77510 additions and 3588 deletions
+5
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
# ============================================================================
+81
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
bun run verify
+5
View File
@@ -2,3 +2,8 @@
node_modules/
prisma/migrations/
bun.lock
coverage/
playwright-report/
test-results/
reports/
.stryker-tmp/
+98
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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
+92 -8
View File
@@ -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
@@ -14,14 +14,14 @@ OpenFrame is built for video teams that want one system for review, revision, ap
- Timestamped comments directly on the video timeline
- Voice notes, image attachments, and frame annotations
- Version history with side-by-side compare
- Version history with side-by-side compare and per-version subtitle tracks
- Approval requests and sign-off tracking
- Share links for client review with optional guest commenting
- Workspaces, projects, member roles, and invitation flows
- Comment tags, resolved states, and CSV/PDF exports
- Video-linked assets for supporting media and references
- Email and Telegram notifications
- URL-based YouTube video intake plus optional Bunny direct uploads
- URL-based YouTube video intake plus optional direct uploads (Bunny Stream or self-hosted S3)
## Core Workflow
@@ -41,7 +41,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
### Versioning And Comparison
- Videos support multiple versions inside the same review thread.
- Videos support multiple versions inside the same review thread, each with its own subtitle tracks uploaded as SRT or WebVTT.
- Teams can switch between versions without losing review context.
- Compare mode lets reviewers inspect two versions side by side.
@@ -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
View File
@@ -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
View File
@@ -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.
+35 -14
View File
@@ -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&apos;t have an account?{' '}
<Link href="/register" className="text-primary hover:underline">
<Link href={registerHref} className="text-primary hover:underline">
Sign up
</Link>
</p>
+37 -2
View File
@@ -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}
/>
);
}
+83 -12
View File
@@ -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&apos;ll be taken straight to it.
</p>
</div>
) : isInvitationFlow && invitationLookupThrottled ? (
<div className="p-3 rounded-md bg-amber-500/10 text-sm">
We couldn&apos;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>
+7 -1
View File
@@ -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}
+3 -2
View File
@@ -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'}
/>
);
}
+8 -4
View File
@@ -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>
);
+24 -1
View File
@@ -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,48 @@ 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 { beginUnloadGuard } from '@/lib/client/unload-guard';
import {
createDownloadProgressToast,
type DownloadProgressToastHandle,
} from '@/components/download-progress-toast';
interface SerializedVideo {
id: string;
@@ -38,36 +74,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 +147,184 @@ 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;
let releaseUnloadGuard: (() => void) | 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…',
});
// The files are pulled one by one through this tab, so closing it drops
// everything that hasn't been saved yet. Warn before that happens.
releaseUnloadGuard = beginUnloadGuard();
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 {
releaseUnloadGuard?.();
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 +358,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 +379,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 +456,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 +566,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 +624,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">
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle';
import Hls from 'hls.js';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
@@ -29,6 +30,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 +93,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);
@@ -122,8 +129,7 @@ export default function CompareVersionsPageClient({
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [cursorIdle, setCursorIdle] = useState(false);
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying);
const timelineRef = useRef<HTMLDivElement>(null);
// Map of versionId -> YT.Player or Custom Adapter
@@ -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) => {
@@ -375,57 +409,6 @@ export default function CompareVersionsPageClient({
handleSeek(currentTimeRef.current);
}, [isDragging, handleSeek]);
const handleVideoMouseMove = useCallback(() => {
setCursorIdle(false);
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
}
if (isPlaying) {
cursorIdleTimerRef.current = setTimeout(() => {
setCursorIdle(true);
}, 1000);
}
}, [isPlaying]);
const handleVideoMouseLeave = useCallback(() => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
}
setCursorIdle(false);
}, []);
useEffect(() => {
return () => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
}
};
}, []);
useEffect(() => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
cursorIdleTimerRef.current = null;
}
if (!isPlaying) {
setCursorIdle(false);
return;
}
cursorIdleTimerRef.current = setTimeout(() => {
setCursorIdle(true);
}, 1000);
return () => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
cursorIdleTimerRef.current = null;
}
};
}, [isPlaying]);
// Keyboard shortcuts (matching video page)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -728,6 +711,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 +812,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 +967,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'}
/>
);
}
+242 -28
View File
@@ -30,6 +30,27 @@ import {
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
import type { CancellationReason } from '@/lib/cancellation-reasons';
/** Convert Stripe API units separately from the currency's display precision. */
function formatInvoiceAmount(amountInMinorUnits: number, currency: string) {
const currencyCode = currency.toUpperCase();
try {
const formatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
});
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
// Stripe retains two-decimal API amounts for ISK/UGX despite their zero-decimal display.
// https://docs.stripe.com/currencies#special-cases
const apiExponent = currencyCode === 'ISK' || currencyCode === 'UGX' ? 2 : fractionDigits;
return formatter.format(amountInMinorUnits / 10 ** apiExponent);
} catch {
return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`;
}
}
interface NotificationSettings {
telegramChatId: string | null;
@@ -49,13 +70,25 @@ interface BillingOverview {
status: 'disabled' | 'ready' | 'misconfigured';
checkoutAvailable: boolean;
portalAvailable: boolean;
cancelAvailable: boolean;
cancelIsImmediate: boolean;
needsPaymentFix: boolean;
openInvoice: {
id: string | null;
hostedInvoiceUrl: string | null;
amountDue: number;
currency: string;
attemptCount: number;
nextPaymentAttempt: string | null;
} | null;
subscription: {
status: string;
label: string;
hasActiveSubscription: boolean;
hasRecoverableSubscription: boolean;
hasActiveTrial: boolean;
hasBillingAccess: boolean;
isTrialEligible: boolean;
isPaid: boolean;
priceId: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
@@ -66,6 +99,7 @@ interface BillingOverview {
};
workspaceCreation: {
canCreateWorkspace: boolean;
canStartTrial?: boolean;
reason: string | null;
ownedWorkspaceCount: number;
invitedWorkspaceCount: number;
@@ -76,6 +110,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 {
@@ -144,7 +180,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
const [testing, setTesting] = useState<string | null>(null);
const [billing, setBilling] = useState<BillingOverview | null>(null);
const [billingLoading, setBillingLoading] = useState(true);
const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | null>(null);
const [billingAction, setBillingAction] = useState<
'checkout' | 'portal' | 'trial' | 'cancel' | null
>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null);
const [storageLoading, setStorageLoading] = useState(true);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -251,12 +290,16 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
);
const handleBillingRedirect = useCallback(
async (endpoint: '/api/billing/checkout' | '/api/billing/portal') => {
async (
endpoint: '/api/billing/checkout' | '/api/billing/portal',
flow?: 'payment_method_update'
) => {
setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal');
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flow ? { flow } : {}),
});
const data = await res.json();
@@ -275,6 +318,72 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
[showMessage]
);
const handleStartTrial = useCallback(async () => {
setBillingAction('trial');
try {
const res = await fetch('/api/billing/trial', { method: 'POST' });
const data = await res.json();
if (!res.ok) {
showMessage('error', data.error || 'Failed to start your free trial');
return;
}
const billingRes = await fetch('/api/billing');
if (billingRes.ok) {
setBilling((await billingRes.json()).data);
}
showMessage('success', 'Your free trial has started');
} catch {
showMessage('error', 'Failed to start your free trial');
} finally {
setBillingAction(null);
}
}, [showMessage]);
const handleCancelSubscription = useCallback(
async (input: { reason: CancellationReason | null; note: string | null }) => {
setBillingAction('cancel');
try {
const res = await fetch('/api/billing/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json();
if (!res.ok) {
showMessage('error', data.error || 'Failed to cancel subscription');
return false;
}
setCancelDialogOpen(false);
const billingRes = await fetch('/api/billing');
if (billingRes.ok) {
setBilling((await billingRes.json()).data);
}
const endsOn = data.data?.periodEnd
? new Date(data.data.periodEnd).toLocaleDateString()
: null;
showMessage(
'success',
data.data?.canceledImmediately
? 'Subscription canceled. Automatic collection has stopped for its open invoices. Charges for prior service may still be owed.'
: endsOn
? `Your subscription ends on ${endsOn}. You keep full access until then.`
: 'Your subscription ends at the close of the current period.'
);
return true;
} catch {
showMessage('error', 'Failed to cancel subscription');
return false;
} finally {
setBillingAction(null);
}
},
[showMessage]
);
if (loading) {
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
@@ -364,14 +473,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}
@@ -382,14 +494,14 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<p className="text-sm text-muted-foreground mt-1">
{billing.subscription.hasActiveSubscription
? hasScheduledCancellation
? billing.subscription.hasActiveTrial
? billing.subscription.status === 'TRIALING'
? 'Trial canceled. Access remains active until the trial ends.'
: '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.subscription.hasBillingAccess
? 'Workspace access remains available while you resolve your payment.'
: 'Billing access has ended.'}
</p>
</div>
@@ -400,7 +512,15 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</Badge>
</div>
{billing.subscription.hasActiveTrial &&
{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&apos;t go through. Update your payment method to keep
your subscription. Starting a new one would create a duplicate.
</p>
) : null}
{billing.subscription.status === 'TRIALING' &&
billing.subscription.trialEndsAt &&
hasScheduledCancellation ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
@@ -419,18 +539,20 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{hasScheduledCancellation && billing.subscription.cancelAt ? (
<p className="text-sm text-muted-foreground">
Cancellation was scheduled on{' '}
Cancellation takes effect on{' '}
{new Date(billing.subscription.cancelAt).toLocaleDateString()}.
</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}
@@ -443,10 +565,54 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</div>
) : null}
{billing.openInvoice ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-4 space-y-2">
<p className="text-sm font-semibold text-destructive">
A payment of{' '}
{formatInvoiceAmount(
billing.openInvoice.amountDue,
billing.openInvoice.currency
)}{' '}
did not go through
</p>
<p className="text-sm text-muted-foreground">
{billing.openInvoice.attemptCount} attempt
{billing.openInvoice.attemptCount === 1 ? '' : 's'} so far
{billing.openInvoice.nextPaymentAttempt
? `, next one on ${new Date(billing.openInvoice.nextPaymentAttempt).toLocaleDateString()}`
: ''}
. Update your payment method or pay the invoice to stop the retries, or cancel
to stop them for good.
</p>
{billing.subscription.billingAccessEndedAt ? (
<p className="text-sm text-muted-foreground">
{new Date(billing.subscription.billingAccessEndedAt) > new Date()
? `Access to your workspaces continues until ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.`
: `Access to your workspaces ended on ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. Paying this invoice restores it.`}
</p>
) : null}
{billing.openInvoice.hostedInvoiceUrl ? (
<a
href={billing.openInvoice.hostedInvoiceUrl}
target="_blank"
rel="noreferrer"
className="inline-block text-sm font-medium text-primary hover:underline"
>
View and pay this invoice
</a>
) : null}
</div>
) : 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')}
onClick={() =>
handleBillingRedirect(
'/api/billing/portal',
billing.needsPaymentFix ? 'payment_method_update' : undefined
)
}
disabled={billingAction !== null}
>
{billingAction === 'portal' ? (
@@ -454,12 +620,43 @@ 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>
) : (
) : null}
{/* Beside the portal button, not inside it. Someone who came to
cancel should not have to guess that "Manage" is the way, and
the portal cannot ask why they are leaving. */}
{billing.cancelAvailable ? (
<Button
variant="ghost"
className="text-muted-foreground"
onClick={() => setCancelDialogOpen(true)}
disabled={billingAction !== null}
>
Cancel subscription
</Button>
) : null}
{billing.subscription.hasRecoverableSubscription &&
billing.portalAvailable ? null : (
<>
{billing.workspaceCreation.canStartTrial ? (
<Button onClick={handleStartTrial} disabled={billingAction !== null}>
{billingAction === 'trial' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Starting Trial...
</>
) : (
'Start Free Trial'
)}
</Button>
) : null}
<Button
variant={billing.workspaceCreation.canStartTrial ? 'outline' : 'default'}
onClick={() => handleBillingRedirect('/api/billing/checkout')}
disabled={!billing.checkoutAvailable || billingAction !== null}
>
@@ -468,12 +665,11 @@ 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'
)}
</Button>
</>
)}
</div>
</>
@@ -481,6 +677,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</CardContent>
</Card>
{billing ? (
<CancelSubscriptionDialog
open={cancelDialogOpen}
onOpenChange={setCancelDialogOpen}
periodEnd={billing.subscription.currentPeriodEnd}
isTrial={billing.subscription.status === 'TRIALING'}
canceledImmediately={billing.cancelIsImmediate}
onConfirm={handleCancelSubscription}
/>
) : null}
{billing?.subscription.hasBillingAccess && (
<Card className="mb-6">
<CardHeader>
@@ -489,7 +696,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 +737,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">
@@ -15,12 +15,35 @@ export default function NewWorkspacePage({
}: {
workspaceCreation: {
canCreateWorkspace: boolean;
canStartTrial?: boolean;
reason: string | null;
};
}) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isStartingTrial, setIsStartingTrial] = useState(false);
const [error, setError] = useState('');
const handleStartTrial = async () => {
setIsStartingTrial(true);
setError('');
try {
const response = await fetch('/api/billing/trial', { method: 'POST' });
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to start your free trial');
return;
}
router.refresh();
} catch {
setError('Something went wrong. Please try again.');
} finally {
setIsStartingTrial(false);
}
};
const [formData, setFormData] = useState({
name: '',
description: '',
@@ -76,7 +99,11 @@ export default function NewWorkspacePage({
)}
</div>
<CardTitle className="text-2xl">
{workspaceCreation.canCreateWorkspace ? 'Create New Workspace' : 'Upgrade Required'}
{workspaceCreation.canCreateWorkspace
? 'Create New Workspace'
: workspaceCreation.canStartTrial
? 'Start Your Free Trial'
: 'Upgrade Required'}
</CardTitle>
<CardDescription className="text-base">
{workspaceCreation.canCreateWorkspace
@@ -139,9 +166,27 @@ export default function NewWorkspacePage({
You can still create and manage projects inside workspaces where you are already a
member.
</p>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{workspaceCreation.canStartTrial ? (
<Button className="w-full" onClick={handleStartTrial} disabled={isStartingTrial}>
{isStartingTrial ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Starting Trial...
</>
) : (
'Start Free Trial'
)}
</Button>
) : (
<Button asChild className="w-full">
<Link href="/settings">Open Billing Settings</Link>
</Button>
)}
</div>
)}
</CardContent>
+74
View File
@@ -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} />
</>
);
}
+426
View File
@@ -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&apos;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
View File
@@ -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>
+22 -1
View File
@@ -1,4 +1,5 @@
import { Metadata } from 'next';
import { Suspense } from 'react';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
@@ -10,6 +11,7 @@ import {
} from '@/lib/admin-stats';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button';
import { CancellationReasonsCard } from '@/components/admin/cancellation-reasons-card';
import {
Users,
Folder,
@@ -82,7 +84,7 @@ export default async function AdminDashboardPage() {
where: { voiceUrl: { not: null } },
}),
db.comment.count({
where: { imageUrl: { not: null } },
where: { images: { some: {} } },
}),
]);
@@ -275,9 +277,28 @@ 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>
</>
)}
{/* Outside the `stripeStats` guard on purpose: the answers live in our own
table and must stay readable while a Stripe outage blanks the cards above. */}
{isStripeBillingEnabled() && (
<Suspense fallback={null}>
<CancellationReasonsCard />
</Suspense>
)}
</div>
);
}
+384 -12
View File
@@ -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>
+2 -2
View File
@@ -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({
+44
View File
@@ -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,
+47 -4
View File
@@ -15,7 +15,16 @@ import {
createVerificationToken,
isEmailVerificationEnabled,
sendVerificationEmail,
warnIfTrialsSkipVerification,
} from '@/lib/email-verification';
import {
isDisposableEmailDomain,
isValidEmailAddress,
normalizeEmail,
} from '@/lib/email-validation';
import { startCardlessTrialOnSignup } 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 startCardlessTrialOnSignup(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
+4 -4
View File
@@ -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');
}
+20 -4
View File
@@ -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');
}
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import {
CANCELLATION_NOTE_MAX_LENGTH,
cancelSubscription,
isCancellationReason,
} from '@/lib/cancellation';
import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimit, rateLimitHeaders } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { isStripeConfigured } from '@/lib/stripe';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger';
/**
* In-app cancellation: end unpaid subscriptions immediately, schedule paid
* subscriptions for period end, and record the optional reason.
*
* This exists beside the Stripe portal rather than instead of it. The portal
* cannot ask a question of our own, and by the time its webhook arrives the
* customer has already left the page. Both fields are optional: skipping the
* question is allowed and must never stand between someone and cancelling.
*/
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
if (!isTrustedSameOriginRequest(request)) {
return apiErrors.forbidden('Invalid request origin');
}
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
// A second limit keyed on the account. The IP-keyed one above is shared by
// every mutating route and, without TRUSTED_PROXY_MODE, by every caller,
// so it is the wrong thing to lean on for the one action a leaving
// customer most needs to succeed.
const config = RATE_LIMIT_CONFIGS['billing-cancel'];
const limit = await checkRateLimit(session.user.id, 'billing-cancel', 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 (!isStripeFeatureEnabled()) {
return apiErrors.badRequest('Stripe billing is disabled by this host');
}
if (!isStripeConfigured()) {
return apiErrors.internalError('Stripe billing is not configured');
}
const body = await request.json().catch(() => null);
const rawReason = body?.reason ?? null;
if (rawReason !== null && !isCancellationReason(rawReason)) {
return apiErrors.badRequest('Unknown cancellation reason');
}
const rawNote = body?.note;
if (rawNote !== undefined && rawNote !== null && typeof rawNote !== 'string') {
return apiErrors.badRequest('Note must be text');
}
const trimmedNote = typeof rawNote === 'string' ? rawNote.trim() : '';
if (trimmedNote.length > CANCELLATION_NOTE_MAX_LENGTH) {
return apiErrors.badRequest(
`Note must be at most ${CANCELLATION_NOTE_MAX_LENGTH} characters`
);
}
const result = await cancelSubscription({
userId: session.user.id,
reason: rawReason,
note: trimmedNote.length > 0 ? trimmedNote : null,
});
if (!result.ok) {
switch (result.code) {
case 'ALREADY_CANCELING':
return apiErrors.conflict(
'Your subscription is already set to end at the close of this period'
);
case 'STRIPE_REJECTED':
return apiErrors.conflict(
'Stripe could not find this subscription. Open Manage Subscription to see its current state.'
);
default:
return apiErrors.conflict('There is no active subscription to cancel');
}
}
const response = successResponse({
cancelAtPeriodEnd: !result.canceledImmediately,
canceledImmediately: result.canceledImmediately,
status: result.status,
cancelAt: result.cancelAt?.toISOString() ?? null,
voidedInvoices: result.voidedInvoices,
periodEnd: result.periodEnd?.toISOString() ?? null,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('billing.cancel', error);
return apiErrors.internalError('Failed to cancel subscription');
}
}
+34 -4
View File
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import {
DEFAULT_TRIAL_PERIOD_DAYS,
findBlockingStripeSubscription,
getOrCreateStripeCustomerId,
getStripeCheckoutState,
} from '@/lib/billing';
@@ -11,6 +11,7 @@ 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,13 +47,31 @@ 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();
const priceId = getStripePriceId();
const customerId = await getOrCreateStripeCustomerId(session.user.id);
// The guard above reads the local mirror, which can be stale or cleared: the incident
// that prompted this had a customer holding three subscriptions at once because the
// mirror said there were none. Stripe is the one that knows.
const blockingSubscription = await findBlockingStripeSubscription(customerId);
if (blockingSubscription) {
return apiErrors.badRequest(
'A subscription already exists for this account. Manage it from the billing portal.'
);
}
const appOrigin = getAppOrigin(request);
const checkoutSession = await stripe.checkout.sessions.create({
@@ -65,11 +84,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 +98,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) {
+41 -4
View File
@@ -1,4 +1,5 @@
import { NextRequest } from 'next/server';
import type Stripe from 'stripe';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing';
@@ -19,6 +20,40 @@ function getAppOrigin(request: NextRequest) {
return request.nextUrl.origin;
}
async function readRequestedFlow(request: NextRequest) {
try {
const body = await request.json();
return body?.flow === 'payment_method_update' ? 'payment_method_update' : null;
} catch {
return null;
}
}
async function createPortalSession(
stripe: Stripe,
customer: string,
returnUrl: string,
flow: 'payment_method_update' | null
) {
if (flow === 'payment_method_update') {
try {
return await stripe.billingPortal.sessions.create({
customer,
return_url: returnUrl,
flow_data: { type: 'payment_method_update' },
});
} catch (error) {
// The portal configuration may not expose this flow; the plain portal still works.
logError('Falling back to the default Stripe portal flow:', error);
}
}
return stripe.billingPortal.sessions.create({
customer,
return_url: returnUrl,
});
}
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'mutate');
@@ -47,10 +82,12 @@ export async function POST(request: NextRequest) {
}
const stripe = getStripe();
const portalSession = await stripe.billingPortal.sessions.create({
customer: billing.subscription.stripeCustomerId,
return_url: `${getAppOrigin(request)}/settings`,
});
const portalSession = await createPortalSession(
stripe,
billing.subscription.stripeCustomerId,
`${getAppOrigin(request)}/settings`,
await readRequestedFlow(request)
);
const response = successResponse({ url: portalSession.url });
return withCacheControl(response, 'private, no-store');
+54 -4
View File
@@ -1,6 +1,12 @@
import { BillingSubscriptionStatus } from '@prisma/client';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing';
import {
findCancelableStripeSubscription,
isUnpaidStripeSubscription,
getBillingOverview,
getOpenInvoiceForCustomer,
} from '@/lib/billing';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe';
import { logError } from '@/lib/logger';
@@ -15,19 +21,63 @@ export async function GET() {
const billing = await getBillingOverview(session.user.id);
const isEnabled = isStripeFeatureEnabled();
const isConfigured = hasStripeRuntimeConfig();
// Invoice details are only needed when the current subscription is behind on payment.
const needsPaymentFix =
billing.subscription.status === BillingSubscriptionStatus.PAST_DUE ||
billing.subscription.status === BillingSubscriptionStatus.UNPAID;
const openInvoice =
isStripeConfigured() && needsPaymentFix && billing.subscription.stripeCustomerId
? await getOpenInvoiceForCustomer(
billing.subscription.stripeCustomerId,
billing.subscription.stripeSubscriptionId
)
: null;
const cancelable =
isStripeConfigured() && billing.subscription.stripeCustomerId
? await findCancelableStripeSubscription(billing.subscription.stripeCustomerId)
: null;
const response = successResponse({
isEnabled,
isConfigured,
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasActiveSubscription,
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription,
// A customer id alone is not enough: it is created on the first checkout attempt, so
// someone who abandoned checkout would be sent to an empty portal.
portalAvailable:
isStripeConfigured() &&
Boolean(billing.subscription.stripeCustomerId) &&
(billing.subscription.hasRecoverableSubscription ||
Boolean(billing.subscription.stripeSubscriptionId)),
// An already scheduled unpaid subscription still needs immediate cancellation.
// A different unscheduled subscription may also remain after an earlier cancel.
cancelAvailable: Boolean(cancelable),
needsPaymentFix,
cancelIsImmediate: Boolean(
cancelable &&
(isUnpaidStripeSubscription(cancelable) ||
['canceled', 'incomplete_expired'].includes(cancelable.status))
),
openInvoice: openInvoice
? {
id: openInvoice.id,
hostedInvoiceUrl: openInvoice.hostedInvoiceUrl,
amountDue: openInvoice.amountDue,
currency: openInvoice.currency,
attemptCount: openInvoice.attemptCount,
nextPaymentAttempt: openInvoice.nextPaymentAttempt?.toISOString() ?? null,
}
: null,
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,
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse } from '@/lib/api-response';
import { startCardlessTrial } from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger';
import { db } from '@/lib/db';
/**
* The explicit claim of a deferred cardless trial.
*
* An invited collaborator has their trial held back at signup; nothing else in
* the product is allowed to start it as a side effect, because the clock spends
* the account's only trial. This endpoint is the one place the user says "start
* it now", from the workspace-creation and billing screens.
*/
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
if (!isTrustedSameOriginRequest(request)) {
return apiErrors.forbidden('Invalid request origin');
}
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
if (!isStripeFeatureEnabled()) {
return apiErrors.badRequest('Stripe billing is disabled by this host');
}
const started = await startCardlessTrial(session.user.id);
if (!started) {
return apiErrors.conflict('Your free trial has already been used');
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { trialEndsAt: true },
});
return successResponse({ trialEndsAt: user?.trialEndsAt ?? null });
} catch (error) {
logError('billing.trial.start', error);
return apiErrors.internalError();
}
}
+146 -9
View File
@@ -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 } });
+44
View File
@@ -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;
}
+54
View File
@@ -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');
}
+7 -3
View File
@@ -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');
}
+1 -1
View File
@@ -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 };
}
@@ -129,6 +130,14 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
videoId: result.version.videoId,
};
// Read before the delete: the rows cascade away with the version, and their stored
// objects would then have nothing pointing at them. Subtitles live in our own storage
// whatever hosts the video, so this runs for a Bunny-hosted cut too.
const subtitles = await db.videoSubtitle.findMany({
where: { versionId },
select: { sourceUrl: true },
});
await db.$transaction(async (tx) => {
// Delete the version (cascades to comments).
await tx.videoVersion.delete({ where: { id: versionId } });
@@ -148,8 +157,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
});
const bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([bunnyRef]);
const cleanupInput = { bunny: bunnyCleanupResult };
const versionMediaUrls = [
...subtitles.map((subtitle) => subtitle.sourceUrl),
...(result.version.providerId === 'r2'
? [result.version.originalUrl, result.version.thumbnailUrl]
: []),
].filter((url): url is string => Boolean(url));
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
cleanupBunnyStreamVideosBestEffort([bunnyRef]),
deleteMediaFilesBestEffort(versionMediaUrls),
]);
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');
}
}
+134 -11
View File
@@ -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) {
+41 -13
View File
@@ -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,30 @@ 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,
billingAccessEndedAt: 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 +213,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
View File
@@ -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 } } }],
};
+1 -3
View File
@@ -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({
+9 -2
View File
@@ -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
+38 -33
View File
@@ -1,28 +1,16 @@
import { NextRequest } from 'next/server';
import type Stripe from 'stripe';
import { markSubscriptionCanceledByCustomerId, syncStripeSubscriptionToUser } from '@/lib/billing';
import { getInvoiceSubscriptionId, 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,47 @@ 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;
}
// Invoice events carry the payment health of a subscription earlier and more
// reliably than the subscription events alone. Without them a customer whose card
// failed keeps the mirror of a healthy subscription until Stripe eventually gives
// up, which is the whole dunning window spent showing them the wrong state.
case 'invoice.paid':
case 'invoice.payment_failed':
case 'invoice.voided':
case 'invoice.marked_uncollectible': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = getCustomerId(invoice.customer);
// Only subscription invoices. A one-off invoice against a customer record left
// behind by an abandoned checkout has no subscription, and syncing on it would
// find an empty list, mark the account canceled and book a churn event for a
// subscription that never existed.
if (customerId && getInvoiceSubscriptionId(invoice)) {
await syncStripeCustomerSubscriptions(customerId);
}
break;
}
default:
+31 -19
View File
@@ -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) {
+35 -7
View File
@@ -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;
}
+24 -4
View File
@@ -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');
}
+25 -5
View File
@@ -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,91 @@
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 { logError } from '@/lib/logger';
import {
SAFE_SUBTITLE_FILENAME,
SUBTITLE_CONTENT_TYPE,
SUBTITLE_OBJECT_KEY_PREFIX,
subtitleFileNameToProxyUrl,
} from '@/lib/subtitle-validation';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Validate filename to prevent path traversal
if (!SAFE_SUBTITLE_FILENAME.test(filename)) {
return apiErrors.badRequest('Invalid filename');
}
// Parallelize the DB lookup and session check to narrow the timing delta
// between "subtitle not found" and "subtitle found, access denied" responses.
const [subtitle, session] = await Promise.all([
db.videoSubtitle.findUnique({
where: { sourceUrl: subtitleFileNameToProxyUrl(filename) },
select: {
version: {
select: {
video: {
select: {
id: true,
projectId: true,
project: {
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
},
},
},
},
},
},
}),
auth(),
]);
const video = subtitle?.version?.video ?? 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,
})
: null;
if (!shareAccess?.hasAccess) {
return apiErrors.forbidden('Access denied');
}
}
return proxyR2MediaObject({
request,
key: `${SUBTITLE_OBJECT_KEY_PREFIX}${filename}`,
fallbackContentType: SUBTITLE_CONTENT_TYPE,
cacheControl: 'private, no-store',
extraHeaders: {
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
},
internalErrorMessage: 'Failed to retrieve subtitle',
});
} catch (error: unknown) {
logError('Error serving subtitle:', error);
return apiErrors.internalError('Failed to retrieve subtitle');
}
}
+123
View File
@@ -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 {
+113 -65
View File
@@ -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');
}
+19 -3
View File
@@ -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');
}
}
+179 -28
View File
@@ -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');
}
@@ -0,0 +1,48 @@
import { NextRequest } from 'next/server';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { db } from '@/lib/db';
import { logError } from '@/lib/logger';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { rateLimit } from '@/lib/rate-limit';
import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation';
import { getVideoAssetAccessContext } from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string; subtitleId: string }> };
// DELETE /api/videos/[videoId]/subtitles/[subtitleId]
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'subtitle-delete');
if (limited) return limited;
const { videoId, subtitleId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
if (!context.viewerUserId || !context.canManageAssets) {
return apiErrors.forbidden('Access denied');
}
const subtitle = await db.videoSubtitle.findFirst({
where: { id: subtitleId, version: { videoParentId: videoId } },
select: { id: true, sourceUrl: true },
});
if (!subtitle) return apiErrors.notFound('Subtitle');
// Storage first, row second, for the same reason video deletion does it in that
// order: a refused delete leaves the row in place so the operation can be retried,
// rather than orphaning an object nothing points at any more.
const objectKey = subtitleProxyPathToObjectKey(subtitle.sourceUrl);
if (objectKey) {
await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: objectKey }));
}
await db.videoSubtitle.delete({ where: { id: subtitle.id } });
const response = successResponse({ id: subtitle.id, deleted: true });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting subtitle:', error);
return apiErrors.internalError('Failed to delete subtitle');
}
}
+276
View File
@@ -0,0 +1,276 @@
import { NextRequest } from 'next/server';
import { DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { db } from '@/lib/db';
import { logError } from '@/lib/logger';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { rateLimit } from '@/lib/rate-limit';
import {
releaseStorageReservation,
reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import {
getSubtitleExtension,
MAX_SUBTITLE_FILE_SIZE,
normalizeSubtitleFile,
normalizeSubtitleLanguage,
sanitizeSubtitleLabel,
subtitleFileNameToProxyUrl,
SUBTITLE_CONTENT_TYPE,
SUBTITLE_OBJECT_KEY_PREFIX,
subtitleProxyPathToObjectKey,
} from '@/lib/subtitle-validation';
import { getVideoAssetAccessContext } from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string }> };
const MAX_MULTIPART_BODY_SIZE = MAX_SUBTITLE_FILE_SIZE + 64 * 1024;
/** A cut with more tracks than this is not being subtitled, it is being used as storage. */
const MAX_SUBTITLES_PER_VERSION = 20;
type SubtitleRow = {
id: string;
versionId: string;
language: string;
label: string;
sourceUrl: string;
sizeBytes: bigint;
createdAt: Date;
updatedAt: Date;
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
};
function shapeSubtitle(subtitle: SubtitleRow, canManage: boolean) {
return {
id: subtitle.id,
versionId: subtitle.versionId,
language: subtitle.language,
label: subtitle.label,
url: subtitle.sourceUrl,
sizeBytes: Number(subtitle.sizeBytes),
createdAt: subtitle.createdAt,
updatedAt: subtitle.updatedAt,
uploadedByUser: subtitle.uploadedByUser,
canDelete: canManage,
};
}
const SUBTITLE_SELECT = {
id: true,
versionId: true,
language: true,
label: true,
sourceUrl: true,
sizeBytes: true,
createdAt: true,
updatedAt: true,
uploadedByUser: { select: { id: true, name: true, image: true } },
} as const;
// GET /api/videos/[videoId]/subtitles?versionId=...
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'subtitle-list');
if (limited) return limited;
const { videoId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
const versionId = request.nextUrl.searchParams.get('versionId')?.trim() || null;
const subtitles = await db.videoSubtitle.findMany({
where: {
version: {
videoParentId: videoId,
...(versionId ? { id: versionId } : {}),
},
},
orderBy: [{ language: 'asc' }],
select: SUBTITLE_SELECT,
});
const response = successResponse({
subtitles: subtitles.map((subtitle) => shapeSubtitle(subtitle, context.canManageAssets)),
canManageSubtitles: context.canManageAssets,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error listing subtitles:', error);
return apiErrors.internalError('Failed to load subtitles');
}
}
// POST /api/videos/[videoId]/subtitles
export async function POST(request: NextRequest, { params }: RouteParams) {
let reservationId: string | null = null;
let billedUserId: string | null = null;
let storedObjectKey: string | null = null;
try {
const contentLength = request.headers.get('content-length');
if (!contentLength) {
return apiErrors.badRequest('Missing Content-Length header');
}
const bodySize = Number.parseInt(contentLength, 10);
if (!Number.isFinite(bodySize) || bodySize <= 0) {
return apiErrors.badRequest('Invalid Content-Length header');
}
if (bodySize > MAX_MULTIPART_BODY_SIZE) {
return apiErrors.badRequest('Subtitle file is too large. Maximum size is 2MB.');
}
const limited = await rateLimit(request, 'subtitle-create');
if (limited) return limited;
const { videoId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
// A subtitle is part of the delivered cut rather than a comment attachment, so it
// takes the editor permission and never the commenter one. Guests and share-link
// viewers can read the tracks but cannot add them.
if (!context.viewerUserId || !context.canManageAssets) {
return apiErrors.forbidden('Access denied');
}
const formData = await request.formData();
const files = formData.getAll('subtitle');
if (files.length !== 1 || !(files[0] instanceof File)) {
return apiErrors.badRequest('No subtitle file provided');
}
const file = files[0];
if (file.size > MAX_SUBTITLE_FILE_SIZE) {
return apiErrors.badRequest('Subtitle file is too large. Maximum size is 2MB.');
}
if (!getSubtitleExtension(file.name)) {
return apiErrors.badRequest('Subtitle must be a .srt or .vtt file');
}
const versionIdValue = formData.get('versionId');
if (typeof versionIdValue !== 'string' || !versionIdValue.trim()) {
return apiErrors.badRequest('versionId is required');
}
const versionId = versionIdValue.trim();
const language = normalizeSubtitleLanguage(formData.get('language'));
if (!language) {
return apiErrors.badRequest('language must be a BCP-47 tag such as "tr" or "en-US"');
}
const label = sanitizeSubtitleLabel(formData.get('label'), language.toUpperCase());
const version = await db.videoVersion.findFirst({
where: { id: versionId, videoParentId: videoId },
select: { id: true },
});
if (!version) return apiErrors.notFound('Version');
const existing = await db.videoSubtitle.findUnique({
where: { versionId_language: { versionId, language } },
select: { id: true, sourceUrl: true },
});
if (!existing) {
const trackCount = await db.videoSubtitle.count({ where: { versionId } });
if (trackCount >= MAX_SUBTITLES_PER_VERSION) {
return apiErrors.badRequest(
`A version can hold at most ${MAX_SUBTITLES_PER_VERSION} subtitle tracks`
);
}
}
const normalized = normalizeSubtitleFile(new Uint8Array(await file.arrayBuffer()));
if (!normalized.ok) {
return apiErrors.badRequest(normalized.error);
}
const body = Buffer.from(normalized.vtt, 'utf8');
const sizeBytes = BigInt(body.byteLength);
billedUserId = context.video.project.workspace.ownerId;
const reserveResult = await reserveStorageQuota(
billedUserId,
sizeBytes,
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
);
if ('error' in reserveResult) return reserveResult.error;
reservationId = reserveResult.reservationId;
const fileName = `${randomUUID()}.vtt`;
const objectKey = `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`;
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: objectKey,
Body: body,
ContentType: SUBTITLE_CONTENT_TYPE,
})
);
storedObjectKey = objectKey;
const created = await db.$transaction(async (tx) => {
if (existing) {
await tx.videoSubtitle.delete({ where: { id: existing.id } });
}
return tx.videoSubtitle.create({
data: {
versionId,
language,
label,
sourceUrl: subtitleFileNameToProxyUrl(fileName),
sizeBytes,
billedUserId: billedUserId as string,
uploadedByUserId: context.viewerUserId,
},
select: SUBTITLE_SELECT,
});
});
// The row is committed, so the bytes are counted by the usage sum and the hold that
// stood in for them until now is no longer needed.
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
);
reservationId = null;
storedObjectKey = null;
if (existing) {
// Best effort: the replaced track is already unreachable, and a stranded object is
// a cleanup problem rather than a reason to fail an upload that succeeded.
const staleKey = subtitleProxyPathToObjectKey(existing.sourceUrl);
if (staleKey) {
try {
await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: staleKey }));
} catch (deleteError) {
logError('Failed to delete replaced subtitle object:', deleteError);
}
}
}
const response = successResponse(shapeSubtitle(created, true), 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
if (storedObjectKey) {
try {
await r2Client.send(
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: storedObjectKey })
);
} catch (cleanupError) {
logError('Failed to clean up subtitle object after a failed upload:', cleanupError);
}
}
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
);
logError('Error uploading subtitle:', error);
return apiErrors.internalError('Failed to upload subtitle');
}
}
+8 -2
View File
@@ -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');
}
+7
View File
@@ -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&apos;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&apos;t have an OpenFrame account yet. Create one to open this{' '}
{preview.scopeLabel} we&apos;ll bring you right back here once you&apos;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>
);
}
+25 -4
View File
@@ -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') {
+27 -6
View File
@@ -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';
@@ -22,8 +26,8 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
metadataBase: new URL(seoConfig.url),
title: {
default: `${seoConfig.name} | ${seoConfig.title}`,
template: `%s | ${seoConfig.name}`,
default: `${seoConfig.name} - ${seoConfig.title}`,
template: `%s - ${seoConfig.name}`,
},
description: seoConfig.description,
applicationName: seoConfig.name,
@@ -47,7 +51,7 @@ export const metadata: Metadata = {
locale: 'en_US',
siteName: seoConfig.name,
url: seoConfig.url,
title: `${seoConfig.name} | ${seoConfig.title}`,
title: `${seoConfig.name} - ${seoConfig.title}`,
description: seoConfig.description,
images: [
{
@@ -60,7 +64,7 @@ export const metadata: Metadata = {
},
twitter: {
card: 'summary_large_image',
title: `${seoConfig.name} | ${seoConfig.title}`,
title: `${seoConfig.name} - ${seoConfig.title}`,
description: seoConfig.description,
images: [seoConfig.ogImage],
},
@@ -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">
+74 -3
View File
@@ -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}
+2
View File
@@ -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
View File
@@ -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} />;
}

Some files were not shown because too many files have changed in this diff Show More