mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
refactor: eslint and prettier conflict will be resolved and formatted
This commit is contained in:
+378
-359
@@ -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
@@ -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,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
@@ -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
@@ -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 else’s 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
@@ -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
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 →', 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
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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('▶', '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 →', 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('▶', '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 →', 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('●', '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 →', 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('↩', '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 →', 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('✓', '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 →', 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('✓', '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 →', 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('✓', '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 →', 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('⛔', '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 →', event.url)}
|
||||
</td></tr>
|
||||
`),
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate branded HTML for test emails sent from settings page.
|
||||
*/
|
||||
export function testEmailHtml(): string {
|
||||
return emailTemplate(`
|
||||
return emailTemplate(`
|
||||
<tr>${emailHeading('✓', 'Test Notification')}</tr>
|
||||
<tr><td style="padding:20px;">
|
||||
<p style="margin:0 0 8px;font-size:14px;color:${EMAIL_COLORS.text};">Email notifications are working.</p>
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user