refactor: eslint and prettier conflict will be resolved and formatted

This commit is contained in:
Enes Köksal
2026-04-23 17:05:43 +03:00
parent 385b61f29b
commit 3cfea40fbd
219 changed files with 16638 additions and 13663 deletions
+378 -359
View File
@@ -10,460 +10,479 @@ const BUNNY_API_BASE = 'https://video.bunnycdn.com';
const STORAGE_CACHE_SECONDS = 120;
interface R2StorageSnapshot {
fileSizes: Map<string, number>;
totalBytes: number;
refreshedAt: string;
fileSizes: Map<string, number>;
totalBytes: number;
refreshedAt: string;
}
const globalForAdminStats = globalThis as unknown as {
adminR2StorageSnapshot?: R2StorageSnapshot;
adminR2StorageSnapshotPromise?: Promise<R2StorageSnapshot>;
adminR2StorageSnapshot?: R2StorageSnapshot;
adminR2StorageSnapshotPromise?: Promise<R2StorageSnapshot>;
};
interface BunnyStorageStats {
totalBytes: number;
byVideoId: Record<string, number>;
totalBytes: number;
byVideoId: Record<string, number>;
}
function bigintToNumber(value: bigint): number {
return value > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(value);
return value > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(value);
}
function getBunnyConfig(): { apiKey: string; libraryId: string } {
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
throw new Error('Missing Bunny Stream credentials.');
}
return { apiKey, libraryId };
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId =
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
throw new Error('Missing Bunny Stream credentials.');
}
return { apiKey, libraryId };
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') return null;
return value as Record<string, unknown>;
if (!value || typeof value !== 'object') return null;
return value as Record<string, unknown>;
}
function parseBunnyVideoStorageBytes(item: unknown): number {
const record = toRecord(item);
if (!record) return 0;
const record = toRecord(item);
if (!record) return 0;
const candidates = ['storageSize', 'storage', 'size'];
for (const key of candidates) {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
const candidates = ['storageSize', 'storage', 'size'];
for (const key of candidates) {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
}
return 0;
return 0;
}
function parseBunnyVideoGuid(item: unknown): string | null {
const record = toRecord(item);
if (!record) return null;
const value = record.guid;
return typeof value === 'string' && value.length > 0 ? value : null;
const record = toRecord(item);
if (!record) return null;
const value = record.guid;
return typeof value === 'string' && value.length > 0 ? value : null;
}
async function listAllR2FileSizes(): Promise<Map<string, number>> {
const fileSizes = new Map<string, number>();
let isTruncated = true;
let continuationToken: string | undefined;
const fileSizes = new Map<string, number>();
let isTruncated = true;
let continuationToken: string | undefined;
while (isTruncated) {
const commandParams: ListObjectsV2CommandInput = { Bucket: R2_BUCKET_NAME };
if (continuationToken) {
commandParams.ContinuationToken = continuationToken;
}
const data = await r2Client.send(new ListObjectsV2Command(commandParams));
if (data.Contents) {
for (const item of data.Contents) {
if (item.Key) fileSizes.set(item.Key, item.Size || 0);
}
}
isTruncated = data.IsTruncated ?? false;
continuationToken = data.NextContinuationToken;
while (isTruncated) {
const commandParams: ListObjectsV2CommandInput = { Bucket: R2_BUCKET_NAME };
if (continuationToken) {
commandParams.ContinuationToken = continuationToken;
}
return fileSizes;
const data = await r2Client.send(new ListObjectsV2Command(commandParams));
if (data.Contents) {
for (const item of data.Contents) {
if (item.Key) fileSizes.set(item.Key, item.Size || 0);
}
}
isTruncated = data.IsTruncated ?? false;
continuationToken = data.NextContinuationToken;
}
return fileSizes;
}
async function buildR2StorageSnapshot(): Promise<R2StorageSnapshot> {
const fileSizes = await listAllR2FileSizes();
let totalBytes = 0;
for (const size of fileSizes.values()) {
totalBytes += size;
}
const fileSizes = await listAllR2FileSizes();
let totalBytes = 0;
for (const size of fileSizes.values()) {
totalBytes += size;
}
return {
fileSizes,
totalBytes,
refreshedAt: new Date().toISOString(),
};
return {
fileSizes,
totalBytes,
refreshedAt: new Date().toISOString(),
};
}
async function getR2StorageSnapshot(): Promise<R2StorageSnapshot> {
if (globalForAdminStats.adminR2StorageSnapshot) {
return globalForAdminStats.adminR2StorageSnapshot;
}
if (globalForAdminStats.adminR2StorageSnapshot) {
return globalForAdminStats.adminR2StorageSnapshot;
}
return Promise.reject(new Error('R2 storage snapshot is not available. Trigger a manual refresh from admin dashboard.'));
return Promise.reject(
new Error(
'R2 storage snapshot is not available. Trigger a manual refresh from admin dashboard.'
)
);
}
export async function refreshR2StorageSnapshot(): Promise<string> {
const snapshot = await buildR2StorageSnapshot();
globalForAdminStats.adminR2StorageSnapshot = snapshot;
globalForAdminStats.adminR2StorageSnapshotPromise = undefined;
return snapshot.refreshedAt;
const snapshot = await buildR2StorageSnapshot();
globalForAdminStats.adminR2StorageSnapshot = snapshot;
globalForAdminStats.adminR2StorageSnapshotPromise = undefined;
return snapshot.refreshedAt;
}
async function fetchBunnyStorageStats(): Promise<BunnyStorageStats> {
if (!isBunnyUploadsFeatureEnabled()) {
return { totalBytes: 0, byVideoId: {} };
if (!isBunnyUploadsFeatureEnabled()) {
return { totalBytes: 0, byVideoId: {} };
}
const { apiKey, libraryId } = getBunnyConfig();
const byVideoId: Record<string, number> = {};
let totalBytes = 0;
let page = 1;
const itemsPerPage = 100;
while (page <= 200) {
const response = await fetch(
`${BUNNY_API_BASE}/library/${libraryId}/videos?page=${page}&itemsPerPage=${itemsPerPage}`,
{ headers: { AccessKey: apiKey }, cache: 'no-store' }
);
if (!response.ok) {
throw new Error(`Bunny API failed (${response.status})`);
}
const { apiKey, libraryId } = getBunnyConfig();
const byVideoId: Record<string, number> = {};
let totalBytes = 0;
let page = 1;
const itemsPerPage = 100;
const json = await response.json();
const record = toRecord(json);
if (!record) break;
while (page <= 200) {
const response = await fetch(
`${BUNNY_API_BASE}/library/${libraryId}/videos?page=${page}&itemsPerPage=${itemsPerPage}`,
{ headers: { AccessKey: apiKey }, cache: 'no-store' }
);
const rawItems = Array.isArray(record.items)
? record.items
: Array.isArray(record.Items)
? record.Items
: [];
if (!response.ok) {
throw new Error(`Bunny API failed (${response.status})`);
}
if (rawItems.length === 0) break;
const json = await response.json();
const record = toRecord(json);
if (!record) break;
const rawItems = Array.isArray(record.items)
? record.items
: (Array.isArray(record.Items) ? record.Items : []);
if (rawItems.length === 0) break;
for (const rawItem of rawItems) {
const guid = parseBunnyVideoGuid(rawItem);
if (!guid) continue;
const storageBytes = parseBunnyVideoStorageBytes(rawItem);
byVideoId[guid] = storageBytes;
totalBytes += storageBytes;
}
const totalItems = typeof record.totalItems === 'number'
? record.totalItems
: (typeof record.TotalItems === 'number' ? record.TotalItems : null);
if (totalItems !== null && page * itemsPerPage >= totalItems) {
break;
}
page += 1;
for (const rawItem of rawItems) {
const guid = parseBunnyVideoGuid(rawItem);
if (!guid) continue;
const storageBytes = parseBunnyVideoStorageBytes(rawItem);
byVideoId[guid] = storageBytes;
totalBytes += storageBytes;
}
return { totalBytes, byVideoId };
const totalItems =
typeof record.totalItems === 'number'
? record.totalItems
: typeof record.TotalItems === 'number'
? record.TotalItems
: null;
if (totalItems !== null && page * itemsPerPage >= totalItems) {
break;
}
page += 1;
}
return { totalBytes, byVideoId };
}
export async function getCachedTotalStorage(): Promise<number> {
try {
const snapshot = await getR2StorageSnapshot();
return snapshot.totalBytes;
} catch (err) {
logError('Failed to fetch total storage stats:', err);
return -1;
}
try {
const snapshot = await getR2StorageSnapshot();
return snapshot.totalBytes;
} catch (err) {
logError('Failed to fetch total storage stats:', err);
return -1;
}
}
export const getCachedBunnyStorageStats = unstable_cache(
async () => {
try {
return await fetchBunnyStorageStats();
} catch (err) {
logError('Failed to fetch Bunny storage stats:', err);
return { totalBytes: -1, byVideoId: {} } as BunnyStorageStats;
}
},
['admin-bunny-storage'],
{ revalidate: STORAGE_CACHE_SECONDS }
async () => {
try {
return await fetchBunnyStorageStats();
} catch (err) {
logError('Failed to fetch Bunny storage stats:', err);
return { totalBytes: -1, byVideoId: {} } as BunnyStorageStats;
}
},
['admin-bunny-storage'],
{ revalidate: STORAGE_CACHE_SECONDS }
);
export const getCachedUserBunnyStorage = unstable_cache(
async () => {
const perUserStorage: Record<string, number> = {};
try {
const bunnyStats = await getCachedBunnyStorageStats();
if (bunnyStats.totalBytes < 0) return perUserStorage;
async () => {
const perUserStorage: Record<string, number> = {};
try {
const bunnyStats = await getCachedBunnyStorageStats();
if (bunnyStats.totalBytes < 0) return perUserStorage;
const [bunnyVersions, bunnyAssets] = await Promise.all([
db.videoVersion.findMany({
where: { providerId: 'bunny' },
select: {
videoId: true,
video: {
select: {
project: {
select: { ownerId: true },
},
},
},
},
}),
db.videoAsset.findMany({
where: {
provider: 'BUNNY',
providerVideoId: { not: null },
},
select: {
providerVideoId: true,
billedUserId: true,
},
}),
]);
const [bunnyVersions, bunnyAssets] = await Promise.all([
db.videoVersion.findMany({
where: { providerId: 'bunny' },
select: {
videoId: true,
video: {
select: {
project: {
select: { ownerId: true },
},
},
},
},
}),
db.videoAsset.findMany({
where: {
provider: 'BUNNY',
providerVideoId: { not: null },
},
select: {
providerVideoId: true,
billedUserId: true,
},
}),
]);
const seenVideoIds = new Set<string>();
for (const version of bunnyVersions) {
const ownerId = version.video.project.ownerId;
const dedupeKey = `${ownerId}:${version.videoId}`;
if (seenVideoIds.has(dedupeKey)) continue;
seenVideoIds.add(dedupeKey);
const seenVideoIds = new Set<string>();
for (const version of bunnyVersions) {
const ownerId = version.video.project.ownerId;
const dedupeKey = `${ownerId}:${version.videoId}`;
if (seenVideoIds.has(dedupeKey)) continue;
seenVideoIds.add(dedupeKey);
const size = bunnyStats.byVideoId[version.videoId] || 0;
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
}
const size = bunnyStats.byVideoId[version.videoId] || 0;
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
}
for (const asset of bunnyAssets) {
if (!asset.providerVideoId) continue;
const billedUserId = asset.billedUserId;
const dedupeKey = `${billedUserId}:${asset.providerVideoId}`;
if (seenVideoIds.has(dedupeKey)) continue;
seenVideoIds.add(dedupeKey);
for (const asset of bunnyAssets) {
if (!asset.providerVideoId) continue;
const billedUserId = asset.billedUserId;
const dedupeKey = `${billedUserId}:${asset.providerVideoId}`;
if (seenVideoIds.has(dedupeKey)) continue;
seenVideoIds.add(dedupeKey);
const size = bunnyStats.byVideoId[asset.providerVideoId] || 0;
perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size;
}
} catch (err) {
logError('Failed to calculate per-user Bunny storage:', err);
}
return perUserStorage;
},
['admin-user-bunny-storage'],
{ revalidate: STORAGE_CACHE_SECONDS }
const size = bunnyStats.byVideoId[asset.providerVideoId] || 0;
perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size;
}
} catch (err) {
logError('Failed to calculate per-user Bunny storage:', err);
}
return perUserStorage;
},
['admin-user-bunny-storage'],
{ revalidate: STORAGE_CACHE_SECONDS }
);
export async function getCachedUserMediaStorage(): Promise<Record<string, { total: number, voice: number, image: number }>> {
// Return a plain object so it maps cleanly out of server component boundaries
const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
try {
const snapshot = await getR2StorageSnapshot();
const seenKeys = new Set<string>();
export async function getCachedUserMediaStorage(): Promise<
Record<string, { total: number; voice: number; image: number }>
> {
// Return a plain object so it maps cleanly out of server component boundaries
const userStorage: Record<string, { total: number; voice: number; image: number }> = {};
try {
const snapshot = await getR2StorageSnapshot();
const seenKeys = new Set<string>();
const [mediaComments, imageAssets, audioAssets] = await Promise.all([
db.comment.findMany({
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
const [mediaComments, imageAssets, audioAssets] = await Promise.all([
db.comment.findMany({
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
select: {
voiceUrl: true,
imageUrl: true,
version: {
select: {
video: {
select: {
voiceUrl: true,
imageUrl: true,
version: {
select: {
video: {
select: {
project: {
select: {
workspace: {
select: { ownerId: true },
},
},
},
},
},
},
project: {
select: {
workspace: {
select: { ownerId: true },
},
},
},
},
}),
db.videoAsset.findMany({
where: { provider: 'R2_IMAGE' },
select: {
sourceUrl: true,
billedUserId: true,
},
}),
db.videoAsset.findMany({
where: { provider: 'R2_AUDIO' },
select: {
sourceUrl: true,
billedUserId: true,
},
}),
]);
},
},
},
},
}),
db.videoAsset.findMany({
where: { provider: 'R2_IMAGE' },
select: {
sourceUrl: true,
billedUserId: true,
},
}),
db.videoAsset.findMany({
where: { provider: 'R2_AUDIO' },
select: {
sourceUrl: true,
billedUserId: true,
},
}),
]);
for (const comment of mediaComments) {
const billedUserId = comment.version.video.project.workspace.ownerId;
if (!billedUserId) continue;
for (const comment of mediaComments) {
const billedUserId = comment.version.video.project.workspace.ownerId;
if (!billedUserId) continue;
if (!userStorage[billedUserId]) {
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
}
if (!userStorage[billedUserId]) {
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
}
if (comment.voiceUrl) {
const keyParts = comment.voiceUrl.split('/');
const filename = keyParts[keyParts.length - 1];
const r2Key = `voice/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (!seenKeys.has(dedupeKey)) {
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].voice += size;
userStorage[billedUserId].total += size;
}
}
if (comment.imageUrl) {
const keyParts = comment.imageUrl.split('/');
const filename = keyParts[keyParts.length - 1];
const r2Key = `images/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (!seenKeys.has(dedupeKey)) {
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].image += size;
userStorage[billedUserId].total += size;
}
}
if (comment.voiceUrl) {
const keyParts = comment.voiceUrl.split('/');
const filename = keyParts[keyParts.length - 1];
const r2Key = `voice/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (!seenKeys.has(dedupeKey)) {
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].voice += size;
userStorage[billedUserId].total += size;
}
}
for (const asset of imageAssets) {
const billedUserId = asset.billedUserId;
if (!billedUserId) continue;
if (!userStorage[billedUserId]) {
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
}
const keyParts = asset.sourceUrl.split('/');
const filename = keyParts[keyParts.length - 1];
if (!filename) continue;
const r2Key = `images/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (seenKeys.has(dedupeKey)) continue;
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].image += size;
userStorage[billedUserId].total += size;
if (comment.imageUrl) {
const keyParts = comment.imageUrl.split('/');
const filename = keyParts[keyParts.length - 1];
const r2Key = `images/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (!seenKeys.has(dedupeKey)) {
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].image += size;
userStorage[billedUserId].total += size;
}
for (const asset of audioAssets) {
const billedUserId = asset.billedUserId;
if (!billedUserId) continue;
if (!userStorage[billedUserId]) {
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
}
const keyParts = asset.sourceUrl.split('/');
const filename = keyParts[keyParts.length - 1];
if (!filename) continue;
const r2Key = `voice/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (seenKeys.has(dedupeKey)) continue;
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].voice += size;
userStorage[billedUserId].total += size;
}
} catch (err) {
logError('Failed to parse user storage:', err);
}
}
return userStorage;
for (const asset of imageAssets) {
const billedUserId = asset.billedUserId;
if (!billedUserId) continue;
if (!userStorage[billedUserId]) {
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
}
const keyParts = asset.sourceUrl.split('/');
const filename = keyParts[keyParts.length - 1];
if (!filename) continue;
const r2Key = `images/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (seenKeys.has(dedupeKey)) continue;
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].image += size;
userStorage[billedUserId].total += size;
}
for (const asset of audioAssets) {
const billedUserId = asset.billedUserId;
if (!billedUserId) continue;
if (!userStorage[billedUserId]) {
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
}
const keyParts = asset.sourceUrl.split('/');
const filename = keyParts[keyParts.length - 1];
if (!filename) continue;
const r2Key = `voice/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (seenKeys.has(dedupeKey)) continue;
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].voice += size;
userStorage[billedUserId].total += size;
}
} catch (err) {
logError('Failed to parse user storage:', err);
}
return userStorage;
}
export const getCachedUserDownloadEgress = unstable_cache(
async () => {
const perUserDownloadEgress: Record<string, number> = {};
try {
const grouped = await db.downloadEgressEvent.groupBy({
by: ['billedUserId'],
_sum: {
estimatedBytes: true,
},
});
async () => {
const perUserDownloadEgress: Record<string, number> = {};
try {
const grouped = await db.downloadEgressEvent.groupBy({
by: ['billedUserId'],
_sum: {
estimatedBytes: true,
},
});
for (const row of grouped) {
perUserDownloadEgress[row.billedUserId] = row._sum.estimatedBytes
? bigintToNumber(row._sum.estimatedBytes)
: 0;
}
} catch (err) {
logError('Failed to calculate per-user download egress:', err);
}
for (const row of grouped) {
perUserDownloadEgress[row.billedUserId] = row._sum.estimatedBytes
? bigintToNumber(row._sum.estimatedBytes)
: 0;
}
} catch (err) {
logError('Failed to calculate per-user download egress:', err);
}
return perUserDownloadEgress;
},
['admin-user-download-egress'],
{ revalidate: STORAGE_CACHE_SECONDS }
return perUserDownloadEgress;
},
['admin-user-download-egress'],
{ revalidate: STORAGE_CACHE_SECONDS }
);
export interface StripeStats {
activeSubscribers: number;
trialingUsers: number;
pastDueUsers: number;
canceledUsers: number;
freeUsers: number;
mrrCents: number;
currency: string;
activeSubscribers: number;
trialingUsers: number;
pastDueUsers: number;
canceledUsers: number;
freeUsers: number;
mrrCents: number;
currency: string;
}
const STRIPE_STATS_CACHE_SECONDS = 300;
export const getCachedStripeStats = unstable_cache(
async (): Promise<StripeStats | null> => {
if (!isStripeBillingEnabled()) return null;
async (): Promise<StripeStats | null> => {
if (!isStripeBillingEnabled()) return null;
try {
const statusCounts = await db.user.groupBy({
by: ['subscriptionStatus'],
_count: { id: true },
});
try {
const statusCounts = await db.user.groupBy({
by: ['subscriptionStatus'],
_count: { id: true },
});
const counts: Record<string, number> = {};
for (const row of statusCounts) {
const key = row.subscriptionStatus ?? 'UNKNOWN';
counts[key] = row._count.id;
}
const counts: Record<string, number> = {};
for (const row of statusCounts) {
const key = row.subscriptionStatus ?? 'UNKNOWN';
counts[key] = row._count.id;
}
const activeSubscribers = counts['ACTIVE'] ?? 0;
const trialingUsers = counts['TRIALING'] ?? 0;
const pastDueUsers = counts['PAST_DUE'] ?? 0;
const canceledUsers = counts['CANCELED'] ?? 0;
const freeUsers = counts['FREE'] ?? 0;
const activeSubscribers = counts['ACTIVE'] ?? 0;
const trialingUsers = counts['TRIALING'] ?? 0;
const pastDueUsers = counts['PAST_DUE'] ?? 0;
const canceledUsers = counts['CANCELED'] ?? 0;
const freeUsers = counts['FREE'] ?? 0;
let mrrCents = 0;
let currency = 'usd';
let mrrCents = 0;
let currency = 'usd';
try {
const stripe = getStripe();
const priceId = getStripePriceId();
const price = await stripe.prices.retrieve(priceId);
const unitAmount = price.unit_amount ?? 0;
currency = price.currency ?? 'usd';
mrrCents = activeSubscribers * unitAmount;
} catch (err) {
logError('Failed to fetch Stripe price for MRR calculation:', err);
}
try {
const stripe = getStripe();
const priceId = getStripePriceId();
const price = await stripe.prices.retrieve(priceId);
const unitAmount = price.unit_amount ?? 0;
currency = price.currency ?? 'usd';
mrrCents = activeSubscribers * unitAmount;
} catch (err) {
logError('Failed to fetch Stripe price for MRR calculation:', err);
}
return { activeSubscribers, trialingUsers, pastDueUsers, canceledUsers, freeUsers, mrrCents, currency };
} catch (err) {
logError('Failed to fetch Stripe stats:', err);
return null;
}
},
['admin-stripe-stats'],
{ revalidate: STRIPE_STATS_CACHE_SECONDS }
return {
activeSubscribers,
trialingUsers,
pastDueUsers,
canceledUsers,
freeUsers,
mrrCents,
currency,
};
} catch (err) {
logError('Failed to fetch Stripe stats:', err);
return null;
}
},
['admin-stripe-stats'],
{ revalidate: STRIPE_STATS_CACHE_SECONDS }
);
+23 -22
View File
@@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from 'next/server';
/**
* Standardized API error response format
@@ -45,27 +45,27 @@ export const HttpStatus = {
*/
export const ErrorCode = {
// Authentication errors
UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: "FORBIDDEN",
INVALID_CREDENTIALS: "INVALID_CREDENTIALS",
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
// Resource errors
NOT_FOUND: "NOT_FOUND",
ALREADY_EXISTS: "ALREADY_EXISTS",
NOT_FOUND: 'NOT_FOUND',
ALREADY_EXISTS: 'ALREADY_EXISTS',
// Validation errors
VALIDATION_ERROR: "VALIDATION_ERROR",
INVALID_INPUT: "INVALID_INPUT",
VALIDATION_ERROR: 'VALIDATION_ERROR',
INVALID_INPUT: 'INVALID_INPUT',
// Rate limiting
RATE_LIMITED: "RATE_LIMITED",
RATE_LIMITED: 'RATE_LIMITED',
// Server errors
INTERNAL_ERROR: "INTERNAL_ERROR",
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
// Storage errors
STORAGE_LIMIT_EXCEEDED: "STORAGE_LIMIT_EXCEEDED",
STORAGE_LIMIT_EXCEEDED: 'STORAGE_LIMIT_EXCEEDED',
} as const;
/**
@@ -94,7 +94,7 @@ export function errorResponse(
// Sanitize: only allow string arrays to prevent accidental data leakage
const sanitized: Record<string, string[]> = {};
for (const [key, value] of Object.entries(details)) {
if (Array.isArray(value) && value.every(v => typeof v === 'string')) {
if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
sanitized[key] = value;
}
}
@@ -122,7 +122,7 @@ export function errorResponse(
export function successResponse<T>(
data: T,
status: number = HttpStatus.OK,
meta?: ApiSuccessResponse["meta"]
meta?: ApiSuccessResponse['meta']
): NextResponse<ApiSuccessResponse<T>> {
const body: ApiSuccessResponse<T> = { data };
if (meta) body.meta = meta;
@@ -139,16 +139,16 @@ export function withCacheControl(response: Response, value: string): Response {
* Common error response helpers
*/
export const apiErrors = {
unauthorized: (message = "Unauthorized") =>
unauthorized: (message = 'Unauthorized') =>
errorResponse(message, HttpStatus.UNAUTHORIZED, ErrorCode.UNAUTHORIZED),
forbidden: (message = "Forbidden") =>
forbidden: (message = 'Forbidden') =>
errorResponse(message, HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN),
notFound: (resource = "Resource") =>
notFound: (resource = 'Resource') =>
errorResponse(`${resource} not found`, HttpStatus.NOT_FOUND, ErrorCode.NOT_FOUND),
badRequest: (message = "Bad request") =>
badRequest: (message = 'Bad request') =>
errorResponse(message, HttpStatus.BAD_REQUEST, ErrorCode.INVALID_INPUT),
validationError: (message: string, details?: Record<string, string[]>) =>
@@ -157,12 +157,13 @@ export const apiErrors = {
conflict: (message: string) =>
errorResponse(message, HttpStatus.CONFLICT, ErrorCode.ALREADY_EXISTS),
rateLimited: (message = "Too many requests") =>
rateLimited: (message = 'Too many requests') =>
errorResponse(message, HttpStatus.TOO_MANY_REQUESTS, ErrorCode.RATE_LIMITED),
internalError: (message = "Internal server error") =>
internalError: (message = 'Internal server error') =>
errorResponse(message, HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR),
storageExceeded: (message = "Storage limit exceeded. Please delete some files to free up space.") =>
errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.STORAGE_LIMIT_EXCEEDED),
storageExceeded: (
message = 'Storage limit exceeded. Please delete some files to free up space.'
) => errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.STORAGE_LIMIT_EXCEEDED),
};
+7 -2
View File
@@ -7,12 +7,17 @@ export interface ApprovalCandidate {
image: string | null;
}
function addCandidate(map: Map<string, ApprovalCandidate>, user: ApprovalCandidate | null | undefined) {
function addCandidate(
map: Map<string, ApprovalCandidate>,
user: ApprovalCandidate | null | undefined
) {
if (!user) return;
map.set(user.id, user);
}
export async function getApprovalCandidatesForProject(projectId: string): Promise<ApprovalCandidate[] | null> {
export async function getApprovalCandidatesForProject(
projectId: string
): Promise<ApprovalCandidate[] | null> {
const project = await db.project.findUnique({
where: { id: projectId },
select: {
+47 -25
View File
@@ -62,16 +62,26 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
},
}),
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? [Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET })]
? [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
]
: []),
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
? [{
...GitHub({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET }),
// GitHub sends iss=https://github.com/login/oauth in callbacks (RFC 9207).
// Auth.js v5 beta defaults to "https://authjs.dev" for OAuth providers, causing
// a mismatch. Setting the correct issuer here fixes the CallbackRouteError.
issuer: 'https://github.com/login/oauth',
}]
? [
{
...GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
// GitHub sends iss=https://github.com/login/oauth in callbacks (RFC 9207).
// Auth.js v5 beta defaults to "https://authjs.dev" for OAuth providers, causing
// a mismatch. Setting the correct issuer here fixes the CallbackRouteError.
issuer: 'https://github.com/login/oauth',
},
]
: []),
],
session: {
@@ -96,7 +106,12 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// OAuth sign-in: allow existing OAuth accounts regardless of invite setting
if (account?.providerAccountId && account?.provider) {
const existingAccount = await db.account.findUnique({
where: { provider_providerAccountId: { provider: account.provider, providerAccountId: account.providerAccountId } },
where: {
provider_providerAccountId: {
provider: account.provider,
providerAccountId: account.providerAccountId,
},
},
select: { id: true },
});
if (existingAccount) return true;
@@ -160,12 +175,22 @@ export function projectAccessInclude(userId: string | undefined) {
},
},
members: userId
? { where: { userId }, take: 1, orderBy: { createdAt: 'asc' as const }, select: { role: true } }
? {
where: { userId },
take: 1,
orderBy: { createdAt: 'asc' as const },
select: { role: true },
}
: { take: 0, select: { role: true } },
},
},
members: userId
? { where: { userId }, take: 1, orderBy: { createdAt: 'asc' as const }, select: { role: true } }
? {
where: { userId },
take: 1,
orderBy: { createdAt: 'asc' as const },
select: { role: true },
}
: { take: 0, select: { role: true } },
};
}
@@ -195,7 +220,7 @@ export type EnrichedProjectForAccess = {
*/
export function computeProjectAccess(
project: EnrichedProjectForAccess,
userId: string | undefined,
userId: string | undefined
) {
const isOwner = userId === project.ownerId;
const isPublic = project.visibility === 'PUBLIC';
@@ -217,16 +242,12 @@ export function computeProjectAccess(
}
const isWorkspaceMember = !!workspaceRole;
const isWorkspaceAdmin =
workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
const hasAccess =
workspaceOwnerBillingAccess &&
(isOwner || isProjectMember || isPublic || isWorkspaceMember);
const canEdit =
workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
const canDelete =
workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember);
const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
return {
isOwner,
@@ -254,8 +275,8 @@ export async function checkProjectAccess(
// Get project membership
const projectMember = userId
? await db.projectMember.findUnique({
where: { projectId_userId: { projectId: project.id, userId } },
})
where: { projectId_userId: { projectId: project.id, userId } },
})
: null;
const isProjectMember = !!projectMember;
const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN;
@@ -316,7 +337,8 @@ export async function checkProjectAccess(
const isWorkspaceMember = !!workspaceRole;
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
const hasAccess = workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember);
const hasAccess =
workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember);
const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
@@ -343,8 +365,8 @@ export async function checkWorkspaceAccess(
// Get workspace membership
const workspaceMember = userId
? await db.workspaceMember.findUnique({
where: { workspaceId_userId: { workspaceId: workspace.id, userId } },
})
where: { workspaceId_userId: { workspaceId: workspace.id, userId } },
})
: null;
const isMember = !!workspaceMember;
const isAdmin = workspaceMember?.role === WorkspaceMemberRole.ADMIN;
+74 -64
View File
@@ -79,7 +79,11 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
return {
OR: [
{ subscriptionStatus: { in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING] } },
{
subscriptionStatus: {
in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
},
},
{ trialEndsAt: { gt: now } },
{ stripeCurrentPeriodEnd: { gt: now } },
],
@@ -98,10 +102,7 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
OR: [
{ billingAccessEndedAt: { lte: cleanupCutoff } },
{
AND: [
{ billingAccessEndedAt: null },
{ trialEndsAt: { lte: cleanupCutoff } },
],
AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cleanupCutoff } }],
},
],
},
@@ -174,51 +175,52 @@ export async function getStripeCheckoutState(userId: string) {
}
export async function getWorkspaceCreationEligibility(userId: string) {
const [user, ownedWorkspaceCount, invitedWorkspaceCount, projectOnlyCollaborationCount] = await Promise.all([
db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
billingTrialConsumedAt: true,
stripeCustomerId: true,
stripeSubscriptionId: true,
stripePriceId: true,
stripeCurrentPeriodEnd: true,
stripeCancelAtPeriodEnd: true,
stripeCancelAt: true,
billingAccessEndedAt: true,
},
}),
db.workspace.count({
where: { ownerId: userId },
}),
db.workspaceMember.count({
where: {
userId,
workspace: {
ownerId: {
not: userId,
},
const [user, ownedWorkspaceCount, invitedWorkspaceCount, projectOnlyCollaborationCount] =
await Promise.all([
db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
billingTrialConsumedAt: true,
stripeCustomerId: true,
stripeSubscriptionId: true,
stripePriceId: true,
stripeCurrentPeriodEnd: true,
stripeCancelAtPeriodEnd: true,
stripeCancelAt: true,
billingAccessEndedAt: true,
},
},
}),
db.projectMember.count({
where: {
userId,
project: {
ownerId: {
not: userId,
},
}),
db.workspace.count({
where: { ownerId: userId },
}),
db.workspaceMember.count({
where: {
userId,
workspace: {
ownerId: {
not: userId,
},
},
},
},
}),
]);
}),
db.projectMember.count({
where: {
userId,
project: {
ownerId: {
not: userId,
},
workspace: {
ownerId: {
not: userId,
},
},
},
},
}),
]);
if (!user) {
throw new Error(`User ${userId} not found`);
@@ -227,7 +229,9 @@ export async function getWorkspaceCreationEligibility(userId: string) {
const billingAccess = hasBillingAccess(user);
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
const canCreateWorkspace =
!isStripeFeatureEnabled() || billingAccess || (ownedWorkspaceCount === 0 && collaborationCount === 0);
!isStripeFeatureEnabled() ||
billingAccess ||
(ownedWorkspaceCount === 0 && collaborationCount === 0);
let reason: string | null = null;
if (!canCreateWorkspace && isStripeFeatureEnabled()) {
@@ -235,8 +239,7 @@ export async function getWorkspaceCreationEligibility(userId: string) {
reason =
'You are currently collaborating in someone elses workspace or project. Start a subscription to create a workspace of your own.';
} else {
reason =
'Your trial has ended. Start a subscription to create and keep owning workspaces.';
reason = 'Your trial has ended. Start a subscription to create and keep owning workspaces.';
}
}
@@ -318,9 +321,16 @@ function getStripeTimestamp(value: unknown): number | null {
return typeof value === 'number' ? value : null;
}
function getInactiveBillingAccessEndedAt(subscription: Stripe.Subscription, currentPeriodEnd: number | null) {
const endedAt = getStripeTimestamp((subscription as Stripe.Subscription & { ended_at?: unknown }).ended_at);
const canceledAt = getStripeTimestamp((subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at);
function getInactiveBillingAccessEndedAt(
subscription: Stripe.Subscription,
currentPeriodEnd: number | null
) {
const endedAt = getStripeTimestamp(
(subscription as Stripe.Subscription & { ended_at?: unknown }).ended_at
);
const canceledAt = getStripeTimestamp(
(subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at
);
const reference = currentPeriodEnd ?? endedAt ?? canceledAt;
return reference ? new Date(reference * 1000) : new Date();
@@ -329,14 +339,14 @@ function getInactiveBillingAccessEndedAt(subscription: Stripe.Subscription, curr
function getEntitledStripePriceId(subscription: Stripe.Subscription) {
const configuredPriceId = getStripePriceId();
return subscription.items.data.find((item) => item.price.id === configuredPriceId)?.price.id ?? null;
return (
subscription.items.data.find((item) => item.price.id === configuredPriceId)?.price.id ?? null
);
}
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const user = await db.user.findUnique({
where: { stripeCustomerId: customerId },
@@ -371,14 +381,13 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
const mappedStatus = hasEntitledPrice
? mapStripeSubscriptionStatus(subscription.status)
: BillingSubscriptionStatus.FREE;
const effectiveCurrentPeriodEnd = hasEntitledPrice && currentPeriodEnd
? new Date(currentPeriodEnd * 1000)
: null;
const effectiveTrialEnd = hasEntitledPrice && trialEnd
? new Date(trialEnd * 1000)
: null;
const hasAccess = hasEntitledPrice
&& (hasActiveSubscription(mappedStatus) || Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
const effectiveCurrentPeriodEnd =
hasEntitledPrice && currentPeriodEnd ? new Date(currentPeriodEnd * 1000) : null;
const effectiveTrialEnd = hasEntitledPrice && trialEnd ? new Date(trialEnd * 1000) : null;
const hasAccess =
hasEntitledPrice &&
(hasActiveSubscription(mappedStatus) ||
Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
return db.user.update({
where: { id: user.id },
@@ -390,9 +399,10 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
stripeCancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
subscriptionStatus: mappedStatus,
trialEndsAt: effectiveTrialEnd,
billingTrialConsumedAt: hasEntitledPrice && trialEnd
? (user.billingTrialConsumedAt ?? new Date())
: user.billingTrialConsumedAt,
billingTrialConsumedAt:
hasEntitledPrice && trialEnd
? (user.billingTrialConsumedAt ?? new Date())
: user.billingTrialConsumedAt,
billingAccessEndedAt: hasAccess
? null
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
+3 -1
View File
@@ -12,7 +12,9 @@ function normalizeBunnyCdnHostname(raw: string | null | undefined): string | nul
}
export function resolveServerBunnyCdnHostname(): string | null {
return normalizeBunnyCdnHostname(process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL);
return normalizeBunnyCdnHostname(
process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL
);
}
export function resolvePublicBunnyCdnHostname(): string | null {
+14 -3
View File
@@ -111,7 +111,10 @@ async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloa
return null;
}
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
async function resolveBunnyCompressedSource(
videoId: string,
requestedQuality: number | null
): Promise<BunnyDownloadSource> {
const hostname = resolveBunnyCdnHostname();
if (!hostname) {
return {
@@ -121,7 +124,11 @@ async function resolveBunnyCompressedSource(videoId: string, requestedQuality: n
};
}
if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) {
if (
typeof requestedQuality === 'number' &&
Number.isFinite(requestedQuality) &&
requestedQuality > 0
) {
const requestedUrl = `https://${hostname}/${videoId}/play_${requestedQuality}p.mp4`;
if (await isRemoteFileAvailable(requestedUrl)) {
return {
@@ -147,7 +154,11 @@ async function resolveBunnyCompressedSource(videoId: string, requestedQuality: n
};
}
function buildSourceCacheKey(videoId: string, requestedQuality: number | null, preference: BunnyDownloadSourcePreference): string {
function buildSourceCacheKey(
videoId: string,
requestedQuality: number | null,
preference: BunnyDownloadSourcePreference
): string {
return `${videoId}:${requestedQuality ?? 'none'}:${preference}`;
}
+17 -9
View File
@@ -17,10 +17,13 @@ const BUNNY_DELETE_CONCURRENCY = 5;
function getBunnyConfig(): { apiKey: string; libraryId: string } {
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
const libraryId =
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
throw new Error('Bunny cleanup failed: missing BUNNY_STREAM_API_KEY or BUNNY_STREAM_LIBRARY_ID.');
throw new Error(
'Bunny cleanup failed: missing BUNNY_STREAM_API_KEY or BUNNY_STREAM_LIBRARY_ID.'
);
}
return { apiKey, libraryId };
@@ -42,7 +45,9 @@ function getUniqueBunnyVideoIds(videoRefs: BunnyVideoRef[]): string[] {
];
}
export async function cleanupBunnyStreamVideosBestEffort(videoRefs: BunnyVideoRef[]): Promise<BunnyCleanupResult> {
export async function cleanupBunnyStreamVideosBestEffort(
videoRefs: BunnyVideoRef[]
): Promise<BunnyCleanupResult> {
const bunnyVideoIds = getUniqueBunnyVideoIds(videoRefs);
if (bunnyVideoIds.length === 0) {
return {
@@ -70,12 +75,15 @@ export async function cleanupBunnyStreamVideosBestEffort(videoRefs: BunnyVideoRe
await runWithConcurrency(bunnyVideoIds, BUNNY_DELETE_CONCURRENCY, async (bunnyVideoId) => {
try {
const response = await fetch(`${BUNNY_API_BASE}/library/${libraryId}/videos/${encodeURIComponent(bunnyVideoId)}`, {
method: 'DELETE',
headers: {
AccessKey: apiKey,
},
});
const response = await fetch(
`${BUNNY_API_BASE}/library/${libraryId}/videos/${encodeURIComponent(bunnyVideoId)}`,
{
method: 'DELETE',
headers: {
AccessKey: apiKey,
},
}
);
// Treat not-found as already deleted.
if (response.status === 404) return;
+15 -11
View File
@@ -33,14 +33,16 @@ function signPayload(payload: string, secret: string): string {
function isValidPayload(value: unknown): value is BunnyUploadTokenPayload {
if (!value || typeof value !== 'object') return false;
const payload = value as Partial<BunnyUploadTokenPayload>;
return payload.typ === BUNNY_UPLOAD_TOKEN_TYPE
&& typeof payload.uid === 'string'
&& typeof payload.pid === 'string'
&& typeof payload.vid === 'string'
&& typeof payload.iat === 'number'
&& Number.isFinite(payload.iat)
&& typeof payload.exp === 'number'
&& Number.isFinite(payload.exp);
return (
payload.typ === BUNNY_UPLOAD_TOKEN_TYPE &&
typeof payload.uid === 'string' &&
typeof payload.pid === 'string' &&
typeof payload.vid === 'string' &&
typeof payload.iat === 'number' &&
Number.isFinite(payload.iat) &&
typeof payload.exp === 'number' &&
Number.isFinite(payload.exp)
);
}
export function createBunnyUploadToken(
@@ -86,9 +88,11 @@ export function verifyBunnyUploadToken(token: string, subject: BunnyUploadTokenS
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return false;
return payload.uid === subject.userId
&& payload.pid === subject.projectId
&& payload.vid === subject.videoId;
return (
payload.uid === subject.userId &&
payload.pid === subject.projectId &&
payload.vid === subject.videoId
);
} catch {
return false;
}
+6 -2
View File
@@ -210,7 +210,9 @@ export function buildCommentsCsv(
row.hasImageAttachment,
row.hasAnnotation,
row.createdAtIso,
].map(csvCell).join(',')
]
.map(csvCell)
.join(',')
);
}
@@ -242,7 +244,9 @@ export function buildCommentsPdf(
`image=${row.hasImageAttachment ? 'yes' : 'no'}`,
`annotation=${row.hasAnnotation ? 'yes' : 'no'}`,
row.tag ? `tag=${row.tag}` : null,
].filter((item): item is string => item !== null).join(', ');
]
.filter((item): item is string => item !== null)
.join(', ');
lines.push(base);
lines.push(` ${details}`);
+3 -3
View File
@@ -55,7 +55,7 @@ function createPrismaClient() {
// In development without a database, we'll create a mock-friendly client
// For production or when DATABASE_URL is set, use the real adapter
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
console.warn('DATABASE_URL not set - database features will not work');
// Return a client that will throw clear errors when used
@@ -65,10 +65,10 @@ function createPrismaClient() {
adapter: new PrismaPg(pool),
});
}
const pool = createPool(connectionString);
const adapter = new PrismaPg(pool);
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
+7 -3
View File
@@ -63,11 +63,15 @@ export function brandedEmailTemplate(
${body}
</td></tr>
${(footerText || (footerLinkText && footerLinkUrl)) ? `
${
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>` : ''}
${(footerLinkText && footerLinkUrl) ? `<a href="${escapeAttr(footerLinkUrl)}" style="font-size:11px;color:${EMAIL_COLORS.accent};text-decoration:underline;">${escapeHtml(footerLinkText)}</a>` : ''}
</td></tr>` : ''}
${footerLinkText && footerLinkUrl ? `<a href="${escapeAttr(footerLinkUrl)}" style="font-size:11px;color:${EMAIL_COLORS.accent};text-decoration:underline;">${escapeHtml(footerLinkText)}</a>` : ''}
</td></tr>`
: ''
}
</table>
</td></tr>
</table>
+74 -75
View File
@@ -2,12 +2,12 @@ import { createHash, randomBytes } from 'crypto';
import { db } from '@/lib/db';
import nodemailer from 'nodemailer';
import {
brandedEmailTemplate,
emailButton,
emailHeading,
emailRow,
escapeHtml,
EMAIL_COLORS,
brandedEmailTemplate,
emailButton,
emailHeading,
emailRow,
escapeHtml,
EMAIL_COLORS,
} from '@/lib/email-brand';
import { logError } from '@/lib/logger';
@@ -16,7 +16,7 @@ const TOKEN_EXPIRY_HOURS = 2;
/** Hash a raw token before persisting so the DB stores only the digest. */
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
return createHash('sha256').update(token).digest('hex');
}
/**
@@ -25,7 +25,7 @@ function hashToken(token: string): string {
* without a mail server continue to function.
*/
export function isEmailVerificationEnabled(): boolean {
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASSWORD);
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASSWORD);
}
/**
@@ -34,19 +34,19 @@ export function isEmailVerificationEnabled(): boolean {
* Any existing tokens for this email are deleted first (at most one live token).
*/
export async function createVerificationToken(email: string): Promise<string> {
const token = randomBytes(32).toString('hex');
const tokenHash = hashToken(token);
const expires = new Date(Date.now() + TOKEN_EXPIRY_HOURS * 60 * 60 * 1000);
const token = randomBytes(32).toString('hex');
const tokenHash = hashToken(token);
const expires = new Date(Date.now() + TOKEN_EXPIRY_HOURS * 60 * 60 * 1000);
// Delete existing tokens for this identifier before creating a new one
await db.verificationToken.deleteMany({ where: { identifier: email } });
// Delete existing tokens for this identifier before creating a new one
await db.verificationToken.deleteMany({ where: { identifier: email } });
await db.verificationToken.create({
data: { identifier: email, token: tokenHash, expires },
});
await db.verificationToken.create({
data: { identifier: email, token: tokenHash, expires },
});
// Return the raw (unhashed) token — only ever sent to the user, never stored.
return token;
// Return the raw (unhashed) token — only ever sent to the user, never stored.
return token;
}
/**
@@ -56,29 +56,29 @@ export async function createVerificationToken(email: string): Promise<string> {
* already verified, or deleted account).
*/
export async function consumeVerificationToken(token: string): Promise<string | null> {
const tokenHash = hashToken(token);
const record = await db.verificationToken.findUnique({ where: { token: tokenHash } });
const tokenHash = hashToken(token);
const record = await db.verificationToken.findUnique({ where: { token: tokenHash } });
if (!record) return null;
if (record.expires < new Date()) {
await db.verificationToken.delete({ where: { token: tokenHash } }).catch(() => null);
return null;
}
if (!record) return null;
if (record.expires < new Date()) {
await db.verificationToken.delete({ where: { token: tokenHash } }).catch(() => null);
return null;
}
// Atomically mark email as verified and delete the token
const [user] = await db.$transaction([
db.user.updateMany({
where: { email: record.identifier, emailVerified: null },
data: { emailVerified: new Date() },
}),
db.verificationToken.delete({ where: { token: tokenHash } }),
]);
// Atomically mark email as verified and delete the token
const [user] = await db.$transaction([
db.user.updateMany({
where: { email: record.identifier, emailVerified: null },
data: { emailVerified: new Date() },
}),
db.verificationToken.delete({ where: { token: tokenHash } }),
]);
// count === 0 means the user was already verified or has been deleted.
// Return null so a replayed/stale token never produces a misleading success redirect.
if (user.count === 0) return null;
// count === 0 means the user was already verified or has been deleted.
// Return null so a replayed/stale token never produces a misleading success redirect.
if (user.count === 0) return null;
return record.identifier;
return record.identifier;
}
// ---------------------------------------------------------------------------
@@ -86,36 +86,35 @@ export async function consumeVerificationToken(token: string): Promise<string |
// ---------------------------------------------------------------------------
function createTransport() {
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT || '587');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
if (!host || !user || !pass) return null;
return nodemailer.createTransport({ host, port, secure: port === 465, auth: { user, pass } });
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT || '587');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
if (!host || !user || !pass) return null;
return nodemailer.createTransport({ host, port, secure: port === 465, auth: { user, pass } });
}
export async function sendVerificationEmail(email: string, token: string): Promise<void> {
const transporter = createTransport();
if (!transporter) return;
const transporter = createTransport();
if (!transporter) return;
const baseUrl = process.env.NEXTAUTH_URL;
if (!baseUrl) {
// A missing NEXTAUTH_URL means the verification link will be malformed and the
// user will be permanently locked out with no visible failure. Treat as fatal.
logError(
'NEXTAUTH_URL is not set — cannot build a valid verification link.',
new Error(
'Set NEXTAUTH_URL to your deployment origin (e.g. https://app.example.com).'
)
);
return;
}
const baseUrl = process.env.NEXTAUTH_URL;
if (!baseUrl) {
// A missing NEXTAUTH_URL means the verification link will be malformed and the
// user will be permanently locked out with no visible failure. Treat as fatal.
logError(
'NEXTAUTH_URL is not set — cannot build a valid verification link.',
new Error('Set NEXTAUTH_URL to your deployment origin (e.g. https://app.example.com).')
);
return;
}
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}`;
const from = process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}`;
const from =
process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
const html = brandedEmailTemplate(
`
const html = brandedEmailTemplate(
`
<tr>${emailHeading('✉', 'Verify your email address')}</tr>
<tr><td style="padding:20px;">
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
@@ -129,19 +128,19 @@ export async function sendVerificationEmail(email: string, token: string): Promi
${emailButton('Verify Email Address &#8594;', verifyUrl)}
</td></tr>
`,
{
footerText: `This link expires in ${TOKEN_EXPIRY_HOURS} hours.`,
}
);
try {
await transporter.sendMail({
from,
to: email,
subject: 'Verify your OpenFrame email address',
html,
});
} catch (err) {
logError('Failed to send verification email:', err);
{
footerText: `This link expires in ${TOKEN_EXPIRY_HOURS} hours.`,
}
);
try {
await transporter.sendMail({
from,
to: email,
subject: 'Verify your OpenFrame email address',
html,
});
} catch (err) {
logError('Failed to send verification email:', err);
}
}
+11 -5
View File
@@ -10,7 +10,8 @@ interface GuestIdentityPayload {
}
function getGuestIdentitySecret(): string {
const secret = process.env.GUEST_IDENTITY_SECRET ?? process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET;
const secret =
process.env.GUEST_IDENTITY_SECRET ?? process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET;
if (!secret) {
throw new Error('Missing GUEST_IDENTITY_SECRET, AUTH_SECRET, or NEXTAUTH_SECRET.');
}
@@ -38,9 +39,12 @@ function parseSignedValue(value: string): GuestIdentityPayload | null {
if (!timingSafeEqual(actualBytes, expectedBytes)) return null;
try {
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) as Partial<GuestIdentityPayload>;
const payload = JSON.parse(
Buffer.from(encodedPayload, 'base64url').toString('utf8')
) as Partial<GuestIdentityPayload>;
if (!payload.gid || typeof payload.gid !== 'string') return null;
if (!payload.exp || typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) return null;
if (!payload.exp || typeof payload.exp !== 'number' || !Number.isFinite(payload.exp))
return null;
if (payload.exp <= Math.floor(Date.now() / 1000)) return null;
return { gid: payload.gid, exp: payload.exp };
} catch {
@@ -72,7 +76,10 @@ export function getGuestIdentityFromRequest(request: NextRequest): string | null
return payload?.gid ?? null;
}
export function ensureGuestIdentityFromRequest(request: NextRequest): { identityId: string; shouldSetCookie: boolean } {
export function ensureGuestIdentityFromRequest(request: NextRequest): {
identityId: string;
shouldSetCookie: boolean;
} {
const existingIdentity = getGuestIdentityFromRequest(request);
if (existingIdentity) {
return { identityId: existingIdentity, shouldSetCookie: false };
@@ -88,4 +95,3 @@ export function setGuestIdentityCookie(response: NextResponse, identityId: strin
cookieOptions(GUEST_IDENTITY_TTL_SECONDS)
);
}
+29 -23
View File
@@ -32,7 +32,8 @@ interface GuestUploadTokenSubject {
const TRUSTED_IP_PATTERN = /^[\da-fA-F.:]+$/;
function getGuestUploadTokenSecret(): string {
const secret = process.env.GUEST_UPLOAD_TOKEN_SECRET ?? process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET;
const secret =
process.env.GUEST_UPLOAD_TOKEN_SECRET ?? process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET;
if (!secret) {
throw new Error('Missing GUEST_UPLOAD_TOKEN_SECRET, AUTH_SECRET, or NEXTAUTH_SECRET.');
}
@@ -40,7 +41,9 @@ function getGuestUploadTokenSecret(): string {
}
function signPayload(encodedPayload: string): string {
return createHmac('sha256', getGuestUploadTokenSecret()).update(encodedPayload).digest('base64url');
return createHmac('sha256', getGuestUploadTokenSecret())
.update(encodedPayload)
.digest('base64url');
}
function getCloudflareClientIp(request: Request): string | null {
@@ -65,18 +68,23 @@ function resolveTrustedClientIp(request: Request): string | null {
function isValidPayload(value: unknown): value is GuestUploadTokenPayload {
if (!value || typeof value !== 'object') return false;
const payload = value as Partial<GuestUploadTokenPayload>;
return payload.typ === GUEST_UPLOAD_TOKEN_TYPE
&& typeof payload.pid === 'string'
&& typeof payload.vid === 'string'
&& typeof payload.iat === 'number'
&& Number.isFinite(payload.iat)
&& typeof payload.exp === 'number'
&& Number.isFinite(payload.exp)
&& (payload.intent === 'audio' || payload.intent === 'image' || payload.intent === 'bunny')
&& typeof payload.ctx === 'string';
return (
payload.typ === GUEST_UPLOAD_TOKEN_TYPE &&
typeof payload.pid === 'string' &&
typeof payload.vid === 'string' &&
typeof payload.iat === 'number' &&
Number.isFinite(payload.iat) &&
typeof payload.exp === 'number' &&
Number.isFinite(payload.exp) &&
(payload.intent === 'audio' || payload.intent === 'image' || payload.intent === 'bunny') &&
typeof payload.ctx === 'string'
);
}
export function deriveGuestUploadContext(request: Request, shareToken: string | null): string | null {
export function deriveGuestUploadContext(
request: Request,
shareToken: string | null
): string | null {
const ip = resolveTrustedClientIp(request);
if (!ip) return null;
@@ -128,10 +136,12 @@ export function verifyGuestUploadToken(token: string, subject: GuestUploadTokenS
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return false;
return payload.pid === subject.projectId
&& payload.vid === subject.videoId
&& payload.intent === subject.intent
&& payload.ctx === subject.context;
return (
payload.pid === subject.projectId &&
payload.vid === subject.videoId &&
payload.intent === subject.intent &&
payload.ctx === subject.context
);
} catch {
return false;
}
@@ -145,15 +155,11 @@ export async function enforceGuestUploadQuota(
): Promise<NextResponse | null> {
const ip = resolveTrustedClientIp(request);
if (!ip) {
return NextResponse.json(
{ error: 'Missing trusted client IP header' },
{ status: 403 }
);
return NextResponse.json({ error: 'Missing trusted client IP header' }, { status: 403 });
}
const videoScopedMaxRequests = intent === 'bunny'
? GUEST_BUNNY_UPLOAD_VIDEO_MAX_REQUESTS
: GUEST_UPLOAD_VIDEO_MAX_REQUESTS;
const videoScopedMaxRequests =
intent === 'bunny' ? GUEST_BUNNY_UPLOAD_VIDEO_MAX_REQUESTS : GUEST_UPLOAD_VIDEO_MAX_REQUESTS;
const videoScoped = await checkRateLimit(
`${ip}:guest-upload:${intent}:video:${videoId}`,
+31 -26
View File
@@ -1,4 +1,9 @@
export const ALLOWED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
export const ALLOWED_IMAGE_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
] as const;
export type AllowedImageMimeType = (typeof ALLOWED_IMAGE_MIME_TYPES)[number];
const EXT_BY_MIME: Record<AllowedImageMimeType, string> = {
@@ -24,41 +29,41 @@ export function detectImageMime(buffer: Uint8Array): AllowedImageMimeType | null
}
// PNG
if (
buffer.length >= 8
&& buffer[0] === 0x89
&& buffer[1] === 0x50
&& buffer[2] === 0x4e
&& buffer[3] === 0x47
&& buffer[4] === 0x0d
&& buffer[5] === 0x0a
&& buffer[6] === 0x1a
&& buffer[7] === 0x0a
buffer.length >= 8 &&
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a
) {
return 'image/png';
}
// GIF87a/GIF89a
if (
buffer.length >= 6
&& buffer[0] === 0x47
&& buffer[1] === 0x49
&& buffer[2] === 0x46
&& buffer[3] === 0x38
&& (buffer[4] === 0x37 || buffer[4] === 0x39)
&& buffer[5] === 0x61
buffer.length >= 6 &&
buffer[0] === 0x47 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x38 &&
(buffer[4] === 0x37 || buffer[4] === 0x39) &&
buffer[5] === 0x61
) {
return 'image/gif';
}
// WEBP: "RIFF"...."WEBP"
if (
buffer.length >= 12
&& buffer[0] === 0x52
&& buffer[1] === 0x49
&& buffer[2] === 0x46
&& buffer[3] === 0x46
&& buffer[8] === 0x57
&& buffer[9] === 0x45
&& buffer[10] === 0x42
&& buffer[11] === 0x50
buffer.length >= 12 &&
buffer[0] === 0x52 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x46 &&
buffer[8] === 0x57 &&
buffer[9] === 0x45 &&
buffer[10] === 0x42 &&
buffer[11] === 0x50
) {
return 'image/webp';
}
+92 -72
View File
@@ -1,6 +1,13 @@
import { randomBytes } from 'crypto';
import nodemailer from 'nodemailer';
import { InvitationRole, InvitationScope, InvitationStatus, Prisma, ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import {
InvitationRole,
InvitationScope,
InvitationStatus,
Prisma,
ProjectMemberRole,
WorkspaceMemberRole,
} from '@prisma/client';
import { db } from '@/lib/db';
import {
brandedEmailTemplate,
@@ -60,7 +67,8 @@ export async function sendInvitationEmail(input: {
return false;
}
const fromAddress = process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
const fromAddress =
process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
const subject = `[OpenFrame] You were invited to a ${scopeLabel(input.scope)}: ${input.targetName}`;
const html = invitationEmailTemplate({
inviterName: input.inviterName,
@@ -128,35 +136,8 @@ export async function createOrRefreshInvitation(params: {
const token = randomBytes(32).toString('hex');
try {
return await db.$transaction(async (tx) => {
await tx.invitation.updateMany({
where: {
email: normalizedEmail,
scope: params.scope,
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
status: InvitationStatus.PENDING,
expiresAt: { lte: now },
},
data: {
status: InvitationStatus.EXPIRED,
},
});
const existingPending = await tx.invitation.findFirst({
where: {
email: normalizedEmail,
scope: params.scope,
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (existingPending) {
return await db.$transaction(
async (tx) => {
await tx.invitation.updateMany({
where: {
email: normalizedEmail,
@@ -164,42 +145,73 @@ export async function createOrRefreshInvitation(params: {
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
status: InvitationStatus.PENDING,
id: { not: existingPending.id },
expiresAt: { lte: now },
},
data: {
status: InvitationStatus.CANCELED,
status: InvitationStatus.EXPIRED,
},
});
return tx.invitation.update({
where: { id: existingPending.id },
const existingPending = await tx.invitation.findFirst({
where: {
email: normalizedEmail,
scope: params.scope,
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (existingPending) {
await tx.invitation.updateMany({
where: {
email: normalizedEmail,
scope: params.scope,
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
status: InvitationStatus.PENDING,
id: { not: existingPending.id },
},
data: {
status: InvitationStatus.CANCELED,
},
});
return tx.invitation.update({
where: { id: existingPending.id },
data: {
role: params.role,
invitedById: params.invitedById,
token,
expiresAt,
},
});
}
return tx.invitation.create({
data: {
email: normalizedEmail,
scope: params.scope,
role: params.role,
invitedById: params.invitedById,
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
token,
expiresAt,
status: InvitationStatus.PENDING,
},
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}
return tx.invitation.create({
data: {
email: normalizedEmail,
scope: params.scope,
role: params.role,
invitedById: params.invitedById,
workspaceId: params.workspaceId ?? null,
projectId: params.projectId ?? null,
token,
expiresAt,
status: InvitationStatus.PENDING,
},
});
}, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});
);
} catch (error) {
const isSerializationFailure = error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034';
const isSerializationFailure =
error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034';
if (!isSerializationFailure || attempt === MAX_INVITATION_RETRIES) {
throw error;
}
@@ -230,13 +242,17 @@ async function acceptInvitation(tx: Prisma.TransactionClient, invitationId: stri
});
}
async function applyInvitationMembership(tx: Prisma.TransactionClient, invitation: {
id: string;
role: InvitationRole;
scope: InvitationScope;
workspaceId: string | null;
projectId: string | null;
}, userId: string) {
async function applyInvitationMembership(
tx: Prisma.TransactionClient,
invitation: {
id: string;
role: InvitationRole;
scope: InvitationScope;
workspaceId: string | null;
projectId: string | null;
},
userId: string
) {
if (invitation.scope === InvitationScope.WORKSPACE && invitation.workspaceId) {
const workspace = await tx.workspace.findUnique({
where: { id: invitation.workspaceId },
@@ -253,16 +269,18 @@ async function applyInvitationMembership(tx: Prisma.TransactionClient, invitatio
},
},
update: {
role: invitation.role === InvitationRole.ADMIN
? WorkspaceMemberRole.ADMIN
: WorkspaceMemberRole.COMMENTATOR,
role:
invitation.role === InvitationRole.ADMIN
? WorkspaceMemberRole.ADMIN
: WorkspaceMemberRole.COMMENTATOR,
},
create: {
workspaceId: invitation.workspaceId,
userId,
role: invitation.role === InvitationRole.ADMIN
? WorkspaceMemberRole.ADMIN
: WorkspaceMemberRole.COMMENTATOR,
role:
invitation.role === InvitationRole.ADMIN
? WorkspaceMemberRole.ADMIN
: WorkspaceMemberRole.COMMENTATOR,
},
});
}
@@ -287,16 +305,18 @@ async function applyInvitationMembership(tx: Prisma.TransactionClient, invitatio
},
},
update: {
role: invitation.role === InvitationRole.ADMIN
? ProjectMemberRole.ADMIN
: ProjectMemberRole.COMMENTATOR,
role:
invitation.role === InvitationRole.ADMIN
? ProjectMemberRole.ADMIN
: ProjectMemberRole.COMMENTATOR,
},
create: {
projectId: invitation.projectId,
userId,
role: invitation.role === InvitationRole.ADMIN
? ProjectMemberRole.ADMIN
: ProjectMemberRole.COMMENTATOR,
role:
invitation.role === InvitationRole.ADMIN
? ProjectMemberRole.ADMIN
: ProjectMemberRole.COMMENTATOR,
},
});
}
+366 -297
View File
@@ -1,13 +1,13 @@
import { db } from '@/lib/db';
import nodemailer from 'nodemailer';
import {
EMAIL_COLORS,
brandedEmailTemplate,
emailButton,
emailHeading,
emailHighlight,
emailRow,
escapeHtml,
EMAIL_COLORS,
brandedEmailTemplate,
emailButton,
emailHeading,
emailHighlight,
emailRow,
escapeHtml,
} from '@/lib/email-brand';
import { logError } from '@/lib/logger';
@@ -19,41 +19,41 @@ import { logError } from '@/lib/logger';
* Send a message via Telegram Bot API with optional inline keyboard button.
*/
async function sendTelegram(
botToken: string,
chatId: string,
text: string,
buttonLabel?: string,
buttonUrl?: string,
botToken: string,
chatId: string,
text: string,
buttonLabel?: string,
buttonUrl?: string
): Promise<boolean> {
try {
const payload: Record<string, unknown> = {
chat_id: chatId,
text,
link_preview_options: { is_disabled: true },
};
try {
const payload: Record<string, unknown> = {
chat_id: chatId,
text,
link_preview_options: { is_disabled: true },
};
// Add inline keyboard button for clickable URL (Telegram requires HTTPS)
if (buttonLabel && buttonUrl && buttonUrl.startsWith('https://')) {
payload.reply_markup = {
inline_keyboard: [[{ text: buttonLabel, url: buttonUrl }]],
};
}
const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.text();
console.error('Telegram API error:', res.status, body);
return false;
}
return true;
} catch (err) {
logError('Telegram send failed:', err);
return false;
// Add inline keyboard button for clickable URL (Telegram requires HTTPS)
if (buttonLabel && buttonUrl && buttonUrl.startsWith('https://')) {
payload.reply_markup = {
inline_keyboard: [[{ text: buttonLabel, url: buttonUrl }]],
};
}
const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.text();
console.error('Telegram API error:', res.status, body);
return false;
}
return true;
} catch (err) {
logError('Telegram send failed:', err);
return false;
}
}
/**
@@ -61,19 +61,19 @@ async function sendTelegram(
* Required env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD
*/
function createSmtpTransport() {
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT || '587');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT || '587');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
if (!host || !user || !pass) return null;
if (!host || !user || !pass) return null;
return nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
});
return nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
});
}
/**
@@ -82,21 +82,22 @@ function createSmtpTransport() {
* Falls back to logging if not configured.
*/
async function sendEmail(to: string, subject: string, html: string): Promise<boolean> {
const transporter = createSmtpTransport();
const fromAddress = process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
const transporter = createSmtpTransport();
const fromAddress =
process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
if (!transporter) {
console.warn('SMTP not configured — skipping email notification');
return false;
}
if (!transporter) {
console.warn('SMTP not configured — skipping email notification');
return false;
}
try {
await transporter.sendMail({ from: fromAddress, to, subject, html });
return true;
} catch (err) {
logError('Email send failed:', err);
return false;
}
try {
await transporter.sendMail({ from: fromAddress, to, subject, html });
return true;
} catch (err) {
logError('Email send failed:', err);
return false;
}
}
// ============================================
@@ -104,20 +105,76 @@ async function sendEmail(to: string, subject: string, html: string): Promise<boo
// ============================================
export type NotificationEvent =
| { type: 'new_video'; projectName: string; videoTitle: string; addedBy: string; url: string }
| { type: 'new_version'; projectName: string; videoTitle: string; versionLabel: string; addedBy: string; url: string }
| { type: 'new_comment'; projectName: string; videoTitle: string; commentAuthor: string; commentText: string; timestamp: string; url: string }
| { type: 'new_reply'; projectName: string; videoTitle: string; replyAuthor: string; replyText: string; parentAuthor: string; timestamp: string; url: string }
| { type: 'approval_requested'; projectName: string; videoTitle: string; versionLabel: string; requestedBy: string; message?: string; url: string }
| { type: 'approval_action'; projectName: string; videoTitle: string; versionLabel: string; actorName: string; action: 'approved' | 'rejected'; note?: string; url: string }
| { type: 'approval_completed'; projectName: string; videoTitle: string; versionLabel: string; approvedByCount: number; url: string }
| { type: 'approval_rejected'; projectName: string; videoTitle: string; versionLabel: string; rejectedBy: string; note?: string; url: string };
| { type: 'new_video'; projectName: string; videoTitle: string; addedBy: string; url: string }
| {
type: 'new_version';
projectName: string;
videoTitle: string;
versionLabel: string;
addedBy: string;
url: string;
}
| {
type: 'new_comment';
projectName: string;
videoTitle: string;
commentAuthor: string;
commentText: string;
timestamp: string;
url: string;
}
| {
type: 'new_reply';
projectName: string;
videoTitle: string;
replyAuthor: string;
replyText: string;
parentAuthor: string;
timestamp: string;
url: string;
}
| {
type: 'approval_requested';
projectName: string;
videoTitle: string;
versionLabel: string;
requestedBy: string;
message?: string;
url: string;
}
| {
type: 'approval_action';
projectName: string;
videoTitle: string;
versionLabel: string;
actorName: string;
action: 'approved' | 'rejected';
note?: string;
url: string;
}
| {
type: 'approval_completed';
projectName: string;
videoTitle: string;
versionLabel: string;
approvedByCount: number;
url: string;
}
| {
type: 'approval_rejected';
projectName: string;
videoTitle: string;
versionLabel: string;
rejectedBy: string;
note?: string;
url: string;
};
/** Structured Telegram message with text body + button label/URL */
interface TelegramMessage {
text: string;
buttonLabel: string;
buttonUrl: string;
text: string;
buttonLabel: string;
buttonUrl: string;
}
/**
@@ -125,107 +182,107 @@ interface TelegramMessage {
* The URL is no longer in the text body — it's attached as a clickable button instead.
*/
function formatTelegramMessage(event: NotificationEvent, timezone: string): TelegramMessage {
const now = formatNow(timezone);
switch (event.type) {
case 'new_video':
return {
text:
`🎬 New Video Added\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Added by: ${event.addedBy}\n` +
`${now}`,
buttonLabel: 'View Video',
buttonUrl: event.url,
};
case 'new_version':
return {
text:
`🎬 New Version Added\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Added by: ${event.addedBy}\n` +
`${now}`,
buttonLabel: 'View Version',
buttonUrl: event.url,
};
case 'new_comment':
return {
text:
`💬 New Comment\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ By: ${event.commentAuthor} at ${event.timestamp}\n` +
`${now}\n\n` +
`"${truncate(event.commentText, 200)}"`,
buttonLabel: 'View Comment',
buttonUrl: event.url,
};
case 'new_reply':
return {
text:
`↩️ New Reply\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`${event.replyAuthor} replied to ${event.parentAuthor}\n` +
`${now}\n\n` +
`"${truncate(event.replyText, 200)}"`,
buttonLabel: 'View Reply',
buttonUrl: event.url,
};
case 'approval_requested':
return {
text:
`✅ Approval Requested\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Requested by: ${event.requestedBy}\n` +
`${now}` +
(event.message ? `\n\n"${truncate(event.message, 200)}"` : ''),
buttonLabel: 'Review Request',
buttonUrl: event.url,
};
case 'approval_action':
return {
text:
`✅ Approval Update\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`${event.actorName} ${event.action}\n` +
`${now}` +
(event.note ? `\n\n"${truncate(event.note, 200)}"` : ''),
buttonLabel: 'Open Request',
buttonUrl: event.url,
};
case 'approval_completed':
return {
text:
`✅ Approval Completed\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Approved by: ${event.approvedByCount}\n` +
`${now}`,
buttonLabel: 'Open Version',
buttonUrl: event.url,
};
case 'approval_rejected':
return {
text:
`⛔ Approval Rejected\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Rejected by: ${event.rejectedBy}\n` +
`${now}` +
(event.note ? `\n\n"${truncate(event.note, 200)}"` : ''),
buttonLabel: 'Open Request',
buttonUrl: event.url,
};
}
const now = formatNow(timezone);
switch (event.type) {
case 'new_video':
return {
text:
`🎬 New Video Added\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Added by: ${event.addedBy}\n` +
`${now}`,
buttonLabel: 'View Video',
buttonUrl: event.url,
};
case 'new_version':
return {
text:
`🎬 New Version Added\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Added by: ${event.addedBy}\n` +
`${now}`,
buttonLabel: 'View Version',
buttonUrl: event.url,
};
case 'new_comment':
return {
text:
`💬 New Comment\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ By: ${event.commentAuthor} at ${event.timestamp}\n` +
`${now}\n\n` +
`"${truncate(event.commentText, 200)}"`,
buttonLabel: 'View Comment',
buttonUrl: event.url,
};
case 'new_reply':
return {
text:
`↩️ New Reply\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`${event.replyAuthor} replied to ${event.parentAuthor}\n` +
`${now}\n\n` +
`"${truncate(event.replyText, 200)}"`,
buttonLabel: 'View Reply',
buttonUrl: event.url,
};
case 'approval_requested':
return {
text:
`✅ Approval Requested\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Requested by: ${event.requestedBy}\n` +
`${now}` +
(event.message ? `\n\n"${truncate(event.message, 200)}"` : ''),
buttonLabel: 'Review Request',
buttonUrl: event.url,
};
case 'approval_action':
return {
text:
`✅ Approval Update\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`${event.actorName} ${event.action}\n` +
`${now}` +
(event.note ? `\n\n"${truncate(event.note, 200)}"` : ''),
buttonLabel: 'Open Request',
buttonUrl: event.url,
};
case 'approval_completed':
return {
text:
`✅ Approval Completed\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Approved by: ${event.approvedByCount}\n` +
`${now}`,
buttonLabel: 'Open Version',
buttonUrl: event.url,
};
case 'approval_rejected':
return {
text:
`⛔ Approval Rejected\n\n` +
`▸ Project: ${event.projectName}\n` +
`▸ Video: ${event.videoTitle}\n` +
`▸ Version: ${event.versionLabel}\n` +
`▸ Rejected by: ${event.rejectedBy}\n` +
`${now}` +
(event.note ? `\n\n"${truncate(event.note, 200)}"` : ''),
buttonLabel: 'Open Request',
buttonUrl: event.url,
};
}
}
// ============================================
@@ -233,24 +290,27 @@ function formatTelegramMessage(event: NotificationEvent, timezone: string): Tele
// ============================================
function emailTemplate(body: string): string {
const baseUrl = process.env.NEXTAUTH_URL || '';
return brandedEmailTemplate(body, {
footerText: 'You received this because email notifications are enabled.',
footerLinkText: 'Unsubscribe · Manage notification settings',
footerLinkUrl: `${baseUrl}/settings`,
});
const baseUrl = process.env.NEXTAUTH_URL || '';
return brandedEmailTemplate(body, {
footerText: 'You received this because email notifications are enabled.',
footerLinkText: 'Unsubscribe · Manage notification settings',
footerLinkUrl: `${baseUrl}/settings`,
});
}
/**
* Format a notification event into an email subject + full branded HTML email.
*/
function formatEmail(event: NotificationEvent, timezone: string): { subject: string; html: string } {
const now = formatNow(timezone);
switch (event.type) {
case 'new_video':
return {
subject: `[OpenFrame] New video in ${event.projectName}: ${event.videoTitle}`,
html: emailTemplate(`
function formatEmail(
event: NotificationEvent,
timezone: string
): { subject: string; html: string } {
const now = formatNow(timezone);
switch (event.type) {
case 'new_video':
return {
subject: `[OpenFrame] New video in ${event.projectName}: ${event.videoTitle}`,
html: emailTemplate(`
<tr>${emailHeading('&#9654;', 'New Video Added')}</tr>
<tr><td style="padding:20px;">
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
@@ -262,11 +322,11 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
${emailButton('View Video &#8594;', event.url)}
</td></tr>
`),
};
case 'new_version':
return {
subject: `[OpenFrame] New version of ${event.videoTitle} in ${event.projectName}`,
html: emailTemplate(`
};
case 'new_version':
return {
subject: `[OpenFrame] New version of ${event.videoTitle} in ${event.projectName}`,
html: emailTemplate(`
<tr>${emailHeading('&#9654;', 'New Version Added')}</tr>
<tr><td style="padding:20px;">
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
@@ -279,11 +339,11 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
${emailButton('View Version &#8594;', event.url)}
</td></tr>
`),
};
case 'new_comment':
return {
subject: `[OpenFrame] New comment on ${event.videoTitle}`,
html: emailTemplate(`
};
case 'new_comment':
return {
subject: `[OpenFrame] New comment on ${event.videoTitle}`,
html: emailTemplate(`
<tr>${emailHeading('&#9679;', 'New Comment')}</tr>
<tr><td style="padding:20px;">
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
@@ -299,11 +359,11 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
${emailButton('View Comment &#8594;', event.url)}
</td></tr>
`),
};
case 'new_reply':
return {
subject: `[OpenFrame] ${event.replyAuthor} replied on ${event.videoTitle}`,
html: emailTemplate(`
};
case 'new_reply':
return {
subject: `[OpenFrame] ${event.replyAuthor} replied on ${event.videoTitle}`,
html: emailTemplate(`
<tr>${emailHeading('&#8617;', 'New Reply')}</tr>
<tr><td style="padding:20px;">
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
@@ -318,11 +378,11 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
${emailButton('View Reply &#8594;', event.url)}
</td></tr>
`),
};
case 'approval_requested':
return {
subject: `[OpenFrame] Approval requested for ${event.versionLabel} in ${event.projectName}`,
html: emailTemplate(`
};
case 'approval_requested':
return {
subject: `[OpenFrame] Approval requested for ${event.versionLabel} in ${event.projectName}`,
html: emailTemplate(`
<tr>${emailHeading('&#10003;', 'Approval Requested')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`A new approval request is waiting for your response.`)}
@@ -337,11 +397,11 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
${emailButton('Review Request &#8594;', event.url)}
</td></tr>
`),
};
case 'approval_action':
return {
subject: `[OpenFrame] Approval ${event.action} by ${event.actorName}`,
html: emailTemplate(`
};
case 'approval_action':
return {
subject: `[OpenFrame] Approval ${event.action} by ${event.actorName}`,
html: emailTemplate(`
<tr>${emailHeading('&#10003;', 'Approval Update')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`${escapeHtml(event.actorName)} ${escapeHtml(event.action)} this request.`)}
@@ -356,11 +416,11 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
${emailButton('Open Request &#8594;', event.url)}
</td></tr>
`),
};
case 'approval_completed':
return {
subject: `[OpenFrame] Approval completed for ${event.versionLabel}`,
html: emailTemplate(`
};
case 'approval_completed':
return {
subject: `[OpenFrame] Approval completed for ${event.versionLabel}`,
html: emailTemplate(`
<tr>${emailHeading('&#10003;', 'Approval Completed')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`All approvers accepted this request.`)}
@@ -374,11 +434,11 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
${emailButton('Open Version &#8594;', event.url)}
</td></tr>
`),
};
case 'approval_rejected':
return {
subject: `[OpenFrame] Approval rejected by ${event.rejectedBy}`,
html: emailTemplate(`
};
case 'approval_rejected':
return {
subject: `[OpenFrame] Approval rejected by ${event.rejectedBy}`,
html: emailTemplate(`
<tr>${emailHeading('&#9940;', 'Approval Rejected')}</tr>
<tr><td style="padding:20px;">
${emailHighlight(`${escapeHtml(event.rejectedBy)} rejected this request.`)}
@@ -393,15 +453,15 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
${emailButton('Open Request &#8594;', event.url)}
</td></tr>
`),
};
}
};
}
}
/**
* Generate branded HTML for test emails sent from settings page.
*/
export function testEmailHtml(): string {
return emailTemplate(`
return emailTemplate(`
<tr>${emailHeading('&#10003;', '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>
@@ -420,73 +480,82 @@ export function testEmailHtml(): string {
* Best-effort — never throws, logs errors.
*/
function isApprovalEvent(event: NotificationEvent): boolean {
return event.type === 'approval_requested'
|| event.type === 'approval_action'
|| event.type === 'approval_completed'
|| event.type === 'approval_rejected';
return (
event.type === 'approval_requested' ||
event.type === 'approval_action' ||
event.type === 'approval_completed' ||
event.type === 'approval_rejected'
);
}
function shouldSendEvent(settings: {
function shouldSendEvent(
settings: {
onNewVideo: boolean;
onNewVersion: boolean;
onNewComment: boolean;
onNewReply: boolean;
onApprovalEvents: boolean;
}, event: NotificationEvent): boolean {
if (event.type === 'new_video') return settings.onNewVideo;
if (event.type === 'new_version') return settings.onNewVersion;
if (event.type === 'new_comment') return settings.onNewComment;
if (event.type === 'new_reply') return settings.onNewReply;
if (isApprovalEvent(event)) return settings.onApprovalEvents;
return false;
},
event: NotificationEvent
): boolean {
if (event.type === 'new_video') return settings.onNewVideo;
if (event.type === 'new_version') return settings.onNewVersion;
if (event.type === 'new_comment') return settings.onNewComment;
if (event.type === 'new_reply') return settings.onNewReply;
if (isApprovalEvent(event)) return settings.onApprovalEvents;
return false;
}
export async function notifyUsers(userIds: string[], event: NotificationEvent): Promise<void> {
try {
const dedupedUserIds = Array.from(new Set(userIds.filter(Boolean)));
if (dedupedUserIds.length === 0) return;
try {
const dedupedUserIds = Array.from(new Set(userIds.filter(Boolean)));
if (dedupedUserIds.length === 0) return;
const settingsList = await db.notificationSetting.findMany({
where: { userId: { in: dedupedUserIds } },
include: { user: { select: { email: true } } },
});
const settingsList = await db.notificationSetting.findMany({
where: { userId: { in: dedupedUserIds } },
include: { user: { select: { email: true } } },
});
await Promise.allSettled(settingsList.map(async (settings) => {
if (!shouldSendEvent(settings, event)) return;
await Promise.allSettled(
settingsList.map(async (settings) => {
if (!shouldSendEvent(settings, event)) return;
const promises: Promise<boolean>[] = [];
const tz = settings.timezone || 'UTC';
const promises: Promise<boolean>[] = [];
const tz = settings.timezone || 'UTC';
const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
if (settings.telegramEnabled && telegramBotToken && settings.telegramChatId) {
const msg = formatTelegramMessage(event, tz);
promises.push(sendTelegram(
telegramBotToken,
settings.telegramChatId,
msg.text,
msg.buttonLabel,
msg.buttonUrl,
));
}
const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
if (settings.telegramEnabled && telegramBotToken && settings.telegramChatId) {
const msg = formatTelegramMessage(event, tz);
promises.push(
sendTelegram(
telegramBotToken,
settings.telegramChatId,
msg.text,
msg.buttonLabel,
msg.buttonUrl
)
);
}
if (settings.emailEnabled && settings.user.email) {
const { subject, html } = formatEmail(event, tz);
promises.push(sendEmail(settings.user.email, subject, html));
}
if (settings.emailEnabled && settings.user.email) {
const { subject, html } = formatEmail(event, tz);
promises.push(sendEmail(settings.user.email, subject, html));
}
await Promise.allSettled(promises);
}));
} catch (err) {
logError('Notification dispatch failed:', err);
}
await Promise.allSettled(promises);
})
);
} catch (err) {
logError('Notification dispatch failed:', err);
}
}
export async function notifyProjectOwner(ownerId: string, event: NotificationEvent): Promise<void> {
try {
await notifyUsers([ownerId], event);
} catch (err) {
logError('Notification dispatch failed:', err);
}
try {
await notifyUsers([ownerId], event);
} catch (err) {
logError('Notification dispatch failed:', err);
}
}
// ============================================
@@ -498,30 +567,30 @@ export async function notifyProjectOwner(ownerId: string, event: NotificationEve
* Returns e.g. "Jan 15, 2025 at 3:45 PM"
*/
function formatNow(timezone: string): string {
try {
return new Date().toLocaleString('en-US', {
timeZone: timezone,
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
} catch {
// Invalid timezone — fall back to UTC
return new Date().toLocaleString('en-US', {
timeZone: 'UTC',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
try {
return new Date().toLocaleString('en-US', {
timeZone: timezone,
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
} catch {
// Invalid timezone — fall back to UTC
return new Date().toLocaleString('en-US', {
timeZone: 'UTC',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
}
function truncate(str: string, maxLen: number): string {
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;
}
+128 -126
View File
@@ -9,13 +9,15 @@ const IMAGE_PATH_PREFIX = '/api/upload/image/';
/** The path prefix for audio URLs served by the upload API. */
const AUDIO_PATH_PREFIX = '/api/upload/audio/';
const CLEANUP_DELETE_CONCURRENCY = 5;
const SAFE_IMAGE_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
const SAFE_AUDIO_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
const SAFE_IMAGE_PATH =
/^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
const SAFE_AUDIO_PATH =
/^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export interface R2CleanupResult {
attempted: number;
failed: number;
failedKeys: string[];
attempted: number;
failed: number;
failedKeys: string[];
}
/**
@@ -23,148 +25,148 @@ export interface R2CleanupResult {
* Accept only canonical upload URLs before deriving a storage key.
*/
export function mediaUrlToKey(url: string): string | null {
if (SAFE_AUDIO_PATH.test(url)) {
const filename = url.slice(AUDIO_PATH_PREFIX.length);
return filename ? `voice/${filename}` : null;
} else if (SAFE_IMAGE_PATH.test(url)) {
const filename = url.slice(IMAGE_PATH_PREFIX.length);
return filename ? `images/${filename}` : null;
}
return null;
if (SAFE_AUDIO_PATH.test(url)) {
const filename = url.slice(AUDIO_PATH_PREFIX.length);
return filename ? `voice/${filename}` : null;
} else if (SAFE_IMAGE_PATH.test(url)) {
const filename = url.slice(IMAGE_PATH_PREFIX.length);
return filename ? `images/${filename}` : null;
}
return null;
}
/**
* Delete a list of media files from R2 (best-effort, logs failures).
*/
export async function deleteMediaFilesBestEffort(mediaUrls: string[]): Promise<R2CleanupResult> {
const invalidUrls: string[] = [];
const mediaKeys = [...new Set(
mediaUrls
.map((url) => {
const key = mediaUrlToKey(url);
if (!key) invalidUrls.push(url);
return key;
})
.filter((key): key is string => Boolean(key))
)];
const failedKeys = new Set<string>();
const invalidUrls: string[] = [];
const mediaKeys = [
...new Set(
mediaUrls
.map((url) => {
const key = mediaUrlToKey(url);
if (!key) invalidUrls.push(url);
return key;
})
.filter((key): key is string => Boolean(key))
),
];
const failedKeys = new Set<string>();
if (invalidUrls.length > 0) {
console.error('Skipping non-canonical media URLs during R2 cleanup', {
rejectedCount: invalidUrls.length,
rejectedSamples: invalidUrls.slice(0, 10),
});
}
await runWithConcurrency(mediaKeys, CLEANUP_DELETE_CONCURRENCY, async (key) => {
try {
await r2Client.send(
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })
);
} catch (err) {
failedKeys.add(key);
logError(`Failed to delete media from R2 (key: ${key}):`, err);
}
if (invalidUrls.length > 0) {
console.error('Skipping non-canonical media URLs during R2 cleanup', {
rejectedCount: invalidUrls.length,
rejectedSamples: invalidUrls.slice(0, 10),
});
}
return {
attempted: mediaKeys.length,
failed: failedKeys.size,
failedKeys: [...failedKeys],
};
await runWithConcurrency(mediaKeys, CLEANUP_DELETE_CONCURRENCY, async (key) => {
try {
await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key }));
} catch (err) {
failedKeys.add(key);
logError(`Failed to delete media from R2 (key: ${key}):`, err);
}
});
return {
attempted: mediaKeys.length,
failed: failedKeys.size,
failedKeys: [...failedKeys],
};
}
/**
* Collect all media URLs from comments under a given video (all versions).
*/
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { videoParentId: videoId },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
videoId,
provider: 'R2_IMAGE',
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { videoParentId: videoId },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
videoId,
provider: 'R2_IMAGE',
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
}
/**
* Collect all media URLs from comments under all videos in a project.
*/
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { projectId } },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
provider: 'R2_IMAGE',
video: { projectId },
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { projectId } },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
provider: 'R2_IMAGE',
video: { projectId },
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
}
/**
* Collect all media URLs from comments under all projects in a workspace.
*/
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { project: { workspaceId } } },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
provider: 'R2_IMAGE',
video: { project: { workspaceId } },
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { project: { workspaceId } } },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
provider: 'R2_IMAGE',
video: { project: { workspaceId } },
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
}
/**
@@ -172,8 +174,8 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
* Call BEFORE deleting the video from the database (cascade would remove comment rows).
*/
export async function cleanupVideoMediaFiles(videoId: string) {
const urls = await collectVideoMediaUrls(videoId);
await deleteMediaFilesBestEffort(urls);
const urls = await collectVideoMediaUrls(videoId);
await deleteMediaFilesBestEffort(urls);
}
/**
@@ -181,8 +183,8 @@ export async function cleanupVideoMediaFiles(videoId: string) {
* Call BEFORE deleting the project from the database.
*/
export async function cleanupProjectMediaFiles(projectId: string) {
const urls = await collectProjectMediaUrls(projectId);
await deleteMediaFilesBestEffort(urls);
const urls = await collectProjectMediaUrls(projectId);
await deleteMediaFilesBestEffort(urls);
}
/**
@@ -190,6 +192,6 @@ export async function cleanupProjectMediaFiles(projectId: string) {
* Call BEFORE deleting the workspace from the database.
*/
export async function cleanupWorkspaceMediaFiles(workspaceId: string) {
const urls = await collectWorkspaceMediaUrls(workspaceId);
await deleteMediaFilesBestEffort(urls);
const urls = await collectWorkspaceMediaUrls(workspaceId);
await deleteMediaFilesBestEffort(urls);
}
+13 -3
View File
@@ -1,4 +1,8 @@
import { GetObjectCommand, type GetObjectCommandInput, type GetObjectCommandOutput } from '@aws-sdk/client-s3';
import {
GetObjectCommand,
type GetObjectCommandInput,
type GetObjectCommandOutput,
} from '@aws-sdk/client-s3';
import { Readable } from 'node:stream';
import { NextResponse } from 'next/server';
import { apiErrors } from '@/lib/api-response';
@@ -42,7 +46,9 @@ function isNotFoundError(error: unknown): boolean {
function isInvalidRangeError(error: unknown): boolean {
const err = error as R2LikeError | null | undefined;
return err?.name === 'InvalidRange' || err?.Code === 'InvalidRange' || getErrorStatus(error) === 416;
return (
err?.name === 'InvalidRange' || err?.Code === 'InvalidRange' || getErrorStatus(error) === 416
);
}
function isPreconditionFailed(error: unknown): boolean {
@@ -68,7 +74,11 @@ function toWebStream(body: unknown): ReadableStream<Uint8Array> | null {
return null;
}
function setIfPresent(headers: Headers, key: string, value: string | number | null | undefined): void {
function setIfPresent(
headers: Headers,
key: string,
value: string | number | null | undefined
): void {
if (value === undefined || value === null) return;
headers.set(key, String(value));
}
+2 -1
View File
@@ -95,7 +95,8 @@ export async function ensureR2BucketExists(): Promise<void> {
await r2Client.send(new HeadBucketCommand({ Bucket: R2_BUCKET_NAME }));
return;
} catch (error) {
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
?.httpStatusCode;
if (statusCode && statusCode !== 404 && statusCode !== 301 && statusCode !== 403) {
throw error;
}
+155 -150
View File
@@ -5,87 +5,87 @@ import { logError } from '@/lib/logger';
const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
const globalForRateLimitCleanup = globalThis as unknown as {
rateLimitCleanupIntervalStarted?: boolean;
rateLimitCleanupIntervalStarted?: boolean;
};
interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
maxRequests: number; // Max requests per window
windowMs: number; // Time window in milliseconds
maxRequests: number; // Max requests per window
}
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: Date;
allowed: boolean;
remaining: number;
resetAt: Date;
}
const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
function isRateLimitDisabled(): boolean {
const rawValue = process.env.DISABLE_RATE_LIMIT?.trim().toLowerCase();
return rawValue !== undefined && TRUTHY_ENV_VALUES.has(rawValue);
const rawValue = process.env.DISABLE_RATE_LIMIT?.trim().toLowerCase();
return rawValue !== undefined && TRUTHY_ENV_VALUES.has(rawValue);
}
if (process.env.NODE_ENV === 'production' && isRateLimitDisabled()) {
throw new Error(
'DISABLE_RATE_LIMIT must not be set in production. ' +
'Remove or unset the environment variable before deploying.'
);
throw new Error(
'DISABLE_RATE_LIMIT must not be set in production. ' +
'Remove or unset the environment variable before deploying.'
);
}
// Industry-standard rate limit defaults per action
export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
// Auth — strict to prevent brute force / credential stuffing
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
'share-unlock': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min per IP
'share-unlock-token': { windowMs: 15 * 60 * 1000, maxRequests: 8 }, // 8 per 15 min per IP+token
// Auth — strict to prevent brute force / credential stuffing
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
'share-unlock': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min per IP
'share-unlock-token': { windowMs: 15 * 60 * 1000, maxRequests: 8 }, // 8 per 15 min per IP+token
// Content creation — moderate limits
comment: { windowMs: 60 * 1000, maxRequests: 15 }, // 15 per minute
'image-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'voice-upload': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'feedback-submit': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
'feedback-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'create-project': { windowMs: 60 * 60 * 1000, maxRequests: 20 }, // 20 per hour
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
'asset-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
'asset-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'asset-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
// Content creation — moderate limits
comment: { windowMs: 60 * 1000, maxRequests: 15 }, // 15 per minute
'image-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'voice-upload': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'feedback-submit': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
'feedback-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'create-project': { windowMs: 60 * 60 * 1000, maxRequests: 20 }, // 20 per hour
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
'asset-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
'asset-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'asset-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
// Search — debounced on client but protect against scripted callers
'search': { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute
// Search — debounced on client but protect against scripted callers
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute
// Watch progress — allow frequent updates but prevent abuse
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)
// Watch progress — allow frequent updates but prevent abuse
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)
// Exports
'comment-export': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
// Exports
'comment-export': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
// Downloads — strict enough to limit upstream probing/cost abuse
'video-download': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
'video-download-prepare': { windowMs: 60 * 1000, maxRequests: 5 }, // 5 per minute
// Downloads — strict enough to limit upstream probing/cost abuse
'video-download': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
'video-download-prepare': { windowMs: 60 * 1000, maxRequests: 5 }, // 5 per minute
// Email verification
'verify-email': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min (clicked link)
'resend-verification': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
// Email verification
'verify-email': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min (clicked link)
'resend-verification': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
// Onboarding — one-time action, very strict
'onboarding-complete': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
// Onboarding — one-time action, very strict
'onboarding-complete': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
// Member management
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
// Member management
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
// Mutations (update/delete) — moderate
'mutate': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute
// Mutations (update/delete) — moderate
mutate: { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute
// General reads — generous
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
// General reads — generous
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
};
/**
@@ -93,37 +93,39 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
* Uses PostgreSQL UNLOGGED table for performance
*/
export async function checkRateLimit(
key: string,
action: string,
config?: RateLimitConfig
key: string,
action: string,
config?: RateLimitConfig
): Promise<RateLimitResult> {
const { windowMs, maxRequests } = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
const { windowMs, maxRequests } = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
if (isRateLimitDisabled()) {
return {
allowed: true,
remaining: maxRequests,
resetAt: new Date(Date.now() + windowMs),
};
}
if (isRateLimitDisabled()) {
return {
allowed: true,
remaining: maxRequests,
resetAt: new Date(Date.now() + windowMs),
};
}
const windowSeconds = Math.floor(windowMs / 1000);
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) };
}
// 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) };
}
try {
// Atomic upsert with window check
// If window expired, reset count; otherwise increment
const result = await db.$queryRaw<Array<{
count: number;
window_start: Date;
is_new_window: boolean;
}>>`
try {
// Atomic upsert with window check
// If window expired, reset count; otherwise increment
const result = await db.$queryRaw<
Array<{
count: number;
window_start: Date;
is_new_window: boolean;
}>
>`
INSERT INTO rate_limits (key, action, count, window_start)
VALUES (${key}, ${action}, 1, NOW())
ON CONFLICT (key, action) DO UPDATE SET
@@ -141,28 +143,28 @@ export async function checkRateLimit(
(window_start = NOW()) as is_new_window
`;
const record = result[0];
const resetAt = new Date(record.window_start.getTime() + windowMs);
const remaining = Math.max(0, maxRequests - record.count);
const allowed = record.count <= maxRequests;
const record = result[0];
const resetAt = new Date(record.window_start.getTime() + windowMs);
const remaining = Math.max(0, maxRequests - record.count);
const allowed = record.count <= maxRequests;
return { allowed, remaining, resetAt };
} catch (error) {
// If table doesn't exist, allow the request but log warning
logError('Rate limit check failed (table may not exist):', error);
return {
allowed: true,
remaining: maxRequests,
resetAt: new Date(Date.now() + windowMs),
};
}
return { allowed, remaining, resetAt };
} catch (error) {
// If table doesn't exist, allow the request but log warning
logError('Rate limit check failed (table may not exist):', error);
return {
allowed: true,
remaining: maxRequests,
resetAt: new Date(Date.now() + windowMs),
};
}
}
// Basic IP format validation — IPv4 or IPv6 (loose check, rejects obvious garbage)
const IP_PATTERN = /^[\da-fA-F.:]+$/;
function isPlausibleIp(value: string): boolean {
return value.length <= 45 && IP_PATTERN.test(value);
return value.length <= 45 && IP_PATTERN.test(value);
}
/**
@@ -184,68 +186,71 @@ function isPlausibleIp(value: string): boolean {
* do so allows clients to spoof their IP and bypass rate limits.
*/
export function getClientIp(request: Request): string {
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
if (mode === 'cloudflare') {
// cf-connecting-ip is injected by Cloudflare and cannot be set by clients
// when origin access is restricted to Cloudflare's IP ranges.
const cfIp = request.headers.get('cf-connecting-ip');
if (cfIp && isPlausibleIp(cfIp)) {
return cfIp;
}
if (mode === 'cloudflare') {
// cf-connecting-ip is injected by Cloudflare and cannot be set by clients
// when origin access is restricted to Cloudflare's IP ranges.
const cfIp = request.headers.get('cf-connecting-ip');
if (cfIp && isPlausibleIp(cfIp)) {
return cfIp;
}
}
if (mode === 'nginx') {
// x-real-ip is set by Nginx's real_ip_header directive (connection-level, not spoofable
// by clients when set_real_ip_from is configured for the upstream proxy).
const realIp = request.headers.get('x-real-ip');
if (realIp && isPlausibleIp(realIp)) return realIp;
if (mode === 'nginx') {
// x-real-ip is set by Nginx's real_ip_header directive (connection-level, not spoofable
// by clients when set_real_ip_from is configured for the upstream proxy).
const realIp = request.headers.get('x-real-ip');
if (realIp && isPlausibleIp(realIp)) return realIp;
// x-forwarded-for last entry added by Nginx when proxy_add_x_forwarded_for is used.
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
const entries = forwardedFor.split(',');
const last = entries[entries.length - 1].trim();
if (isPlausibleIp(last)) return last;
}
// x-forwarded-for last entry added by Nginx when proxy_add_x_forwarded_for is used.
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
const entries = forwardedFor.split(',');
const last = entries[entries.length - 1].trim();
if (isPlausibleIp(last)) return last;
}
}
// No trusted proxy configured — fall back to a constant value.
// Rate limiting will apply per-process; use userId-keyed limits for authenticated endpoints.
return '127.0.0.1';
// No trusted proxy configured — fall back to a constant value.
// Rate limiting will apply per-process; use userId-keyed limits for authenticated endpoints.
return '127.0.0.1';
}
/**
* Create rate limit headers for response
*/
export function rateLimitHeaders(result: RateLimitResult, maxRequests: number): HeadersInit {
return {
'X-RateLimit-Limit': maxRequests.toString(),
'X-RateLimit-Remaining': result.remaining.toString(),
'X-RateLimit-Reset': Math.floor(result.resetAt.getTime() / 1000).toString(),
};
return {
'X-RateLimit-Limit': maxRequests.toString(),
'X-RateLimit-Remaining': result.remaining.toString(),
'X-RateLimit-Reset': Math.floor(result.resetAt.getTime() / 1000).toString(),
};
}
/**
* Cleanup old rate limit entries (call periodically)
*/
export async function cleanupRateLimits(): Promise<void> {
try {
await db.$executeRaw`SELECT cleanup_rate_limits()`;
} catch (error) {
logError('Rate limit cleanup failed:', error);
}
try {
await db.$executeRaw`SELECT cleanup_rate_limits()`;
} catch (error) {
logError('Rate limit cleanup failed:', error);
}
}
// Start cleanup interval once per process to avoid duplicate scheduling on module reload.
if (!globalForRateLimitCleanup.rateLimitCleanupIntervalStarted && typeof setInterval !== 'undefined') {
const interval = setInterval(() => {
cleanupRateLimits().catch((err) => logError('Unexpected error:', err));
}, RATE_LIMIT_CLEANUP_INTERVAL_MS);
if (
!globalForRateLimitCleanup.rateLimitCleanupIntervalStarted &&
typeof setInterval !== 'undefined'
) {
const interval = setInterval(() => {
cleanupRateLimits().catch((err) => logError('Unexpected error:', err));
}, RATE_LIMIT_CLEANUP_INTERVAL_MS);
// Avoid keeping Node.js process alive because of housekeeping timers.
interval.unref?.();
globalForRateLimitCleanup.rateLimitCleanupIntervalStarted = true;
// Avoid keeping Node.js process alive because of housekeeping timers.
interval.unref?.();
globalForRateLimitCleanup.rateLimitCleanupIntervalStarted = true;
}
/**
@@ -255,23 +260,23 @@ if (!globalForRateLimitCleanup.rateLimitCleanupIntervalStarted && typeof setInte
* if (limited) return limited;
*/
export async function rateLimit(
request: Request,
action: string,
config?: RateLimitConfig
request: Request,
action: string,
config?: RateLimitConfig
): Promise<NextResponse | null> {
const ip = getClientIp(request);
const cfg = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
const result = await checkRateLimit(ip, action, cfg);
const ip = getClientIp(request);
const cfg = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
const result = await checkRateLimit(ip, action, cfg);
if (!result.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: rateLimitHeaders(result, cfg.maxRequests),
}
);
}
if (!result.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: rateLimitHeaders(result, cfg.maxRequests),
}
);
}
return null;
return null;
}
+7 -8
View File
@@ -21,7 +21,11 @@ function redirectForBilling() {
redirect(BILLING_REDIRECT);
}
function ensureGuestPolicy(options: { userId?: string; intent: AccessIntent; allowPublicView: boolean }) {
function ensureGuestPolicy(options: {
userId?: string;
intent: AccessIntent;
allowPublicView: boolean;
}) {
const { userId, intent, allowPublicView } = options;
if (userId) return;
@@ -66,9 +70,7 @@ export async function requireAuthOrRedirect() {
return session;
}
export async function requireBillingAccessOrRedirect(options?: {
userId?: string;
}) {
export async function requireBillingAccessOrRedirect(options?: { userId?: string }) {
const resolvedUserId = options?.userId ?? (await auth())?.user?.id;
if (!resolvedUserId) {
@@ -99,10 +101,7 @@ export async function hasCollaboratorBillingBackedAccess(userId: string) {
db.workspace.count({
where: {
owner: buildBillingAccessWhereInput(now),
OR: [
{ ownerId: userId },
{ members: { some: { userId } } },
],
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
},
}),
db.project.count({
+49 -10
View File
@@ -23,10 +23,7 @@ export interface ShareLinkAccessResult {
link: ShareLink | null;
}
function hasRequiredPermission(
actual: SharePermission,
required: SharePermission
): boolean {
function hasRequiredPermission(actual: SharePermission, required: SharePermission): boolean {
if (required === 'VIEW') return actual === 'VIEW' || actual === 'COMMENT';
return actual === 'COMMENT';
}
@@ -67,7 +64,14 @@ export async function validateShareLinkAccess({
});
if (!link) {
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link: null };
return {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
link: null,
};
}
const projectMatches = link.projectId === projectId;
@@ -76,25 +80,60 @@ export async function validateShareLinkAccess({
const permissionMatches = hasRequiredPermission(link.permission, requiredPermission);
if (!projectMatches || !videoMatches || !permissionMatches || isLinkExpired(link)) {
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link };
return {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
link,
};
}
if (!link.project?.workspace.owner || !hasBillingAccess(link.project.workspace.owner)) {
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link };
return {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
link,
};
}
if (link.passwordHash && !passwordVerified) {
if (!presentedPassword) {
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: true, link };
return {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: true,
link,
};
}
if (presentedPassword.length > MAX_SHARE_PASSWORD_LENGTH) {
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: true, link };
return {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: true,
link,
};
}
const isPasswordValid = await bcrypt.compare(presentedPassword, link.passwordHash);
if (!isPasswordValid) {
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: true, link };
return {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: true,
link,
};
}
}
+9 -2
View File
@@ -74,7 +74,11 @@ export function createShareSessionValue(
} satisfies ShareSessionPayload);
}
export function createPendingShareValue(token: string, videoId: string, ttlSeconds = PENDING_TTL_SECONDS): string {
export function createPendingShareValue(
token: string,
videoId: string,
ttlSeconds = PENDING_TTL_SECONDS
): string {
return createSignedValue({
token,
videoId,
@@ -102,7 +106,10 @@ export function getShareSessionFromRequest(
return { token: payload.token, passwordVerified: payload.passwordVerified };
}
export function getPendingShareTokenFromRequest(request: NextRequest, videoId: string): string | null {
export function getPendingShareTokenFromRequest(
request: NextRequest,
videoId: string
): string | null {
const cookieName = getPendingShareCookieName(videoId);
const cookieValue = request.cookies.get(cookieName)?.value;
if (!cookieValue) return null;
+6 -5
View File
@@ -53,9 +53,10 @@ export async function getUserStorageInfo(userId: string): Promise<{
}> {
const usedBytes = await getUserTotalStorageBytes(userId);
const limitBytes = PLAN_STORAGE_LIMIT_BYTES;
const percentage = limitBytes > BigInt(0)
? Math.min(100, Number((usedBytes * BigInt(10000)) / limitBytes) / 100)
: 0;
const percentage =
limitBytes > BigInt(0)
? Math.min(100, Number((usedBytes * BigInt(10000)) / limitBytes) / 100)
: 0;
return { usedBytes, limitBytes, percentage };
}
@@ -71,7 +72,7 @@ export async function getUserStorageInfo(userId: string): Promise<{
*/
export async function enforceStorageQuota(
userId: string,
incomingSizeBytes: bigint,
incomingSizeBytes: bigint
): Promise<NextResponse | null> {
if (!isStripeFeatureEnabled()) {
return null;
@@ -101,7 +102,7 @@ export async function enforceStorageQuota(
*/
export async function reserveStorageQuota(
userId: string,
incomingSizeBytes: bigint,
incomingSizeBytes: bigint
): Promise<{ reservationId: string | null } | { error: NextResponse }> {
if (!isStripeFeatureEnabled()) {
return { reservationId: null };
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
+46 -42
View File
@@ -3,12 +3,12 @@
* Prevents javascript:, data:, and other potentially dangerous URI schemes
*/
export function isValidHttpUrl(urlString: string): boolean {
try {
const url = new URL(urlString);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
try {
const url = new URL(urlString);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
// Matches exactly 6-digit hex colours produced by the annotation canvas (e.g. #FF3B30)
@@ -28,62 +28,66 @@ const MAX_STROKE_WIDTH = 20;
* Returns null when the input is absent or structurally invalid.
*/
export function validateAnnotationStrokes(
data: unknown
data: unknown
): { points: { x: number; y: number }[]; color: string; width: number }[] | null {
if (data === null || data === undefined) return null;
if (!Array.isArray(data)) return null;
if (data.length > MAX_STROKES) return null;
if (data === null || data === undefined) return null;
if (!Array.isArray(data)) return null;
if (data.length > MAX_STROKES) return null;
const result: { points: { x: number; y: number }[]; color: string; width: number }[] = [];
const result: { points: { x: number; y: number }[]; color: string; width: number }[] = [];
for (const stroke of data) {
if (stroke === null || typeof stroke !== 'object' || Array.isArray(stroke)) return null;
for (const stroke of data) {
if (stroke === null || typeof stroke !== 'object' || Array.isArray(stroke)) return null;
const { points, color, width } = stroke as Record<string, unknown>;
const { points, color, width } = stroke as Record<string, unknown>;
if (!Array.isArray(points)) return null;
if (points.length > MAX_POINTS_PER_STROKE) return null;
if (!Array.isArray(points)) return null;
if (points.length > MAX_POINTS_PER_STROKE) return null;
const safePoints: { x: number; y: number }[] = [];
for (const pt of points) {
if (pt === null || typeof pt !== 'object' || Array.isArray(pt)) return null;
const { x, y } = pt as Record<string, unknown>;
if (typeof x !== 'number' || !isFinite(x)) return null;
if (typeof y !== 'number' || !isFinite(y)) return null;
safePoints.push({ x, y });
}
if (typeof color !== 'string' || !ANNOTATION_COLOR_RE.test(color)) return null;
if (typeof width !== 'number' || width < MIN_STROKE_WIDTH || width > MAX_STROKE_WIDTH) return null;
result.push({ points: safePoints, color, width });
const safePoints: { x: number; y: number }[] = [];
for (const pt of points) {
if (pt === null || typeof pt !== 'object' || Array.isArray(pt)) return null;
const { x, y } = pt as Record<string, unknown>;
if (typeof x !== 'number' || !isFinite(x)) return null;
if (typeof y !== 'number' || !isFinite(y)) return null;
safePoints.push({ x, y });
}
return result;
if (typeof color !== 'string' || !ANNOTATION_COLOR_RE.test(color)) return null;
if (typeof width !== 'number' || width < MIN_STROKE_WIDTH || width > MAX_STROKE_WIDTH)
return null;
result.push({ points: safePoints, color, width });
}
return result;
}
/**
* Validates a URL and returns an error message if invalid
*/
export function validateUrl(urlString: string, fieldName: string = 'URL'): string | null {
if (!urlString || typeof urlString !== 'string') {
return `${fieldName} is required`;
}
if (!urlString || typeof urlString !== 'string') {
return `${fieldName} is required`;
}
if (!isValidHttpUrl(urlString)) {
return `${fieldName} must be a valid HTTP or HTTPS URL`;
}
if (!isValidHttpUrl(urlString)) {
return `${fieldName} must be a valid HTTP or HTTPS URL`;
}
return null;
return null;
}
/**
* Validates an optional URL - returns null if empty/undefined, error if invalid
*/
export function validateOptionalUrl(urlString: string | null | undefined, fieldName: string = 'URL'): string | null {
if (!urlString) {
return null; // Optional URLs can be empty
}
export function validateOptionalUrl(
urlString: string | null | undefined,
fieldName: string = 'URL'
): string | null {
if (!urlString) {
return null; // Optional URLs can be empty
}
return validateUrl(urlString, fieldName);
return validateUrl(urlString, fieldName);
}
+32 -16
View File
@@ -9,8 +9,10 @@ import { validateShareLinkAccess } from '@/lib/share-links';
const IMAGE_PROXY_PREFIX = '/api/upload/image/';
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
export const SAFE_IMAGE_PROXY_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export const SAFE_AUDIO_PROXY_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export const SAFE_IMAGE_PROXY_PATH =
/^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export const SAFE_AUDIO_PROXY_PATH =
/^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
export type VideoAssetAccessContext = {
@@ -38,7 +40,10 @@ export type VideoAssetAccessContext = {
viewerGuestIdentityId: string | null;
};
export function sanitizeAssetDisplayName(value: string | null | undefined, fallback: string): string {
export function sanitizeAssetDisplayName(
value: string | null | undefined,
fallback: string
): string {
const raw = typeof value === 'string' ? value : '';
const normalized = raw
.replace(/[\u0000-\u001F\u007F]/g, '')
@@ -89,15 +94,18 @@ export function mediaUrlToR2Key(url: string): string | null {
export function canDeleteAssetForViewer(
asset: Pick<VideoAsset, 'uploadedByUserId' | 'uploadedByGuestIdentityId'>,
viewer: Pick<VideoAssetAccessContext, 'canManageAssets' | 'viewerUserId' | 'viewerGuestIdentityId'>
viewer: Pick<
VideoAssetAccessContext,
'canManageAssets' | 'viewerUserId' | 'viewerGuestIdentityId'
>
): boolean {
if (viewer.canManageAssets) return true;
if (viewer.viewerUserId && asset.uploadedByUserId === viewer.viewerUserId) return true;
if (
!viewer.viewerUserId
&& viewer.viewerGuestIdentityId
&& asset.uploadedByGuestIdentityId
&& asset.uploadedByGuestIdentityId === viewer.viewerGuestIdentityId
!viewer.viewerUserId &&
viewer.viewerGuestIdentityId &&
asset.uploadedByGuestIdentityId &&
asset.uploadedByGuestIdentityId === viewer.viewerGuestIdentityId
) {
return true;
}
@@ -140,17 +148,25 @@ export async function getVideoAssetAccessContext(
const shareSession = getShareSessionFromRequest(request, video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: video.projectId,
videoId: video.id,
requiredPermission,
passwordVerified: shareSession.passwordVerified,
})
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link: null };
token: shareSession.token,
projectId: video.projectId,
videoId: video.id,
requiredPermission,
passwordVerified: shareSession.passwordVerified,
})
: {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
link: null,
};
const hasViewAccess = access.hasAccess || shareAccess.hasAccess;
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
const canCommentWithShare = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
const canCommentWithShare =
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
const canUploadAssets = canCommentWithMembership || canCommentWithShare;
const canDownloadAssets = !!session?.user?.id && hasViewAccess;
+48 -47
View File
@@ -6,64 +6,65 @@ import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
// e.g. https://iframe.mediadelivery.net/play/libraryId/videoId
// e.g. https://video.bunnycdn.com/play/libraryId/videoId
const BUNNY_PATTERNS = [
/(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)\/(?:play|embed)\/[0-9]+\/([a-zA-Z0-9_-]+)/,
/(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)\/(?:play|embed)\/[0-9]+\/([a-zA-Z0-9_-]+)/,
];
export const bunnyProvider: VideoProvider = {
id: 'bunny',
name: 'Bunny Stream',
icon: 'Video',
id: 'bunny',
name: 'Bunny Stream',
icon: 'Video',
canHandle(url: string): boolean {
return BUNNY_PATTERNS.some(pattern => pattern.test(url));
},
canHandle(url: string): boolean {
return BUNNY_PATTERNS.some((pattern) => pattern.test(url));
},
extractVideoId(url: string): string | null {
for (const pattern of BUNNY_PATTERNS) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
},
extractVideoId(url: string): string | null {
for (const pattern of BUNNY_PATTERNS) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
},
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
// Requires library ID, but our current DB only stores `videoId` for standard providers
// For Bunny, we typically store the full embed URL as `originalUrl`
// So if this function is called, we try to extract it from the environment or default
const libraryId = process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID || process.env.BUNNY_STREAM_LIBRARY_ID || '0';
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
// Requires library ID, but our current DB only stores `videoId` for standard providers
// For Bunny, we typically store the full embed URL as `originalUrl`
// So if this function is called, we try to extract it from the environment or default
const libraryId =
process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID || process.env.BUNNY_STREAM_LIBRARY_ID || '0';
const params = new URLSearchParams();
const params = new URLSearchParams();
if (options.autoplay) params.set('autoplay', 'true');
if (options.loop) params.set('loop', 'true');
if (options.muted) params.set('muted', 'true');
if (options.autoplay) params.set('autoplay', 'true');
if (options.loop) params.set('loop', 'true');
if (options.muted) params.set('muted', 'true');
// We can use video.bunnycdn.com or iframe.mediadelivery.net
return `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}?${params.toString()}`;
},
// We can use video.bunnycdn.com or iframe.mediadelivery.net
return `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}?${params.toString()}`;
},
getThumbnailUrl(videoId: string): string {
const bunnyCdnHostname = resolveServerBunnyCdnHostname();
if (!bunnyCdnHostname) return '';
return `https://${bunnyCdnHostname}/${videoId}/thumbnail.jpg`;
},
getThumbnailUrl(videoId: string): string {
const bunnyCdnHostname = resolveServerBunnyCdnHostname();
if (!bunnyCdnHostname) return '';
return `https://${bunnyCdnHostname}/${videoId}/thumbnail.jpg`;
},
async getMetadata(videoId: string): Promise<VideoMetadata> {
const cacheKey = `bunny:${videoId}`;
const cached = getCachedMetadata(cacheKey);
if (cached) return cached;
async getMetadata(videoId: string): Promise<VideoMetadata> {
const cacheKey = `bunny:${videoId}`;
const cached = getCachedMetadata(cacheKey);
if (cached) return cached;
// We can't fetch title/duration via public API without an API key,
// so we return basic metadata. When videos are uploaded via our server,
// the title will be passed during creation.
const fallback: VideoMetadata = {
title: 'Bunny Video',
thumbnailUrl: this.getThumbnailUrl(videoId, 'large'),
};
// We can't fetch title/duration via public API without an API key,
// so we return basic metadata. When videos are uploaded via our server,
// the title will be passed during creation.
const fallback: VideoMetadata = {
title: 'Bunny Video',
thumbnailUrl: this.getThumbnailUrl(videoId, 'large'),
};
setCachedMetadata(cacheKey, fallback);
return fallback;
},
setCachedMetadata(cacheKey, fallback);
return fallback;
},
};
+5 -7
View File
@@ -1,9 +1,7 @@
import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
// Direct video URL patterns (for future self-hosted videos)
const DIRECT_VIDEO_PATTERNS = [
/\.(mp4|webm|ogg|mov)(\?.*)?$/i,
];
const DIRECT_VIDEO_PATTERNS = [/\.(mp4|webm|ogg|mov)(\?.*)?$/i];
// Security: Validate URL protocol to prevent XSS
function isValidVideoUrl(url: string): boolean {
@@ -13,7 +11,7 @@ function isValidVideoUrl(url: string): boolean {
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
return DIRECT_VIDEO_PATTERNS.some(pattern => pattern.test(url));
return DIRECT_VIDEO_PATTERNS.some((pattern) => pattern.test(url));
} catch {
return false;
}
@@ -42,9 +40,9 @@ export const directProvider: VideoProvider = {
// For direct videos, we'll use HTML5 video player
// The videoId IS the URL for direct uploads
const params = new URLSearchParams();
if (options.startTime) params.set('t', String(Math.floor(options.startTime)));
const queryString = params.toString();
return `${videoId}${queryString ? `#t=${options.startTime}` : ''}`;
},
@@ -61,7 +59,7 @@ export const directProvider: VideoProvider = {
// This is a placeholder implementation
const filename = videoId.split('/').pop() || 'Video';
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '');
return {
title: nameWithoutExt,
thumbnailUrl: this.getThumbnailUrl(videoId),
+10 -10
View File
@@ -10,16 +10,10 @@ import { logError } from '@/lib/logger';
export * from './types';
// Registry of all available providers
const providers: VideoProvider[] = [
youtubeProvider,
directProvider,
bunnyProvider,
];
const providers: VideoProvider[] = [youtubeProvider, directProvider, bunnyProvider];
// Provider lookup map for quick access
const providerMap = new Map<string, VideoProvider>(
providers.map(p => [p.id, p])
);
const providerMap = new Map<string, VideoProvider>(providers.map((p) => [p.id, p]));
/**
* Detect which provider can handle a given URL
@@ -91,7 +85,10 @@ export async function fetchVideoMetadata(source: VideoSource): Promise<VideoMeta
/**
* Get embed URL for a video source
*/
export function getEmbedUrl(source: VideoSource, options?: Parameters<VideoProvider['getEmbedUrl']>[1]): string | null {
export function getEmbedUrl(
source: VideoSource,
options?: Parameters<VideoProvider['getEmbedUrl']>[1]
): string | null {
const provider = getProvider(source.providerId);
if (!provider) {
@@ -104,7 +101,10 @@ export function getEmbedUrl(source: VideoSource, options?: Parameters<VideoProvi
/**
* Get thumbnail URL for a video source
*/
export function getThumbnailUrl(source: VideoSource, size?: Parameters<VideoProvider['getThumbnailUrl']>[1]): string | null {
export function getThumbnailUrl(
source: VideoSource,
size?: Parameters<VideoProvider['getThumbnailUrl']>[1]
): string | null {
const provider = getProvider(source.providerId);
if (!provider) {
+5 -1
View File
@@ -32,7 +32,11 @@ export function getCachedMetadata(key: string): VideoMetadata | null {
return entry.value;
}
export function setCachedMetadata(key: string, value: VideoMetadata, ttlMs: number = DEFAULT_TTL_MS): void {
export function setCachedMetadata(
key: string,
value: VideoMetadata,
ttlMs: number = DEFAULT_TTL_MS
): void {
cache.set(key, { value, expiresAt: Date.now() + ttlMs });
pruneIfNeeded();
}
+9 -9
View File
@@ -13,7 +13,7 @@ export const youtubeProvider: VideoProvider = {
icon: 'Youtube',
canHandle(url: string): boolean {
return YOUTUBE_PATTERNS.some(pattern => pattern.test(url));
return YOUTUBE_PATTERNS.some((pattern) => pattern.test(url));
},
extractVideoId(url: string): string | null {
@@ -28,21 +28,21 @@ export const youtubeProvider: VideoProvider = {
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
const params = new URLSearchParams();
// Enable JS API for programmatic control
params.set('enablejsapi', '1');
params.set('origin', typeof window !== 'undefined' ? window.location.origin : '');
if (options.autoplay) params.set('autoplay', '1');
if (options.startTime) params.set('start', String(Math.floor(options.startTime)));
if (options.controls === false) params.set('controls', '0');
if (options.loop) params.set('loop', '1');
if (options.muted) params.set('mute', '1');
// Better UX options
params.set('rel', '0'); // Don't show related videos from other channels
params.set('modestbranding', '1'); // Minimal YouTube branding
return `https://www.youtube.com/embed/${videoId}?${params.toString()}`;
},
@@ -53,7 +53,7 @@ export const youtubeProvider: VideoProvider = {
large: 'hqdefault', // 480x360
maxres: 'maxresdefault', // 1280x720
};
return `https://img.youtube.com/vi/${videoId}/${sizeMap[size]}.jpg`;
},
@@ -68,13 +68,13 @@ export const youtubeProvider: VideoProvider = {
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`,
{ signal: AbortSignal.timeout(5000) }
);
if (!response.ok) {
throw new Error('Failed to fetch video metadata');
}
const data = await response.json();
const metadata: VideoMetadata = {
title: data.title,
thumbnailUrl: data.thumbnail_url,