Make user media uploads session-backed
This commit is contained in:
+59
-41
@@ -22,6 +22,24 @@ interface StorageUploadResult {
|
||||
key: string;
|
||||
}
|
||||
|
||||
function buildStorageUrl(
|
||||
key: string,
|
||||
endpoint: string | null | undefined,
|
||||
publicBaseUrl: string | null | undefined,
|
||||
region: string,
|
||||
bucket: string
|
||||
): string {
|
||||
if (publicBaseUrl) {
|
||||
return `${publicBaseUrl.replace(/\/$/, '')}/${key}`;
|
||||
}
|
||||
|
||||
if (endpoint) {
|
||||
return `${endpoint}/${bucket}/${key}`;
|
||||
}
|
||||
|
||||
return `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt S3 credentials from encrypted storage
|
||||
*/
|
||||
@@ -76,23 +94,48 @@ export async function uploadToUserStorage(
|
||||
encryptedSecretKey: string,
|
||||
password: string
|
||||
): Promise<StorageUploadResult> {
|
||||
// Decrypt credentials
|
||||
const { accessKeyId, secretAccessKey } = decryptS3Credentials(
|
||||
encryptedAccessKey,
|
||||
encryptedSecretKey,
|
||||
password
|
||||
);
|
||||
|
||||
// Create S3 client
|
||||
return uploadWithStorageCredentials(
|
||||
file,
|
||||
filename,
|
||||
mimeType,
|
||||
provider,
|
||||
endpoint,
|
||||
publicBaseUrl,
|
||||
region,
|
||||
bucket,
|
||||
accessKeyId,
|
||||
secretAccessKey
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadWithStorageCredentials(
|
||||
file: Buffer,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
_provider: StorageProvider,
|
||||
endpoint: string | null,
|
||||
publicBaseUrl: string | null,
|
||||
region: string,
|
||||
bucket: string,
|
||||
accessKeyId: string,
|
||||
secretAccessKey: string
|
||||
): Promise<StorageUploadResult> {
|
||||
const normalizedRegion = region || 'us-east-1';
|
||||
|
||||
const s3 = createS3Client({
|
||||
endpoint: endpoint || undefined,
|
||||
region,
|
||||
region: normalizedRegion,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
bucket,
|
||||
});
|
||||
|
||||
// Upload file
|
||||
const key = `synapsis/${filename}`;
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
@@ -102,19 +145,7 @@ export async function uploadToUserStorage(
|
||||
ContentType: mimeType,
|
||||
}));
|
||||
|
||||
// Construct URL based on provider
|
||||
let url: string;
|
||||
|
||||
// Priority: user's publicBaseUrl > construct from endpoint > AWS format
|
||||
if (publicBaseUrl) {
|
||||
url = `${publicBaseUrl.replace(/\/$/, '')}/${key}`;
|
||||
} else if (endpoint) {
|
||||
// Custom endpoint (R2, B2, Contabo)
|
||||
url = `${endpoint}/${bucket}/${key}`;
|
||||
} else {
|
||||
// AWS S3 standard URL
|
||||
url = `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
|
||||
}
|
||||
const url = buildStorageUrl(key, endpoint, publicBaseUrl, normalizedRegion, bucket);
|
||||
|
||||
return { url, key };
|
||||
}
|
||||
@@ -193,33 +224,20 @@ export async function generateAndUploadAvatarToUserStorage(
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// 2. Upload to user's S3
|
||||
const s3 = createS3Client({
|
||||
endpoint: endpoint || undefined,
|
||||
const result = await uploadWithStorageCredentials(
|
||||
buffer,
|
||||
`avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.png`,
|
||||
'image/png',
|
||||
's3',
|
||||
endpoint || null,
|
||||
publicBaseUrl || null,
|
||||
region,
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
bucket,
|
||||
});
|
||||
accessKey,
|
||||
secretKey
|
||||
);
|
||||
|
||||
const key = `synapsis/avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.png`;
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: 'image/png',
|
||||
}));
|
||||
|
||||
// 3. Return URL
|
||||
// Priority: user's publicBaseUrl > construct from endpoint > AWS format
|
||||
if (publicBaseUrl) {
|
||||
return `${publicBaseUrl.replace(/\/$/, '')}/${key}`;
|
||||
}
|
||||
if (endpoint) {
|
||||
return `${endpoint}/${bucket}/${key}`;
|
||||
}
|
||||
return `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
|
||||
return result.url;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating/uploading avatar:', error);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import crypto from 'crypto';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { users } from '@/db';
|
||||
import { decryptS3Credentials, type StorageProvider } from '@/lib/storage/s3';
|
||||
|
||||
const STORAGE_SESSION_COOKIE = 'synapsis_storage_session';
|
||||
const STORAGE_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
interface StorageSessionPayload {
|
||||
userId: string;
|
||||
provider: StorageProvider;
|
||||
endpoint: string | null;
|
||||
publicBaseUrl: string | null;
|
||||
region: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
|
||||
if (!secret || secret.length < 16) {
|
||||
throw new Error('AUTH_SECRET is not configured for storage sessions');
|
||||
}
|
||||
|
||||
return crypto.createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
function encryptPayload(payload: StorageSessionPayload): string {
|
||||
const key = getEncryptionKey();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(JSON.stringify(payload), 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64url');
|
||||
}
|
||||
|
||||
function decryptPayload(token: string): StorageSessionPayload {
|
||||
const key = getEncryptionKey();
|
||||
const raw = Buffer.from(token, 'base64url');
|
||||
|
||||
if (raw.length < 29) {
|
||||
throw new Error('Invalid storage session token');
|
||||
}
|
||||
|
||||
const iv = raw.subarray(0, 12);
|
||||
const tag = raw.subarray(12, 28);
|
||||
const encrypted = raw.subarray(28);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
|
||||
return JSON.parse(decrypted) as StorageSessionPayload;
|
||||
}
|
||||
|
||||
export async function createStorageSession(
|
||||
user: typeof users.$inferSelect,
|
||||
password: string
|
||||
): Promise<StorageSessionPayload | null> {
|
||||
if (
|
||||
!user.storageProvider ||
|
||||
!user.storageAccessKeyEncrypted ||
|
||||
!user.storageSecretKeyEncrypted ||
|
||||
!user.storageBucket
|
||||
) {
|
||||
await clearStorageSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
const { accessKeyId, secretAccessKey } = decryptS3Credentials(
|
||||
user.storageAccessKeyEncrypted,
|
||||
user.storageSecretKeyEncrypted,
|
||||
password
|
||||
);
|
||||
|
||||
const payload: StorageSessionPayload = {
|
||||
userId: user.id,
|
||||
provider: user.storageProvider as StorageProvider,
|
||||
endpoint: user.storageEndpoint,
|
||||
publicBaseUrl: user.storagePublicBaseUrl,
|
||||
region: user.storageRegion || 'us-east-1',
|
||||
bucket: user.storageBucket,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
expiresAt: Date.now() + STORAGE_SESSION_TTL_MS,
|
||||
};
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayload(payload), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: new Date(payload.expiresAt),
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function getStorageSession(userId: string): Promise<StorageSessionPayload | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value;
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = decryptPayload(token);
|
||||
|
||||
if (payload.userId !== userId || payload.expiresAt <= Date.now()) {
|
||||
await clearStorageSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
await clearStorageSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStorageSession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(STORAGE_SESSION_COOKIE);
|
||||
}
|
||||
Reference in New Issue
Block a user