Make user media uploads session-backed

This commit is contained in:
cyph3rasi
2026-03-07 18:14:02 -08:00
parent 7422e01a22
commit 28eb46d653
13 changed files with 340 additions and 285 deletions
+59 -41
View File
@@ -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);
+136
View File
@@ -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);
}