mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-12 01:46:08 +00:00
Add guest upload tokens and share-session aware permissions
This commit is contained in:
+39
-11
@@ -202,35 +202,63 @@ export const getCachedUserMediaStorage = unstable_cache(
|
||||
const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
|
||||
try {
|
||||
const fileSizes = await listAllR2FileSizes();
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
const mediaComments = await db.comment.findMany({
|
||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], authorId: { not: null } },
|
||||
select: { authorId: true, voiceUrl: true, imageUrl: true }
|
||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
||||
select: {
|
||||
voiceUrl: true,
|
||||
imageUrl: true,
|
||||
version: {
|
||||
select: {
|
||||
video: {
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
workspace: {
|
||||
select: { ownerId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const comment of mediaComments) {
|
||||
if (!comment.authorId) continue;
|
||||
const billedUserId = comment.version.video.project.workspace.ownerId;
|
||||
if (!billedUserId) continue;
|
||||
|
||||
if (!userStorage[comment.authorId]) {
|
||||
userStorage[comment.authorId] = { 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 size = fileSizes.get(r2Key) || 0;
|
||||
userStorage[comment.authorId].voice += size;
|
||||
userStorage[comment.authorId].total += size;
|
||||
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||
if (!seenKeys.has(dedupeKey)) {
|
||||
seenKeys.add(dedupeKey);
|
||||
const size = 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 size = fileSizes.get(r2Key) || 0;
|
||||
userStorage[comment.authorId].image += size;
|
||||
userStorage[comment.authorId].total += size;
|
||||
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||
if (!seenKeys.has(dedupeKey)) {
|
||||
seenKeys.add(dedupeKey);
|
||||
const size = fileSizes.get(r2Key) || 0;
|
||||
userStorage[billedUserId].image += size;
|
||||
userStorage[billedUserId].total += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface DefaultCommentTag {
|
||||
name: string;
|
||||
color: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMMENT_TAGS: DefaultCommentTag[] = [
|
||||
{ name: 'Feedback', color: '#3B82F6', position: 0 },
|
||||
{ name: 'Technical', color: '#EF4444', position: 1 },
|
||||
{ name: 'Creative', color: '#8B5CF6', position: 2 },
|
||||
{ name: 'Approved', color: '#22C55E', position: 3 },
|
||||
{ name: 'Urgent', color: '#F59E0B', position: 4 },
|
||||
];
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createHmac, randomUUID, timingSafeEqual } from 'crypto';
|
||||
import type { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GUEST_IDENTITY_COOKIE_NAME = 'openframe_guest_identity';
|
||||
const GUEST_IDENTITY_TTL_SECONDS = 60 * 60 * 24 * 180; // 180 days
|
||||
|
||||
interface GuestIdentityPayload {
|
||||
gid: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function getGuestIdentitySecret(): string {
|
||||
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.');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function sign(value: string): string {
|
||||
return createHmac('sha256', getGuestIdentitySecret()).update(value).digest('base64url');
|
||||
}
|
||||
|
||||
function createSignedValue(payload: GuestIdentityPayload): string {
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
const signature = sign(encodedPayload);
|
||||
return `${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
function parseSignedValue(value: string): GuestIdentityPayload | null {
|
||||
const [encodedPayload, signature] = value.split('.');
|
||||
if (!encodedPayload || !signature) return null;
|
||||
|
||||
const expectedSignature = sign(encodedPayload);
|
||||
const actualBytes = Buffer.from(signature, 'utf8');
|
||||
const expectedBytes = Buffer.from(expectedSignature, 'utf8');
|
||||
if (actualBytes.length !== expectedBytes.length) return null;
|
||||
if (!timingSafeEqual(actualBytes, expectedBytes)) return null;
|
||||
|
||||
try {
|
||||
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 <= Math.floor(Date.now() / 1000)) return null;
|
||||
return { gid: payload.gid, exp: payload.exp };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createGuestIdentityValue(identityId: string): string {
|
||||
return createSignedValue({
|
||||
gid: identityId,
|
||||
exp: Math.floor(Date.now() / 1000) + GUEST_IDENTITY_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
function cookieOptions(maxAge: number) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGuestIdentityFromRequest(request: NextRequest): string | null {
|
||||
const raw = request.cookies.get(GUEST_IDENTITY_COOKIE_NAME)?.value;
|
||||
if (!raw) return null;
|
||||
const payload = parseSignedValue(raw);
|
||||
return payload?.gid ?? null;
|
||||
}
|
||||
|
||||
export function ensureGuestIdentityFromRequest(request: NextRequest): { identityId: string; shouldSetCookie: boolean } {
|
||||
const existingIdentity = getGuestIdentityFromRequest(request);
|
||||
if (existingIdentity) {
|
||||
return { identityId: existingIdentity, shouldSetCookie: false };
|
||||
}
|
||||
|
||||
return { identityId: randomUUID(), shouldSetCookie: true };
|
||||
}
|
||||
|
||||
export function setGuestIdentityCookie(response: NextResponse, identityId: string): void {
|
||||
response.cookies.set(
|
||||
GUEST_IDENTITY_COOKIE_NAME,
|
||||
createGuestIdentityValue(identityId),
|
||||
cookieOptions(GUEST_IDENTITY_TTL_SECONDS)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createHash, createHmac, timingSafeEqual } from 'crypto';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { checkRateLimit, getClientIp, rateLimitHeaders } from '@/lib/rate-limit';
|
||||
|
||||
const GUEST_UPLOAD_TOKEN_TYPE = 'guest-upload';
|
||||
const DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS = 60 * 3;
|
||||
const GUEST_UPLOAD_VIDEO_WINDOW_MS = 15 * 60 * 1000;
|
||||
const GUEST_UPLOAD_VIDEO_MAX_REQUESTS = 12;
|
||||
const GUEST_UPLOAD_SESSION_WINDOW_MS = 15 * 60 * 1000;
|
||||
const GUEST_UPLOAD_SESSION_MAX_REQUESTS = 8;
|
||||
|
||||
export type GuestUploadIntent = 'audio' | 'image';
|
||||
|
||||
interface GuestUploadTokenPayload {
|
||||
typ: typeof GUEST_UPLOAD_TOKEN_TYPE;
|
||||
pid: string;
|
||||
vid: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
intent: GuestUploadIntent;
|
||||
ctx: string;
|
||||
}
|
||||
|
||||
interface GuestUploadTokenSubject {
|
||||
projectId: string;
|
||||
videoId: string;
|
||||
intent: GuestUploadIntent;
|
||||
context: string;
|
||||
}
|
||||
|
||||
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;
|
||||
if (!secret) {
|
||||
throw new Error('Missing GUEST_UPLOAD_TOKEN_SECRET, AUTH_SECRET, or NEXTAUTH_SECRET.');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function signPayload(encodedPayload: string): string {
|
||||
return createHmac('sha256', getGuestUploadTokenSecret()).update(encodedPayload).digest('base64url');
|
||||
}
|
||||
|
||||
function getCloudflareClientIp(request: Request): string | null {
|
||||
const cfIp = request.headers.get('cf-connecting-ip')?.trim();
|
||||
if (!cfIp) return null;
|
||||
if (cfIp.length > 45 || !TRUSTED_IP_PATTERN.test(cfIp)) return null;
|
||||
return cfIp;
|
||||
}
|
||||
|
||||
function resolveTrustedClientIp(request: Request): string | null {
|
||||
const cfIp = getCloudflareClientIp(request);
|
||||
if (cfIp) return cfIp;
|
||||
|
||||
// In production, require Cloudflare-provided client IP to avoid spoofable header fallbacks.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getClientIp(request);
|
||||
}
|
||||
|
||||
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')
|
||||
&& typeof payload.ctx === 'string';
|
||||
}
|
||||
|
||||
export function deriveGuestUploadContext(request: Request, shareToken: string | null): string | null {
|
||||
const ip = resolveTrustedClientIp(request);
|
||||
if (!ip) return null;
|
||||
|
||||
const shareFingerprint = shareToken
|
||||
? createHash('sha256').update(shareToken).digest('hex').slice(0, 24)
|
||||
: 'public';
|
||||
return `${ip}:${shareFingerprint}`;
|
||||
}
|
||||
|
||||
export function createGuestUploadToken(
|
||||
subject: GuestUploadTokenSubject,
|
||||
ttlSeconds = DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS
|
||||
): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: GuestUploadTokenPayload = {
|
||||
typ: GUEST_UPLOAD_TOKEN_TYPE,
|
||||
pid: subject.projectId,
|
||||
vid: subject.videoId,
|
||||
iat: now,
|
||||
exp: now + ttlSeconds,
|
||||
intent: subject.intent,
|
||||
ctx: subject.context,
|
||||
};
|
||||
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
const signature = signPayload(encodedPayload);
|
||||
return `${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyGuestUploadToken(token: string, subject: GuestUploadTokenSubject): boolean {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const [encodedPayload, providedSignature] = parts;
|
||||
if (!encodedPayload || !providedSignature) return false;
|
||||
|
||||
const expectedSignature = signPayload(encodedPayload);
|
||||
const providedBuffer = Buffer.from(providedSignature, 'utf8');
|
||||
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
||||
if (providedBuffer.length !== expectedBuffer.length) return false;
|
||||
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return false;
|
||||
|
||||
const payloadRaw = Buffer.from(encodedPayload, 'base64url').toString('utf8');
|
||||
const payloadUnknown: unknown = JSON.parse(payloadRaw);
|
||||
if (!isValidPayload(payloadUnknown)) return false;
|
||||
|
||||
const payload = payloadUnknown;
|
||||
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;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enforceGuestUploadQuota(
|
||||
request: Request,
|
||||
videoId: string,
|
||||
intent: GuestUploadIntent,
|
||||
shareToken: string | null
|
||||
): Promise<NextResponse | null> {
|
||||
const ip = resolveTrustedClientIp(request);
|
||||
if (!ip) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing trusted client IP header' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const videoScoped = await checkRateLimit(
|
||||
`${ip}:guest-upload:${intent}:video:${videoId}`,
|
||||
`guest-upload-${intent}-video`,
|
||||
{ windowMs: GUEST_UPLOAD_VIDEO_WINDOW_MS, maxRequests: GUEST_UPLOAD_VIDEO_MAX_REQUESTS }
|
||||
);
|
||||
if (!videoScoped.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many uploads for this video. Please wait before uploading again.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders(videoScoped, GUEST_UPLOAD_VIDEO_MAX_REQUESTS),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!shareToken) return null;
|
||||
|
||||
const shareFingerprint = createHash('sha256').update(shareToken).digest('hex').slice(0, 24);
|
||||
const sessionScoped = await checkRateLimit(
|
||||
`${shareFingerprint}:guest-upload:${intent}`,
|
||||
`guest-upload-${intent}-session`,
|
||||
{ windowMs: GUEST_UPLOAD_SESSION_WINDOW_MS, maxRequests: GUEST_UPLOAD_SESSION_MAX_REQUESTS }
|
||||
);
|
||||
if (!sessionScoped.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many uploads for this share session. Please wait before uploading again.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders(sessionScoped, GUEST_UPLOAD_SESSION_MAX_REQUESTS),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const guestUploadTokenTtlSeconds = DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS;
|
||||
+7
-5
@@ -16,6 +16,7 @@ interface ValidateShareLinkParams {
|
||||
export interface ShareLinkAccessResult {
|
||||
hasAccess: boolean;
|
||||
canComment: boolean;
|
||||
canDownload: boolean;
|
||||
allowGuests: boolean;
|
||||
requiresPassword: boolean;
|
||||
link: ShareLink | null;
|
||||
@@ -47,7 +48,7 @@ export async function validateShareLinkAccess({
|
||||
});
|
||||
|
||||
if (!link) {
|
||||
return { hasAccess: false, canComment: 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;
|
||||
@@ -56,27 +57,28 @@ export async function validateShareLinkAccess({
|
||||
const permissionMatches = hasRequiredPermission(link.permission, requiredPermission);
|
||||
|
||||
if (!projectMatches || !videoMatches || !permissionMatches || isLinkExpired(link)) {
|
||||
return { hasAccess: false, canComment: 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, 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, 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, allowGuests: false, requiresPassword: true, link };
|
||||
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: true, link };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
canComment: link.permission === 'COMMENT',
|
||||
canDownload: link.allowDownloads,
|
||||
allowGuests: link.allowGuests,
|
||||
requiresPassword: false,
|
||||
link,
|
||||
|
||||
Reference in New Issue
Block a user