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.
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.
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.
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.
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.
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.
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.
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.
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]>
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').
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
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#16Closes#19
- 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
- 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.
- Introduced a new logger utility (`logError`) to standardize error logging.
- Replaced all instances of `console.error` with `logError` in various API routes and libraries.
- Enhanced error logging to sanitize sensitive information, particularly for Prisma and Stripe errors.
- Ensured consistent error handling and logging practices throughout the codebase.
- Moved registration logic to a new client component for better separation of concerns.
- Integrated invite code requirement based on feature flags in the registration process.
- Enhanced dashboard functionality to conditionally enable Bunny uploads based on feature flags.
- Updated various components and API routes to check for Bunny uploads and Stripe billing feature flags.
- Added new feature flag utilities for managing feature toggles in the application.
- Fix type confusion vulnerability in comment content updates
- Validate pagination offsets to prevent negative values
- Validate timestamp is a valid number before parsing
- Exclude guestEmail from comment API responses for privacy
- Fix TypeScript error in audio upload route
- Add image attachment support for comments with upload API
- Update admin dashboard to track image attachments
- Rename cleanup functions to handle both voice and image media
Add currentUserName to video API responses and session for display in comments. Also adds memoization for performance optimization and implements Page Visibility API to pause comment polling when tab is hidden.
Implements a complete watch progress system that allows users to:
- Save playback position automatically every 5 seconds while watching
- Resume from last position when returning to a video
- Save progress on page leave using sendBeacon for reliability
Also includes:
- Optimized slug generation in projects and workspaces APIs (single query vs loop)
- Added isActive filter to version queries across all video endpoints
- Added pagination support for video and comment queries
- Enhanced database pool management with connection limits and graceful shutdown
- New WatchProgress Prisma model with user-version relations
- Move `checkProjectAccess` function to `lib/auth.ts`
- Consolidate project access logic into a single utility
- Simplify access checks in video API routes
- Remove redundant project member inclusions from queries
- Allow workspace members to view comments
- Enable workspace members to resolve/unresolve comments
- Permit workspace members to delete comments
- Verify workspace membership or ownership for access
- Introduce `withCacheControl` utility function for API responses.
- Apply `private, no-store` to authentication and data modification (POST, PATCH, DELETE) routes.
- Apply `private, no-cache` to sensitive data retrieval (GET) routes.
- Enhance security by preventing caching of private user data.
- Ensure fresh data is always fetched for authenticated API responses.
- Introduce dedicated error pages for dashboard and video routes
- Add specific not-found pages for dashboard, projects, videos, and settings
- Implement global `not-found.tsx` for general unhandled routes
- Integrate root and dashboard layouts with ErrorBoundary and Suspense
- Add new UI components: Accordion, Hover Card, Menubar, Navigation Menu, Select, Tabs
- Update Navbar to utilize the new Navigation Menu component
- Enhance `button` component with a `link` variant for better styling
- Refine existing UI components (dialog, dropdown, input, etc.)
- Update Tailwind config with new colors and animation extensions
- Removed Telegram setup instructions from settings page.
- Enhanced video and version comment APIs to include tag information.
- Added new CommentTag model in Prisma schema for managing tags.
- Created API routes for managing tags (GET, POST, PATCH, DELETE).
- Updated WatchPage to support tag selection and display.
- Introduced keyboard shortcuts modal for improved user experience.
- Added tag selection dropdown in comment input area.
- Add R2 client setup and audio upload functionality in lib/r2.ts.
- Create audio file cleanup functions in lib/r2-cleanup.ts to delete voice files associated with videos, projects, and workspaces.
- Enhance rate limiting in lib/rate-limit.ts with new action-specific limits and improved IP validation.
- Introduce a unified rate limit check function that returns a 429 response when limits are exceeded.
- Update package.json to include the AWS SDK for S3.
- Implemented edit dialog for updating video title and description.
- Added functionality to create new video versions with URL validation.
- Included delete confirmation dialog for video removal.
- Enhanced UI with loading indicators and error handling.
- Updated video card layout for better user interaction.
feat(youtube): extend YT namespace with playback rate methods
- Added methods to set and get playback rate.
- Included method to retrieve available playback rates.
- Implemented API endpoints for managing workspace members (GET, POST, PATCH, DELETE).
- Added workspace creation and retrieval functionalities.
- Enhanced project model to associate with workspaces.
- Updated project member roles and access control logic.
- Created sign-out page and updated authentication flow.
- Modified header to include navigation to workspaces.
- Updated Prisma schema to include workspace and member models.
- Seed script updated to create demo workspaces and associated members.