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.
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.
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.
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.
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
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.
- 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.
- Added billing-related fields to the User model in the database.
- Implemented functions for managing billing access, including trial periods and subscription statuses.
- Created new billing utility functions for Stripe integration.
- Updated onboarding page to include billing overview and workspace creation eligibility.
- Enhanced route access checks to require billing access for certain actions.
- Implemented cleanup scripts for expired billing workspaces and associated media.
- Updated header component to conditionally show app navigation based on billing access.
- Added new migrations for billing-related database changes.
- Add `onboardingCompletedAt` field to User model + migration
- Add AUDIO/R2_AUDIO enum values to schema
- Gate dashboard behind onboarding check; redirect to /onboarding if not completed
- Add /onboarding page + layout with 5-step wizard (welcome, workspace, project, video info, notifications)
- Add POST /api/onboarding/complete endpoint to mark onboarding done
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- 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
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
- 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.
- 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.
Initialize OpenFrame project with core scaffold and UI foundation.
- Project scaffold: Next.js 16.1 (App Router) + Bun runtime
- UI: shadcn/ui + TailwindCSS; landing, dashboard, project, and auth UIs
- Video support: provider abstraction (YouTube first), video player page with timestamped comments and custom timeline
- Auth & DB: NextAuth skeleton and Prisma schema (Postgres) included
- UX: dark-mode toggle, full-width layouts, comments sidebar, video route moved to /watch/[videoId]
- Dev: added YouTube iframe integration, custom controls, and TypeScript types
- Next steps: DB migrations, API routes (CRUD), real data wiring, voice-recording & sharing