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.
This commit is contained in:
yusufipk
2026-07-26 18:53:54 +07:00
parent 0ceba72d5b
commit b51e690062
111 changed files with 1665 additions and 804 deletions
+39 -10
View File
@@ -2,7 +2,7 @@ import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3';
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
import { isBunnyUploadsEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
import { getStripe, getStripePriceId } from '@/lib/stripe';
import { logError } from '@/lib/logger';
@@ -117,14 +117,31 @@ async function getR2StorageSnapshot(): Promise<R2StorageSnapshot> {
}
export async function refreshR2StorageSnapshot(): Promise<string> {
const snapshot = await buildR2StorageSnapshot();
globalForAdminStats.adminR2StorageSnapshot = snapshot;
globalForAdminStats.adminR2StorageSnapshotPromise = undefined;
return snapshot.refreshedAt;
// Single-flight. The promise slot was declared and cleared but never read, so two
// concurrent admin refreshes each walked the whole bucket. A second caller now joins
// the walk already in progress.
const inFlight = globalForAdminStats.adminR2StorageSnapshotPromise;
if (inFlight) {
return (await inFlight).refreshedAt;
}
const pending = buildR2StorageSnapshot();
globalForAdminStats.adminR2StorageSnapshotPromise = pending;
try {
const snapshot = await pending;
globalForAdminStats.adminR2StorageSnapshot = snapshot;
return snapshot.refreshedAt;
} finally {
globalForAdminStats.adminR2StorageSnapshotPromise = undefined;
}
}
async function fetchBunnyStorageStats(): Promise<BunnyStorageStats> {
if (!isBunnyUploadsFeatureEnabled()) {
// isBunnyUploadsEnabled(), not isBunnyUploadsFeatureEnabled(): the flag alone defaults
// to on, so a self-hosted install that never configured Bunny threw
// "Missing Bunny Stream credentials." out of getBunnyConfig() below and the dashboard
// reported -1 instead of zero.
if (!isBunnyUploadsEnabled()) {
return { totalBytes: 0, byVideoId: {} };
}
@@ -219,7 +236,12 @@ export const getCachedUserBunnyStorage = unstable_cache(
video: {
select: {
project: {
select: { ownerId: true },
// The workspace owner, not the project owner. lib/storage-quota.ts bills
// R2 versions to the workspace owner and comment media below does the
// same, and getCachedUserBunnyStorage feeds getUserTotalStorageBytes, so
// the moment project and workspace ownership can differ one workspace's
// Bunny bytes and its R2 bytes would count against two different quotas.
select: { workspace: { select: { ownerId: true } } },
},
},
},
@@ -239,7 +261,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
const seenVideoIds = new Set<string>();
for (const version of bunnyVersions) {
const ownerId = version.video.project.ownerId;
const ownerId = version.video.project.workspace.ownerId;
const dedupeKey = `${ownerId}:${version.videoId}`;
if (seenVideoIds.has(dedupeKey)) continue;
seenVideoIds.add(dedupeKey);
@@ -427,6 +449,8 @@ export interface StripeStats {
pastDueUsers: number;
canceledUsers: number;
freeUsers: number;
/** UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED, which belong to none of the buckets above. */
otherStatusUsers: number;
mrrCents: number;
currency: string;
}
@@ -445,8 +469,7 @@ export const getCachedStripeStats = unstable_cache(
const counts: Record<string, number> = {};
for (const row of statusCounts) {
const key = row.subscriptionStatus ?? 'UNKNOWN';
counts[key] = row._count.id;
counts[row.subscriptionStatus] = row._count.id;
}
const activeSubscribers = counts['ACTIVE'] ?? 0;
@@ -454,6 +477,11 @@ export const getCachedStripeStats = unstable_cache(
const pastDueUsers = counts['PAST_DUE'] ?? 0;
const canceledUsers = counts['CANCELED'] ?? 0;
const freeUsers = counts['FREE'] ?? 0;
// UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED belonged to none of the five buckets
// above, so those users were counted nowhere and the totals silently did not add
// up to the user table.
const otherStatusUsers =
(counts['UNPAID'] ?? 0) + (counts['INCOMPLETE'] ?? 0) + (counts['INCOMPLETE_EXPIRED'] ?? 0);
let mrrCents = 0;
let currency = 'usd';
@@ -475,6 +503,7 @@ export const getCachedStripeStats = unstable_cache(
pastDueUsers,
canceledUsers,
freeUsers,
otherStatusUsers,
mrrCents,
currency,
};
+32 -50
View File
@@ -152,8 +152,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
},
});
type ProjectAccessIntent = 'view' | 'manage' | 'delete';
// ---------------------------------------------------------------------------
// Fast-path: pre-fetch access data alongside any existing DB query so that
// computeProjectAccess() can resolve the result with zero extra round-trips.
@@ -313,13 +311,20 @@ export function computeProjectAccess(
});
}
// Helper to check project access including workspace membership
/**
* Helper to check project access including workspace membership.
*
* This used to take an `intent`, which skipped both workspace queries for an owner at
* `view` intent. That made it report `isWorkspaceMember: false, isWorkspaceAdmin: false`
* for the actor computeProjectAccess reports `true, true` for: the project owner who also
* owns the workspace, which is the shape every real signup produces. The two are meant to
* answer the same question, so they resolve their inputs the same way now and the option
* is gone rather than kept as a parameter that changes nothing.
*/
export async function checkProjectAccess(
project: { id: string; ownerId: string; workspaceId: string; visibility: string },
userId: string | undefined,
options?: { intent?: ProjectAccessIntent }
userId: string | undefined
) {
const intent = options?.intent ?? 'view';
const isOwner = userId === project.ownerId;
const isPublic = project.visibility === 'PUBLIC';
@@ -333,50 +338,18 @@ export async function checkProjectAccess(
const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN;
// The workspace role decides `canEdit`/`isWorkspaceMember`, not just whether the viewer
// gets in at all, so it has to be resolved for every signed-in non-owner. Skipping it
// once access was already granted some other way (public project, or an existing project
// membership) silently downgraded workspace admins to read-only on `intent: 'view'`,
// the intent that pages and GET routes use to decide which actions to render.
// Owners pass every check on their own; resolve their role only when they mutate.
const shouldLoadWorkspaceRole = !!userId && (!isOwner || intent !== 'view');
// Check workspace membership/role
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
let workspaceOwnerBillingAccess = false;
if (shouldLoadWorkspaceRole && userId) {
const [wsMember, wsOwner] = await Promise.all([
db.workspaceMember.findUnique({
where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } },
}),
db.workspace.findUnique({
where: { id: project.workspaceId },
select: {
ownerId: true,
owner: {
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
},
},
}),
]);
if (wsOwner?.ownerId === userId) {
workspaceRole = 'OWNER';
} else if (wsMember) {
workspaceRole = wsMember.role;
}
if (wsOwner?.owner) {
workspaceOwnerBillingAccess = hasBillingAccess(wsOwner.owner);
}
} else {
const wsOwner = await db.workspace.findUnique({
// gets in at all, so it is resolved for every signed-in caller, owners included. The two
// queries run together, so this costs one extra indexed lookup and no extra latency.
const [wsMember, wsOwner] = await Promise.all([
userId
? db.workspaceMember.findUnique({
where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } },
})
: null,
db.workspace.findUnique({
where: { id: project.workspaceId },
select: {
ownerId: true,
owner: {
select: {
subscriptionStatus: true,
@@ -386,9 +359,18 @@ export async function checkProjectAccess(
},
},
},
});
workspaceOwnerBillingAccess = wsOwner?.owner ? hasBillingAccess(wsOwner.owner) : false;
}),
]);
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
if (userId && wsOwner?.ownerId === userId) {
workspaceRole = 'OWNER';
} else if (wsMember) {
workspaceRole = wsMember.role;
}
const workspaceOwnerBillingAccess = wsOwner?.owner ? hasBillingAccess(wsOwner.owner) : false;
return resolveProjectPermissions({
isOwner,
isPublic,
+12 -6
View File
@@ -359,11 +359,11 @@ function getInactiveBillingAccessEndedAt(
}
function getEntitledStripePriceId(subscription: Stripe.Subscription) {
const configuredPriceId = getStripePriceId();
return hasEntitledPrice(subscription, getStripePriceId()) ? getStripePriceId() : null;
}
return (
subscription.items.data.find((item) => item.price.id === configuredPriceId)?.price.id ?? null
);
function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId: string): boolean {
return subscription.items.data.some((item) => item.price.id === configuredPriceId);
}
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
@@ -456,9 +456,15 @@ export function selectAuthoritativeSubscription(
return null;
}
// Read once, up front. Reading it inside the comparator meant a deployment with no
// STRIPE_PRICE_ID configured worked for every customer holding one subscription and
// threw only for those holding two, because a comparator never runs for a one-element
// array. That is a miserable failure mode to diagnose in production.
const configuredPriceId = getStripePriceId();
return [...subscriptions].sort((a, b) => {
const aEntitled = Boolean(getEntitledStripePriceId(a));
const bEntitled = Boolean(getEntitledStripePriceId(b));
const aEntitled = hasEntitledPrice(a, configuredPriceId);
const bEntitled = hasEntitledPrice(b, configuredPriceId);
if (aEntitled !== bEntitled) {
return aEntitled ? -1 : 1;
}
+5 -1
View File
@@ -65,6 +65,10 @@ export function createBunnyUploadToken(
}
export function verifyBunnyUploadToken(token: string, subject: BunnyUploadTokenSubject): boolean {
// Resolved before the try. A missing signing secret is a configuration fault, and
// swallowing that throw made every upload grant look like a forgery instead.
const secret = getBunnyUploadTokenSecret();
try {
const parts = token.split('.');
if (parts.length !== 2) return false;
@@ -72,7 +76,7 @@ export function verifyBunnyUploadToken(token: string, subject: BunnyUploadTokenS
const [encodedPayload, providedSignature] = parts;
if (!encodedPayload || !providedSignature) return false;
const expectedSignature = signPayload(encodedPayload, getBunnyUploadTokenSecret());
const expectedSignature = signPayload(encodedPayload, secret);
const providedBuffer = Buffer.from(providedSignature, 'utf8');
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
+3 -38
View File
@@ -1,4 +1,5 @@
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
import { uploadBytesWithProgress, type UploadProgressHandler } from '@/lib/client/r2-video-upload';
export type R2AssetVideoInitResponse = {
presignedPutUrl: string;
@@ -16,44 +17,8 @@ export type R2AssetVideoUploadResult = R2AssetVideoInitResponse & {
thumbnailUrl: string | null;
};
type UploadProgressHandler = (progress: number) => void;
function uploadBytesWithProgress(
url: string,
body: Blob | File,
contentType: string,
onProgress?: UploadProgressHandler
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', contentType);
xhr.upload.onprogress = (event) => {
if (!onProgress || !event.lengthComputable) return;
onProgress(Math.round((event.loaded / event.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
return;
}
reject(new Error(`Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => {
reject(
new Error(
'Network error during upload. If you use direct S3/R2 uploads, configure bucket CORS to allow PUT from this site origin.'
)
);
};
xhr.onabort = () => reject(new Error('Upload aborted'));
xhr.send(body);
});
}
// uploadBytesWithProgress used to be duplicated here, progress arithmetic included. There
// is one copy now, in r2-video-upload.ts, which is where the multipart path already lives.
export async function initR2AssetVideoUpload(
videoId: string,
+6 -2
View File
@@ -4,6 +4,7 @@ import {
getPartByteRange,
getRetryDelayMs,
getUploadProgressPercent,
isRetryableUploadError,
PART_RETRY_DELAYS_MS,
} from '@/lib/client/upload-chunking';
@@ -33,9 +34,9 @@ export type R2VideoUploadResult = R2VideoInitResponse & {
thumbnailUrl: string | null;
};
type UploadProgressHandler = (progress: number) => void;
export type UploadProgressHandler = (progress: number) => void;
function uploadBytesWithProgress(
export function uploadBytesWithProgress(
url: string,
body: Blob | File,
contentType: string,
@@ -128,6 +129,9 @@ async function withRetry<T>(fn: () => Promise<T>, delays: number[]): Promise<T>
return await fn();
} catch (error) {
lastError = error;
// An abort or a permanent 4xx will fail the same way every time, so repeating it
// only delays the error the caller is waiting for.
if (!isRetryableUploadError(error)) break;
}
}
throw lastError instanceof Error ? lastError : new Error('Upload failed after retries');
+33
View File
@@ -53,6 +53,7 @@ export function getPartByteRange(
/** Whole-percent progress for a single-request upload. */
export function getUploadProgressPercent(loadedBytes: number, totalBytes: number): number {
if (totalBytes <= 0) return 0;
return Math.round((loadedBytes / totalBytes) * 100);
}
@@ -60,11 +61,43 @@ export function getUploadProgressPercent(loadedBytes: number, totalBytes: number
* Whole-percent progress across a multipart upload, given the bytes reported so
* far for each part. Clamped at 100: parts report their own progress
* independently and a re-tried part can briefly double-count.
*
* A total of zero reports 0 rather than dividing. The division produced NaN, which
* reached the UI as "Uploading... NaN%".
*/
export function getMultipartProgressPercent(
loadedBytesPerPart: number[],
totalBytes: number
): number {
if (totalBytes <= 0) return 0;
const loaded = loadedBytesPerPart.reduce((sum, value) => sum + value, 0);
return Math.min(100, Math.round((loaded / totalBytes) * 100));
}
/**
* Whether a failed attempt is worth repeating.
*
* The retry loop used to repeat every rejection, including the user's own cancellation
* and permanently-failing statuses. Cancelling an upload therefore did not cancel it: the
* part sat through the full 2s, 5s and 10s backoff and fired three more PUTs before the
* error surfaced. An expired presigned URL behaved the same way, turning one dead part
* into four requests and 17 seconds of apparent hanging.
*/
export function isRetryableUploadError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
if (/aborted/i.test(message)) return false;
const status = statusFromUploadErrorMessage(message);
if (status === null) return true; // A network error has no status and is worth a retry.
if (status === 408 || status === 429) return true;
return status < 400 || status >= 500;
}
/** The status code an upload error message carries, if it carries one. */
export function statusFromUploadErrorMessage(message: string): number | null {
const match = /failed with status (\d{3})\b/i.exec(message);
if (!match) return null;
const status = Number(match[1]);
return Number.isFinite(status) ? status : null;
}
+8 -1
View File
@@ -42,9 +42,16 @@ export interface ExportCommentRow {
createdAtIso: string;
}
// A leading =, +, - or @ is what a spreadsheet reads as the start of a formula, so those
// cells get an apostrophe. A plain negative number is not a formula, and prefixing one
// stopped the spreadsheet reading a negative timestamp as a number at all.
const FORMULA_START = /^[\s]*[=+\-@]/;
const PLAIN_NUMBER = /^-?\d+(\.\d+)?$/;
function csvCell(value: string | number | boolean | null): string {
const raw = value === null ? '' : String(value);
const neutralized = /^[\s]*[=+\-@]/.test(raw) ? `'${raw}` : raw;
const needsPrefix = FORMULA_START.test(raw) && !PLAIN_NUMBER.test(raw);
const neutralized = needsPrefix ? `'${raw}` : raw;
return `"${neutralized.replace(/"/g, '""')}"`;
}
+9 -3
View File
@@ -35,9 +35,15 @@ function resolveR2ConnectOrigins(): string[] {
origins.add('https://*.r2.cloudflarestorage.com');
}
// Docker/MinIO self-hosted defaults for local development.
origins.add('http://localhost:9000');
origins.add('http://127.0.0.1:9000');
// Docker/MinIO defaults, for local development only. A production build has no reason
// to allow plaintext loopback object storage, and adding it there weakened the policy of
// every deployment to accommodate a developer's machine. A self-hosted install whose
// storage really is on loopback still works: it sets R2_ENDPOINT, which is picked up
// above.
if (process.env.NODE_ENV !== 'production') {
origins.add('http://localhost:9000');
origins.add('http://127.0.0.1:9000');
}
return [...origins];
}
+40 -12
View File
@@ -22,7 +22,29 @@ export function escapeHtml(str: string): string {
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const RAW_EMAIL_HTML = Symbol('rawEmailHtml');
/** Markup a caller has already built and vouches for. See {@link rawEmailHtml}. */
export type RawEmailHtml = { readonly [RAW_EMAIL_HTML]: string };
/**
* Opt a value out of escaping. The helpers below escape everything they are given, so a
* caller that genuinely needs markup, a `<span>` around one half of a label, has to say
* so here. That keeps the default safe: a project name or a display name passed straight
* into a helper is escaped whether or not the caller remembered to.
*/
export function rawEmailHtml(html: string): RawEmailHtml {
return { [RAW_EMAIL_HTML]: html };
}
export type EmailText = string | RawEmailHtml;
function renderEmailText(value: EmailText): string {
return typeof value === 'string' ? escapeHtml(value) : value[RAW_EMAIL_HTML];
}
export function escapeAttr(str: string): string {
@@ -33,14 +55,20 @@ export function escapeAttr(str: string): string {
.replace(/>/g, '&gt;');
}
/**
* `bodyHtml` is markup, not text: it is assembled from the helpers below, so it is the one
* value here that is inserted verbatim. Everything a caller supplies as text, the footer
* included, is escaped.
*/
export function brandedEmailTemplate(
body: string,
bodyHtml: string,
options?: {
footerText?: string;
footerLinkText?: string;
footerLinkUrl?: string;
}
): string {
const body = bodyHtml;
const footerText = options?.footerText || '';
const footerLinkText = options?.footerLinkText || '';
const footerLinkUrl = options?.footerLinkUrl || '';
@@ -67,7 +95,7 @@ export function brandedEmailTemplate(
footerText || (footerLinkText && footerLinkUrl)
? `
<tr><td style="padding:20px 0 0;text-align:center;">
${footerText ? `<p style="margin:0 0 6px;font-size:11px;color:${EMAIL_COLORS.textDim};">${footerText}</p>` : ''}
${footerText ? `<p style="margin:0 0 6px;font-size:11px;color:${EMAIL_COLORS.textDim};">${escapeHtml(footerText)}</p>` : ''}
${footerLinkText && footerLinkUrl ? `<a href="${escapeAttr(footerLinkUrl)}" style="font-size:11px;color:${EMAIL_COLORS.accent};text-decoration:underline;">${escapeHtml(footerLinkText)}</a>` : ''}
</td></tr>`
: ''
@@ -79,26 +107,26 @@ export function brandedEmailTemplate(
</html>`;
}
export function emailHeading(icon: string, title: string): string {
export function emailHeading(icon: EmailText, title: EmailText): string {
return `<td style="padding:16px 20px;border-bottom:1px solid ${EMAIL_COLORS.border};background-color:${EMAIL_COLORS.accentDark};">
<span style="font-size:14px;font-weight:600;color:${EMAIL_COLORS.accent};">${icon} &nbsp;${title}</span>
<span style="font-size:14px;font-weight:600;color:${EMAIL_COLORS.accent};">${renderEmailText(icon)} &nbsp;${renderEmailText(title)}</span>
</td>`;
}
export function emailRow(label: string, value: string, isHighlight = false): string {
export function emailRow(label: EmailText, value: EmailText, isHighlight = false): string {
const valStyle = isHighlight
? `color:${EMAIL_COLORS.text};font-weight:600;`
: `color:${EMAIL_COLORS.textSecondary};`;
return `<tr>
<td style="padding:6px 16px 6px 0;color:${EMAIL_COLORS.textDim};font-size:13px;white-space:nowrap;vertical-align:top;">${label}</td>
<td style="padding:6px 0;font-size:13px;${valStyle}">${value}</td>
<td style="padding:6px 16px 6px 0;color:${EMAIL_COLORS.textDim};font-size:13px;white-space:nowrap;vertical-align:top;">${renderEmailText(label)}</td>
<td style="padding:6px 0;font-size:13px;${valStyle}">${renderEmailText(value)}</td>
</tr>`;
}
export function emailButton(text: string, url: string): string {
return `<a href="${escapeAttr(url)}" style="display:inline-block;padding:9px 22px;background-color:${EMAIL_COLORS.accent};color:#0f1114;font-size:13px;font-weight:700;text-decoration:none;letter-spacing:0.2px;">${text}</a>`;
export function emailButton(text: EmailText, url: string): string {
return `<a href="${escapeAttr(url)}" style="display:inline-block;padding:9px 22px;background-color:${EMAIL_COLORS.accent};color:#0f1114;font-size:13px;font-weight:700;text-decoration:none;letter-spacing:0.2px;">${renderEmailText(text)}</a>`;
}
export function emailHighlight(text: string): string {
return `<div style="border:1px solid ${EMAIL_COLORS.border};padding:10px 12px;margin:0 0 16px;background-color:${EMAIL_COLORS.cardInner};color:${EMAIL_COLORS.text};font-size:13px;line-height:1.5;">${text}</div>`;
export function emailHighlight(text: EmailText): string {
return `<div style="border:1px solid ${EMAIL_COLORS.border};padding:10px 12px;margin:0 0 16px;background-color:${EMAIL_COLORS.cardInner};color:${EMAIL_COLORS.text};font-size:13px;line-height:1.5;">${renderEmailText(text)}</div>`;
}
+2 -3
View File
@@ -6,7 +6,6 @@ import {
emailButton,
emailHeading,
emailRow,
escapeHtml,
EMAIL_COLORS,
} from '@/lib/email-brand';
import { logError } from '@/lib/logger';
@@ -124,14 +123,14 @@ export async function sendVerificationEmail(
<tr>${emailHeading('✉', 'Verify your email address')}</tr>
<tr><td style="padding:20px;">
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
${emailRow('Account', escapeHtml(email), true)}
${emailRow('Account', email, true)}
${emailRow('Expires in', `${TOKEN_EXPIRY_HOURS} hours`)}
</table>
<p style="margin:0 0 20px;font-size:14px;color:${EMAIL_COLORS.textSecondary};line-height:1.6;">
Click the button below to verify your email address and activate your OpenFrame account.
If you did not create an account, you can safely ignore this email.
</p>
${emailButton('Verify Email Address &#8594;', verifyUrl)}
${emailButton('Verify Email Address ', verifyUrl)}
</td></tr>
`,
{
+41 -34
View File
@@ -15,7 +15,6 @@ import {
emailHeading,
emailHighlight,
emailRow,
escapeHtml,
} from '@/lib/email-brand';
import { logError } from '@/lib/logger';
@@ -101,17 +100,17 @@ function invitationEmailTemplate(input: {
}): string {
return brandedEmailTemplate(
`
<tr>${emailHeading('&#10003;', `${escapeHtml(input.scope.charAt(0).toUpperCase() + input.scope.slice(1))} Invitation`)}</tr>
<tr>${emailHeading('', `${input.scope.charAt(0).toUpperCase() + input.scope.slice(1)} Invitation`)}</tr>
<tr><td style="padding:20px;">
${emailHighlight('You were invited to join OpenFrame.')}
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Invited by', escapeHtml(input.inviterName), true)}
${emailRow('Target', `${escapeHtml(input.targetName)} (${escapeHtml(input.scope)})`, true)}
${emailRow('Role', escapeHtml(input.role))}
${emailRow('Invited by', input.inviterName, true)}
${emailRow('Target', `${input.targetName} (${input.scope})`, true)}
${emailRow('Role', input.role)}
${emailRow('Expires', `${INVITATION_TTL_DAYS} days`)}
</table>
${emailHighlight('Create an account (or sign in with this email) to accept this invitation.')}
${emailButton('Accept Invitation &#8594;', input.invitationUrl)}
${emailButton('Accept Invitation ', input.invitationUrl)}
</td></tr>
`,
{
@@ -300,6 +299,18 @@ async function acceptInvitation(tx: Prisma.TransactionClient, invitationId: stri
});
}
/**
* Applies the invited membership and marks the invitation accepted.
*
* Returns false when the invitation grants nothing: a scoped row whose target id is null,
* or one pointing at a workspace or project that no longer exists. The invitation is left
* PENDING in that case, so the caller can report the failure rather than show a success
* screen for a no-op the user has no way to detect.
*
* An existing membership is never downgraded. Applying the invited role unconditionally
* turned an invitation into a privilege-change primitive: re-invite a sitting ADMIN as a
* COMMENTATOR, get them to click the link once, and they are demoted.
*/
async function applyInvitationMembership(
tx: Prisma.TransactionClient,
invitation: {
@@ -310,13 +321,17 @@ async function applyInvitationMembership(
projectId: string | null;
},
userId: string
) {
if (invitation.scope === InvitationScope.WORKSPACE && invitation.workspaceId) {
): Promise<boolean> {
const invitedAsAdmin = invitation.role === InvitationRole.ADMIN;
if (invitation.scope === InvitationScope.WORKSPACE) {
if (!invitation.workspaceId) return false;
const workspace = await tx.workspace.findUnique({
where: { id: invitation.workspaceId },
select: { ownerId: true },
});
if (!workspace) return;
if (!workspace) return false;
if (workspace.ownerId !== userId) {
await tx.workspaceMember.upsert({
@@ -326,33 +341,28 @@ async function applyInvitationMembership(
userId,
},
},
update: {
role:
invitation.role === InvitationRole.ADMIN
? WorkspaceMemberRole.ADMIN
: WorkspaceMemberRole.COMMENTATOR,
},
// Only ever a promotion. An empty update leaves a sitting ADMIN as they were.
update: invitedAsAdmin ? { role: WorkspaceMemberRole.ADMIN } : {},
create: {
workspaceId: invitation.workspaceId,
userId,
role:
invitation.role === InvitationRole.ADMIN
? WorkspaceMemberRole.ADMIN
: WorkspaceMemberRole.COMMENTATOR,
role: invitedAsAdmin ? WorkspaceMemberRole.ADMIN : WorkspaceMemberRole.COMMENTATOR,
},
});
}
await acceptInvitation(tx, invitation.id);
return;
return true;
}
if (invitation.scope === InvitationScope.PROJECT && invitation.projectId) {
if (invitation.scope === InvitationScope.PROJECT) {
if (!invitation.projectId) return false;
const project = await tx.project.findUnique({
where: { id: invitation.projectId },
select: { ownerId: true },
});
if (!project) return;
if (!project) return false;
if (project.ownerId !== userId) {
await tx.projectMember.upsert({
@@ -362,25 +372,20 @@ async function applyInvitationMembership(
userId,
},
},
update: {
role:
invitation.role === InvitationRole.ADMIN
? ProjectMemberRole.ADMIN
: ProjectMemberRole.COMMENTATOR,
},
update: invitedAsAdmin ? { role: ProjectMemberRole.ADMIN } : {},
create: {
projectId: invitation.projectId,
userId,
role:
invitation.role === InvitationRole.ADMIN
? ProjectMemberRole.ADMIN
: ProjectMemberRole.COMMENTATOR,
role: invitedAsAdmin ? ProjectMemberRole.ADMIN : ProjectMemberRole.COMMENTATOR,
},
});
}
await acceptInvitation(tx, invitation.id);
return true;
}
return false;
}
export async function acceptInvitationTokenForUser(input: {
@@ -407,8 +412,10 @@ export async function acceptInvitationTokenForUser(input: {
return 'expired';
}
await applyInvitationMembership(tx, invitation, input.userId);
return 'accepted';
const applied = await applyInvitationMembership(tx, invitation, input.userId);
// A scoped invitation pointing at nothing grants no membership. Reporting 'accepted'
// for it showed a success screen for a no-op and left the row PENDING for good.
return applied ? 'accepted' : 'not_found';
});
}
+20 -2
View File
@@ -23,12 +23,18 @@ function sanitizeError(err: unknown): SanitizedError | unknown {
return err;
}
const name = err.constructor?.name ?? err.name ?? 'Error';
const constructorName = err.constructor?.name;
const name = constructorName || err.name || 'Error';
const anyErr = err as unknown as Record<string, unknown>;
// Prisma client errors: their `.message` can embed raw SQL, WHERE-clause
// values, and schema internals. Only safe to expose the Prisma error code.
if (name.startsWith('PrismaClient')) {
//
// Both names are checked. An Error instance always has a constructor, so keying on
// `constructor.name` alone would silently stop redacting for an error that identifies
// itself only through `name`: one that was re-thrown or deserialised and lost its
// prototype, or a production build whose minifier renamed the class.
if (name.startsWith('PrismaClient') || err.name.startsWith('PrismaClient')) {
const code = typeof anyErr.code === 'string' ? anyErr.code : 'UNKNOWN';
return {
type: 'PrismaError',
@@ -59,3 +65,15 @@ function sanitizeError(err: unknown): SanitizedError | unknown {
export function logError(context: string, err: unknown): void {
console.error(context, sanitizeError(err));
}
/**
* Log a configuration or operational warning. Same sanitisation as {@link logError} for
* the optional detail, so a warning cannot become the leak the error path guards against.
*/
export function logWarn(context: string, detail?: unknown): void {
if (detail === undefined) {
console.warn(context);
return;
}
console.warn(context, sanitizeError(detail));
}
+48 -47
View File
@@ -8,6 +8,7 @@ import {
emailHighlight,
emailRow,
escapeHtml,
rawEmailHtml,
} from '@/lib/email-brand';
import { logError } from '@/lib/logger';
@@ -311,15 +312,15 @@ function formatEmail(
return {
subject: `[OpenFrame] New video in ${event.projectName}: ${event.videoTitle}`,
html: emailTemplate(`
<tr>${emailHeading('&#9654;', 'New Video Added')}</tr>
<tr>${emailHeading('', 'New Video Added')}</tr>
<tr><td style="padding:20px;">
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('Added by', escapeHtml(event.addedBy))}
${emailRow('Project', event.projectName, true)}
${emailRow('Video', event.videoTitle, true)}
${emailRow('Added by', event.addedBy)}
${emailRow('When', now)}
</table>
${emailButton('View Video &#8594;', event.url)}
${emailButton('View Video ', event.url)}
</td></tr>
`),
};
@@ -327,16 +328,16 @@ function formatEmail(
return {
subject: `[OpenFrame] New version of ${event.videoTitle} in ${event.projectName}`,
html: emailTemplate(`
<tr>${emailHeading('&#9654;', 'New Version Added')}</tr>
<tr>${emailHeading('', 'New Version Added')}</tr>
<tr><td style="padding:20px;">
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('Version', escapeHtml(event.versionLabel))}
${emailRow('Added by', escapeHtml(event.addedBy))}
${emailRow('Project', event.projectName, true)}
${emailRow('Video', event.videoTitle, true)}
${emailRow('Version', event.versionLabel)}
${emailRow('Added by', event.addedBy)}
${emailRow('When', now)}
</table>
${emailButton('View Version &#8594;', event.url)}
${emailButton('View Version ', event.url)}
</td></tr>
`),
};
@@ -344,19 +345,19 @@ function formatEmail(
return {
subject: `[OpenFrame] New comment on ${event.videoTitle}`,
html: emailTemplate(`
<tr>${emailHeading('&#9679;', 'New Comment')}</tr>
<tr>${emailHeading('', 'New Comment')}</tr>
<tr><td style="padding:20px;">
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('From', escapeHtml(event.commentAuthor))}
${emailRow('Project', event.projectName, true)}
${emailRow('Video', event.videoTitle, true)}
${emailRow('From', event.commentAuthor)}
${emailRow('At', event.timestamp)}
${emailRow('When', now)}
</table>
<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">
${escapeHtml(truncate(event.commentText, 300))}
</div>
${emailButton('View Comment &#8594;', event.url)}
${emailButton('View Comment ', event.url)}
</td></tr>
`),
};
@@ -364,18 +365,18 @@ function formatEmail(
return {
subject: `[OpenFrame] ${event.replyAuthor} replied on ${event.videoTitle}`,
html: emailTemplate(`
<tr>${emailHeading('&#8617;', 'New Reply')}</tr>
<tr>${emailHeading('', 'New Reply')}</tr>
<tr><td style="padding:20px;">
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('From', `<span style="color:${EMAIL_COLORS.text};font-weight:500;">${escapeHtml(event.replyAuthor)}</span> <span style="color:${EMAIL_COLORS.textDim};">&#8594;</span> ${escapeHtml(event.parentAuthor)}`)}
${emailRow('Project', event.projectName, true)}
${emailRow('Video', event.videoTitle, true)}
${emailRow('From', rawEmailHtml(`<span style="color:${EMAIL_COLORS.text};font-weight:500;">${escapeHtml(event.replyAuthor)}</span> <span style="color:${EMAIL_COLORS.textDim};">&#8594;</span> ${escapeHtml(event.parentAuthor)}`))}
${emailRow('When', now)}
</table>
<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">
${escapeHtml(truncate(event.replyText, 300))}
</div>
${emailButton('View Reply &#8594;', event.url)}
${emailButton('View Reply ', event.url)}
</td></tr>
`),
};
@@ -383,18 +384,18 @@ function formatEmail(
return {
subject: `[OpenFrame] Approval requested for ${event.versionLabel} in ${event.projectName}`,
html: emailTemplate(`
<tr>${emailHeading('&#10003;', 'Approval Requested')}</tr>
<tr>${emailHeading('', 'Approval Requested')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`A new approval request is waiting for your response.`)}
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('Version', escapeHtml(event.versionLabel))}
${emailRow('Requested by', escapeHtml(event.requestedBy))}
${emailRow('Project', event.projectName, true)}
${emailRow('Video', event.videoTitle, true)}
${emailRow('Version', event.versionLabel)}
${emailRow('Requested by', event.requestedBy)}
${emailRow('When', now)}
</table>
${event.message ? `<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">${escapeHtml(truncate(event.message, 300))}</div>` : ''}
${emailButton('Review Request &#8594;', event.url)}
${emailButton('Review Request ', event.url)}
</td></tr>
`),
};
@@ -402,18 +403,18 @@ function formatEmail(
return {
subject: `[OpenFrame] Approval ${event.action} by ${event.actorName}`,
html: emailTemplate(`
<tr>${emailHeading('&#10003;', 'Approval Update')}</tr>
<tr>${emailHeading('', 'Approval Update')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`${escapeHtml(event.actorName)} ${escapeHtml(event.action)} this request.`)}
${emailHighlight(`${event.actorName} ${event.action} this request.`)}
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('Version', escapeHtml(event.versionLabel))}
${emailRow('Action', escapeHtml(`${event.actorName} ${event.action}`))}
${emailRow('Project', event.projectName, true)}
${emailRow('Video', event.videoTitle, true)}
${emailRow('Version', event.versionLabel)}
${emailRow('Action', `${event.actorName} ${event.action}`)}
${emailRow('When', now)}
</table>
${event.note ? `<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">${escapeHtml(truncate(event.note, 300))}</div>` : ''}
${emailButton('Open Request &#8594;', event.url)}
${emailButton('Open Request ', event.url)}
</td></tr>
`),
};
@@ -421,17 +422,17 @@ function formatEmail(
return {
subject: `[OpenFrame] Approval completed for ${event.versionLabel}`,
html: emailTemplate(`
<tr>${emailHeading('&#10003;', 'Approval Completed')}</tr>
<tr>${emailHeading('', 'Approval Completed')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`All approvers accepted this request.`)}
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('Version', escapeHtml(event.versionLabel))}
${emailRow('Project', event.projectName, true)}
${emailRow('Video', event.videoTitle, true)}
${emailRow('Version', event.versionLabel)}
${emailRow('Approvals', String(event.approvedByCount))}
${emailRow('When', now)}
</table>
${emailButton('Open Version &#8594;', event.url)}
${emailButton('Open Version ', event.url)}
</td></tr>
`),
};
@@ -439,18 +440,18 @@ function formatEmail(
return {
subject: `[OpenFrame] Approval rejected by ${event.rejectedBy}`,
html: emailTemplate(`
<tr>${emailHeading('&#9940;', 'Approval Rejected')}</tr>
<tr>${emailHeading('', 'Approval Rejected')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`${escapeHtml(event.rejectedBy)} rejected this request.`)}
${emailHighlight(`${event.rejectedBy} rejected this request.`)}
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
${emailRow('Project', escapeHtml(event.projectName), true)}
${emailRow('Video', escapeHtml(event.videoTitle), true)}
${emailRow('Version', escapeHtml(event.versionLabel))}
${emailRow('Rejected by', escapeHtml(event.rejectedBy))}
${emailRow('Project', event.projectName, true)}
${emailRow('Video', event.videoTitle, true)}
${emailRow('Version', event.versionLabel)}
${emailRow('Rejected by', event.rejectedBy)}
${emailRow('When', now)}
</table>
${event.note ? `<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">${escapeHtml(truncate(event.note, 300))}</div>` : ''}
${emailButton('Open Request &#8594;', event.url)}
${emailButton('Open Request ', event.url)}
</td></tr>
`),
};
@@ -462,7 +463,7 @@ function formatEmail(
*/
export function testEmailHtml(): string {
return emailTemplate(`
<tr>${emailHeading('&#10003;', 'Test Notification')}</tr>
<tr>${emailHeading('', 'Test Notification')}</tr>
<tr><td style="padding:20px;">
<p style="margin:0 0 8px;font-size:14px;color:${EMAIL_COLORS.text};">Email notifications are working.</p>
<p style="margin:0;font-size:13px;color:${EMAIL_COLORS.textSecondary};">You&rsquo;ll receive emails when there&rsquo;s activity on your projects.</p>
+23 -11
View File
@@ -86,10 +86,19 @@ function getSafeDirectDownloadUrl(rawUrl: string): string | null {
}
}
// A file extension is appended after sanitizeFileName() has run, so it has to be safe on
// its own: anything that is not a short alphanumeric run falls back. Slicing from the last
// dot of a whole URL would otherwise let `https://example.com/download` contribute
// `.com/download`, a path separator inside an archive entry name.
const SAFE_EXTENSION = /^[a-z0-9]{1,10}$/i;
function extensionFromUrl(url: string, fallback: string): string {
const withoutQuery = url.split('?')[0] ?? url;
const ext = withoutQuery.includes('.') ? withoutQuery.slice(withoutQuery.lastIndexOf('.')) : '';
return ext || fallback;
const baseName = withoutQuery.slice(withoutQuery.lastIndexOf('/') + 1);
const dotIndex = baseName.lastIndexOf('.');
if (dotIndex <= 0) return fallback;
const ext = baseName.slice(dotIndex + 1);
return SAFE_EXTENSION.test(ext) ? `.${ext.toLowerCase()}` : fallback;
}
type VersionRow = {
@@ -162,18 +171,15 @@ function buildAssetFileName(videoIndex: number, videoTitle: string, asset: Asset
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
const fileName = extractImageFileNameFromProxyUrl(asset.sourceUrl);
const ext = fileName?.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.png';
return `${stem}${ext}`;
return `${stem}${extensionFromUrl(fileName ?? '', '.png')}`;
}
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl);
const ext = fileName?.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.webm';
return `${stem}${ext}`;
return `${stem}${extensionFromUrl(fileName ?? '', '.webm')}`;
}
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl);
const ext = fileName?.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.mp4';
return `${stem}${ext}`;
return `${stem}${extensionFromUrl(fileName ?? '', '.mp4')}`;
}
if (asset.provider === VideoAssetProvider.BUNNY) {
return `${stem}.mp4`;
@@ -189,9 +195,10 @@ function versionDownloadUrl(version: VersionRow): string | null {
return `/api/versions/${version.id}/download?source=original`;
}
if (version.providerId === 'r2') {
if (version.originalUrl.startsWith('/api/upload/video/')) {
return version.originalUrl;
}
// Only the strict proxy-path shape is accepted. A `startsWith` check here would let
// `/api/upload/video/clip.mp4/../../../../etc/passwd` through as a download URL.
// Every r2 version is written through finalizeR2VideoUpload(), which stores exactly
// this shape, so nothing legitimate is lost.
const fileName = extractVideoFileNameFromProxyUrl(version.originalUrl);
if (fileName) return `/api/upload/video/${fileName}`;
}
@@ -294,6 +301,11 @@ export function validateProjectDownloadManifest(manifest: ProjectDownloadManifes
}
if (manifest.totalBytes) {
// The contract is to return a message, so a malformed total has to become one rather
// than a SyntaxError escaping into the route as a 500.
if (!/^\d+$/.test(manifest.totalBytes)) {
return 'Could not determine the size of this download';
}
const knownTotal = BigInt(manifest.totalBytes);
if (knownTotal > maxBytes) {
const maxGiB = Number(maxBytes / BigInt(1024 * 1024 * 1024));
+15
View File
@@ -19,6 +19,17 @@ type ProxyR2MediaOptions = {
internalErrorMessage: string;
};
// Every key this proxy is ever asked for is a prefix plus a stored uuid file name. The
// guard lives here rather than in each caller so it travels with the function: all three
// call sites gate the file name on a strict pattern first, and a fourth that forgot would
// otherwise hand a traversal straight to GetObject.
const SAFE_MEDIA_OBJECT_KEY =
/^(?:images|voice|videos)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export function isSafeR2MediaKey(key: string): boolean {
return SAFE_MEDIA_OBJECT_KEY.test(key);
}
type R2LikeError = {
name?: string;
Code?: string;
@@ -92,6 +103,10 @@ export async function proxyR2MediaObject({
notFoundLabel = 'File',
internalErrorMessage,
}: ProxyR2MediaOptions): Promise<NextResponse> {
if (!isSafeR2MediaKey(key)) {
return apiErrors.badRequest('Invalid media key');
}
const range = request.headers.get('range');
const ifRange = request.headers.get('if-range');
const commandInput: GetObjectCommandInput = {
+10 -1
View File
@@ -32,12 +32,21 @@ export async function createR2UploadSession(input: CreateR2UploadSessionInput) {
});
}
/**
* Cancels an initiated session whether or not it has expired.
*
* The expiry condition that used to be here made the update match zero rows once a
* session lapsed, so the status stayed INITIATED and `consumedAt` stayed null for good.
* The r2-init DELETE route releases the quota reservation only when the update reports a
* row, so every abandoned upload held its reserved bytes against the user's quota
* permanently, and no sweeper reclaims them. Cancelling something already expired is the
* case that most needs to work.
*/
export async function cancelR2UploadSession(sessionId: string) {
return db.videoUploadSession.updateMany({
where: {
id: sessionId,
status: 'INITIATED',
expiresAt: { gt: new Date() },
},
data: {
status: 'CANCELLED',
+7 -1
View File
@@ -100,6 +100,12 @@ export function verifyR2UploadToken(token: string, subject: R2UploadTokenSubject
}
export function parseR2UploadToken(token: string): R2UploadTokenPayload | null {
// Resolved before the try. A server booted with neither R2_UPLOAD_TOKEN_SECRET nor
// NEXTAUTH_SECRET set is misconfigured, and swallowing that throw made it answer
// "invalid token" for every upload grant: a total upload outage that looks like a
// client bug and says nothing about why.
const secret = getR2UploadTokenSecret();
try {
const parts = token.split('.');
if (parts.length !== 2) return null;
@@ -107,7 +113,7 @@ export function parseR2UploadToken(token: string): R2UploadTokenPayload | null {
const [encodedPayload, providedSignature] = parts;
if (!encodedPayload || !providedSignature) return null;
const expectedSignature = signPayload(encodedPayload, getR2UploadTokenSecret());
const expectedSignature = signPayload(encodedPayload, secret);
const providedBuffer = Buffer.from(providedSignature, 'utf8');
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
+43 -12
View File
@@ -12,11 +12,13 @@ import {
PutObjectCommand,
UploadPartCommand,
S3Client,
type CORSRule,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { VIDEO_OBJECT_KEY_PREFIX } from '@/lib/video-upload-validation';
const IMAGE_OBJECT_KEY_PREFIX = 'images/';
const AUDIO_OBJECT_KEY_PREFIX = 'voice/';
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID;
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID;
@@ -104,12 +106,16 @@ export const r2Client = new Proxy({} as S3Client, {
get(_target, prop, receiver) {
if (prop === 'destroy') {
return () => {
if (!cachedR2Client) return;
cachedR2Client.destroy();
cachedR2Client = null;
if (!cachedR2PresignClient) return;
cachedR2PresignClient.destroy();
cachedR2PresignClient = null;
// Both are destroyed independently. Returning early when the send client was
// never created leaked the presign client in a process that only ever presigned.
if (cachedR2Client) {
cachedR2Client.destroy();
cachedR2Client = null;
}
if (cachedR2PresignClient) {
cachedR2PresignClient.destroy();
cachedR2PresignClient = null;
}
};
}
@@ -234,13 +240,22 @@ export async function ensureR2UploadCors(extraOrigins: string[] = []): Promise<s
MaxAgeSeconds: 3600,
};
// The catch covers the read only. Wrapping the write in it too meant a transient write
// failure was mistaken for "this bucket has no CORS config", and the retry below then
// sent the managed rule on its own, discarding whatever the bucket already had.
let existingRules: CORSRule[] | null = null;
try {
const existing = await r2Client.send(
new GetBucketCorsCommand({
Bucket: R2_BUCKET_NAME,
})
);
const existingRules = existing.CORSRules ?? [];
existingRules = existing.CORSRules ?? [];
} catch {
// No CORS config yet, or insufficient permissions to read — write the managed rule.
}
if (existingRules) {
if (existingRules.some((rule) => corsRulesMatchOrigins(rule, allowedOrigins))) {
return allowedOrigins;
}
@@ -254,8 +269,6 @@ export async function ensureR2UploadCors(extraOrigins: string[] = []): Promise<s
})
);
return allowedOrigins;
} catch {
// No CORS config yet, or insufficient permissions to read — attempt to write.
}
await r2Client.send(
@@ -291,7 +304,13 @@ export async function createPresignedVideoPutUrl(
ContentLength: Number(contentLength),
});
return getSignedUrl(getOrCreateR2PresignClient(), command, { expiresIn: expiresInSeconds });
return getSignedUrl(getOrCreateR2PresignClient(), command, {
expiresIn: expiresInSeconds,
// Passing ContentType to the command is not enough: unless the header is signable the
// grant does not bind it, and whoever holds the url can put any media type at the key.
// The client sends the same value back, so the signature covers what actually lands.
signableHeaders: new Set(['content-type']),
});
}
export async function createMultipartVideoUpload(
@@ -397,7 +416,12 @@ export async function createPresignedImagePutUrl(
ContentType: contentType,
});
return getSignedUrl(getOrCreateR2PresignClient(), command, { expiresIn: expiresInSeconds });
return getSignedUrl(getOrCreateR2PresignClient(), command, {
expiresIn: expiresInSeconds,
// Without this the grant binds only the host, so an image upload url accepts any
// media type at an `images/` key the app then serves as an image.
signableHeaders: new Set(['content-type']),
});
}
export async function headVideoObject(key: string): Promise<{
@@ -464,7 +488,14 @@ export async function readVideoObjectBytes(
}
function assertAllowedObjectKey(key: string): void {
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX) && !key.startsWith(IMAGE_OBJECT_KEY_PREFIX)) {
// `voice/` belongs here because uploadAudio() writes under it. Leaving it out meant
// deleteR2Object('voice/...') always threw, so a voice note attached to a comment could
// never be removed by the module that stored it and outlived the comment in the bucket.
if (
!key.startsWith(VIDEO_OBJECT_KEY_PREFIX) &&
!key.startsWith(IMAGE_OBJECT_KEY_PREFIX) &&
!key.startsWith(AUDIO_OBJECT_KEY_PREFIX)
) {
throw new Error('Invalid object key');
}
}
+37 -8
View File
@@ -1,6 +1,7 @@
import { createHash } from 'crypto';
import { db } from '@/lib/db';
import { NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { logError, logWarn } from '@/lib/logger';
const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
@@ -33,6 +34,19 @@ if (process.env.NODE_ENV === 'production' && isRateLimitDisabled()) {
);
}
// Without a proxy mode every caller resolves to 127.0.0.1, so the limiter counts the whole
// world in one bucket. That is the deliberate trade-off (trusting a spoofable header is
// worse), but a deployment behind a proxy should know it is running with global rather
// than per-client limits rather than discover it under load.
if (process.env.NODE_ENV === 'production' && !process.env.TRUSTED_PROXY_MODE?.trim()) {
logWarn(
'TRUSTED_PROXY_MODE is not set. Every request resolves to 127.0.0.1, so rate limits ' +
'apply per process rather than per client. Set TRUSTED_PROXY_MODE=cloudflare or ' +
'TRUSTED_PROXY_MODE=nginx once you have confirmed your proxy overwrites the ' +
'corresponding header on every inbound request.'
);
}
// Industry-standard rate limit defaults per action
export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
// Auth — strict to prevent brute force / credential stuffing
@@ -95,6 +109,21 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
};
// Column widths of rate_limits.key and rate_limits.action in prisma/schema.prisma. A value
// wider than its column would fail the INSERT with SQLSTATE 22001.
const RATE_LIMIT_KEY_MAX_LENGTH = 255;
const RATE_LIMIT_ACTION_MAX_LENGTH = 50;
/**
* Fits a value to its column without ever giving up on counting it. A SHA-256 hex digest
* is 64 characters, so it is truncated for the narrower action column; 50 hex characters
* is 200 bits, far past any collision that matters for a rate limit bucket.
*/
function fitToColumn(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
return createHash('sha256').update(value).digest('hex').slice(0, maxLength);
}
/**
* Check and update rate limit for a given key and action
* Uses PostgreSQL UNLOGGED table for performance
@@ -116,12 +145,12 @@ export async function checkRateLimit(
const windowSeconds = Math.floor(windowMs / 1000);
// Validate inputs before passing to query — defence in depth.
// Prisma's tagged template $queryRaw already parameterizes these values,
// but we enforce sane bounds to reject obviously malicious input.
if (key.length > 256 || action.length > 64) {
return { allowed: true, remaining: maxRequests, resetAt: new Date(Date.now() + windowMs) };
}
// Anything wider than its column is replaced by a digest rather than skipped. Skipping
// meant the limit stopped applying altogether, and letting the value through meant the
// INSERT failed with SQLSTATE 22001 and the catch below allowed the request anyway.
// Both were fail-open. A digest is stable, so the same caller keeps the same bucket.
const storedKey = fitToColumn(key, RATE_LIMIT_KEY_MAX_LENGTH);
const storedAction = fitToColumn(action, RATE_LIMIT_ACTION_MAX_LENGTH);
try {
// Atomic upsert with window check
@@ -134,7 +163,7 @@ export async function checkRateLimit(
}>
>`
INSERT INTO rate_limits (key, action, count, window_start)
VALUES (${key}, ${action}, 1, NOW())
VALUES (${storedKey}, ${storedAction}, 1, NOW())
ON CONFLICT (key, action) DO UPDATE SET
count = CASE
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
+17 -6
View File
@@ -9,15 +9,19 @@ const LOGIN_REDIRECT = '/login';
const FORBIDDEN_REDIRECT = '/dashboard';
const BILLING_REDIRECT = '/settings';
function redirectForMissingAuth() {
// `never` rather than `void`, so a caller that puts a second redirect after one of these
// gets a compile error instead of silently unreachable code. `redirect()` throws, and
// these are authorization decisions: a helper that ever returned would let the branch
// below it run.
function redirectForMissingAuth(): never {
redirect(LOGIN_REDIRECT);
}
function redirectForForbidden() {
function redirectForForbidden(): never {
redirect(FORBIDDEN_REDIRECT);
}
function redirectForBilling() {
function redirectForBilling(): never {
redirect(BILLING_REDIRECT);
}
@@ -46,7 +50,7 @@ async function assertProjectAccessOrRedirect(
ensureGuestPolicy({ userId, intent, allowPublicView });
const access = await checkProjectAccess(project, userId, { intent });
const access = await checkProjectAccess(project, userId);
if (!access.hasAccess) {
if (!userId) {
@@ -162,15 +166,22 @@ export async function requireWorkspaceAccessOrRedirect(options: {
const access = await checkWorkspaceAccess(workspace, resolvedUserId);
// Only the owner is sent to billing. Keying this off the owner's billing status alone
// made the redirect target an oracle: a signed-in stranger probing workspace ids landed
// on /dashboard when the owner was paying and on /settings when the owner had lapsed,
// which reads off whose subscription is in arrears. It also sent a member whose owner
// had lapsed to their own billing page, where nothing they can do resolves it.
const ownerWithLapsedBilling = access.isOwner && !access.ownerBillingActive;
if (!access.hasAccess) {
if (!access.ownerBillingActive) {
if (ownerWithLapsedBilling) {
redirectForBilling();
}
redirectForForbidden();
}
if (intent === 'manage' && !access.canEdit) {
if (!access.ownerBillingActive) {
if (ownerWithLapsedBilling) {
redirectForBilling();
}
redirectForForbidden();
+10 -1
View File
@@ -54,8 +54,17 @@ export function validateAnnotationStrokes(
}
if (typeof color !== 'string' || !ANNOTATION_COLOR_RE.test(color)) return null;
if (typeof width !== 'number' || width < MIN_STROKE_WIDTH || width > MAX_STROKE_WIDTH)
// isFinite as well as the bounds: both comparisons are false for NaN, so a NaN width
// cleared the range check and reached the stored annotation JSON, where
// JSON.stringify renders it as null. Coordinates already had this guard.
if (
typeof width !== 'number' ||
!isFinite(width) ||
width < MIN_STROKE_WIDTH ||
width > MAX_STROKE_WIDTH
) {
return null;
}
result.push({ points: safePoints, color, width });
}
+11 -12
View File
@@ -38,6 +38,13 @@ export type VideoAssetAccessContext = {
};
};
hasViewAccess: boolean;
/**
* Whether the viewer has any relationship to the project: owner, project member,
* workspace member, or a valid share link. Distinguishes "you may not" from "there is
* no such thing", so a route can answer 404 for another tenant's id without answering
* 404 to somebody whose access merely lapsed.
*/
viewerBelongsToProject: boolean;
canUploadAssets: boolean;
canDownloadAssets: boolean;
canManageAssets: boolean;
@@ -98,18 +105,6 @@ export function extractVideoFileNameFromProxyUrl(url: string): string | null {
return filename || null;
}
export function mediaUrlToR2Key(url: string): string | null {
if (url.includes(IMAGE_PROXY_PREFIX)) {
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
return filename ? `images/${filename}` : null;
}
if (url.includes(AUDIO_PROXY_PREFIX)) {
const filename = url.slice(url.indexOf(AUDIO_PROXY_PREFIX) + AUDIO_PROXY_PREFIX.length);
return filename ? `voice/${filename}` : null;
}
return null;
}
export function canDeleteAssetForViewer(
asset: Pick<VideoAsset, 'uploadedByUserId' | 'uploadedByGuestIdentityId'>,
viewer: Pick<
@@ -194,9 +189,13 @@ export async function getVideoAssetAccessContext(
const viewerUserId = session?.user?.id ?? null;
const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request);
const viewerBelongsToProject =
access.isOwner || access.isProjectMember || access.isWorkspaceMember || shareAccess.hasAccess;
return {
video,
hasViewAccess,
viewerBelongsToProject,
canUploadAssets,
canDownloadAssets,
canManageAssets: access.canEdit,
+40 -14
View File
@@ -9,16 +9,32 @@ type BunnyRef = {
videoId: string;
};
type CleanupInput = {
bunny: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>>;
r2: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>>;
};
/**
* Storage refused at least one delete, so the rows were left in place and nothing was
* removed. Carries the cleanup detail so the route can log which keys failed.
*/
export class VideoStorageCleanupError extends Error {
readonly cleanupInput: CleanupInput;
constructor(cleanupInput: CleanupInput) {
super('STORAGE_CLEANUP_FAILED');
this.name = 'VideoStorageCleanupError';
this.cleanupInput = cleanupInput;
}
}
export async function deleteProjectVideosWithCleanup(
projectId: string,
videoIds: string[]
): Promise<{
deletedCount: number;
cleanupWarnings: CleanupWarnings | undefined;
cleanupInput: {
bunny: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>>;
r2: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>>;
};
cleanupInput: CleanupInput;
}> {
const uniqueVideoIds = [...new Set(videoIds)];
if (uniqueVideoIds.length === 0) {
@@ -66,15 +82,11 @@ export async function deleteProjectVideosWithCleanup(
);
}
await db.video.deleteMany({
where: {
projectId,
id: { in: uniqueVideoIds },
},
});
revalidatePath(`/projects/${projectId}`);
// Storage first, rows second. The other order committed the deleteMany before the R2 and
// Bunny calls ran, with nothing spanning the two, so a refused storage DELETE left the
// object in the bucket with no row pointing at it and no way to retry: the video id no
// longer resolved to anything. Leaving the rows in place instead keeps the delete
// repeatable, and a second attempt cleans up whatever the first one could not.
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
deleteMediaFilesBestEffort(mediaUrls),
@@ -85,9 +97,23 @@ export async function deleteProjectVideosWithCleanup(
r2: r2CleanupResult,
};
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
if (cleanupWarnings) {
throw new VideoStorageCleanupError(cleanupInput);
}
await db.video.deleteMany({
where: {
projectId,
id: { in: uniqueVideoIds },
},
});
revalidatePath(`/projects/${projectId}`);
return {
deletedCount: videos.length,
cleanupWarnings: buildCleanupWarnings(cleanupInput),
cleanupWarnings,
cleanupInput,
};
}
+6 -5
View File
@@ -39,12 +39,13 @@ export const directProvider: VideoProvider = {
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
// For direct videos, we'll use HTML5 video player
// The videoId IS the URL for direct uploads
const params = new URLSearchParams();
// A direct video is played by the HTML5 element, which reads the start time from the
// media fragment. The URLSearchParams that used to be built here never reached the
// returned string; only its emptiness was tested, and the fragment then carried the
// unfloored value, so the floor accomplished nothing.
const startTime = options.startTime ? Math.floor(options.startTime) : 0;
if (options.startTime) params.set('t', String(Math.floor(options.startTime)));
const queryString = params.toString();
return `${videoId}${queryString ? `#t=${options.startTime}` : ''}`;
return `${videoId}${startTime > 0 ? `#t=${startTime}` : ''}`;
},
getThumbnailUrl(videoId: string): string {
+13 -9
View File
@@ -36,20 +36,24 @@ export function getVideoExtensionFromFileName(fileName: string): string | null {
return ext;
}
/**
* The file name decides, always. A declared MIME type is a client claim, so trusting it
* on its own let `payload.exe` through as long as it said `video/mp4`. The declared type
* is only consulted to pick between two types that share an extension.
*/
export function resolveVideoContentType(fileName: string, mime: string | undefined): string | null {
const ext = getVideoExtensionFromFileName(fileName);
if (!ext) return null;
const typeFromName = EXT_TO_MIME[ext];
if (!typeFromName) return null;
const normalizedMime = normalizeVideoMime(mime);
if (normalizedMime) {
const extFromMime = getVideoExtensionFromMime(normalizedMime);
const extFromName = getVideoExtensionFromFileName(fileName);
if (extFromMime && extFromName && extFromMime !== extFromName) {
return EXT_TO_MIME[extFromName] ?? normalizedMime;
}
if (normalizedMime && getVideoExtensionFromMime(normalizedMime) === ext) {
return normalizedMime;
}
const ext = getVideoExtensionFromFileName(fileName);
if (!ext) return null;
return EXT_TO_MIME[ext] ?? null;
return typeFromName;
}
export function isAllowedVideoFile(fileName: string, mime: string | undefined): boolean {