Commit Graph
173 Commits
Author SHA1 Message Date
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 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
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
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 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 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 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
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
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
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
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 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
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 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 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 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
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 7058ea9f87 style: fix prettier issues after syncing with master 2026-04-25 20:24:17 +03:00
yusufipek ef7402bc23 Merge origin/master into pr-9-review 2026-04-25 20:21:40 +03:00
Enes Köksal 3cfea40fbd refactor: eslint and prettier conflict will be resolved and formatted 2026-04-23 17:05:43 +03:00
Yusuf İpek 066c4247db feat: add sorting functionality to project videos and update search parameters 2026-04-22 15:31:09 +03:00
Yusuf İpek 873945464d feat: implement storage quota management for uploads
- Added storage quota enforcement for audio and image uploads in the respective routes.
- Introduced reservation system to manage concurrent uploads and prevent quota overages.
- Enhanced comment creation to account for audio and image attachment sizes against user quotas.
- Created new UploadReservation model to track in-flight upload reservations.
- Backfilled existing video assets with size information from R2.
- Added progress component for UI feedback during uploads.
- Updated API responses to include reservation IDs for better quota management.
- Adjusted error handling to return appropriate storage limit exceeded messages.
2026-04-15 19:53:43 +03:00
Yusuf İpek 50d31ef894 feat(download): add estimation of egress bytes by fetching Content-Length via HEAD request 2026-04-14 13:56:27 +03:00