UNPKG

better-auth-cloudflare

Version:

Seamlessly integrate better-auth with Cloudflare Workers, D1, Hyperdrive, KV, R2, and geolocation services.

761 lines (760 loc) 33.9 kB
import { createAuthEndpoint, getSessionFromCtx, sessionMiddleware } from "better-auth/api"; import { z } from "zod"; export const R2_ERROR_CODES = { FILE_TOO_LARGE: "File is too large. Please choose a smaller file", INVALID_FILE_TYPE: "File type not supported. Please choose a different file", NO_FILE_PROVIDED: "Please select a file to upload", INVALID_REQUEST: "Invalid request. Please try again", R2_STORAGE_NOT_CONFIGURED: "File storage is temporarily unavailable. Please try again later", UPLOAD_FAILED: "Upload failed. Please check your connection and try again", FILE_ID_REQUIRED: "File not found", LIST_FILES_FAILED: "Unable to load your files. Please refresh the page", INVALID_METADATA: "Invalid file information. Please try uploading again", UPLOAD_ROLLBACK_FAILED: "Upload failed. Please try again", INVALID_FILE_RECORD: "File information is corrupted. Please contact support", DB_OPERATION_FAILED: "Service temporarily unavailable. Please try again later", }; /** * Helper to create success result */ export const success = (data) => ({ success: true, data }); /** * Helper to create error result */ export const error = (message, code) => ({ success: false, error: message, code, }); /** * Type guard to validate FileMetadata from database records */ function validateFileMetadata(record) { if (!record || typeof record !== "object") { return false; } // Check required string fields if (typeof record.id !== "string" || typeof record.userId !== "string" || typeof record.filename !== "string" || typeof record.originalName !== "string" || typeof record.contentType !== "string" || typeof record.r2Key !== "string") { return false; } // Check size is a number if (typeof record.size !== "number") { return false; } // Check uploadedAt is a valid date (can be Date object or valid date string) if (record.uploadedAt) { const date = record.uploadedAt instanceof Date ? record.uploadedAt : new Date(record.uploadedAt); if (isNaN(date.getTime())) { return false; } // Convert string dates to Date objects for consistency if (!(record.uploadedAt instanceof Date)) { record.uploadedAt = date; } } else { return false; } return true; } /** * Converts Better Auth FieldAttribute to Zod schema (same pattern as feedback plugin) */ function convertFieldAttributesToZodSchema(additionalFields) { const zodSchema = {}; for (const [key, value] of Object.entries(additionalFields)) { let fieldSchema; if (value.type === "string") { fieldSchema = z.string(); } else if (value.type === "number") { fieldSchema = z.number(); } else if (value.type === "boolean") { fieldSchema = z.boolean(); } else if (value.type === "date") { fieldSchema = z.date(); } else if (value.type === "string[]") { fieldSchema = z.array(z.string()); } else if (value.type === "number[]") { fieldSchema = z.array(z.number()); } else { throw new Error(`Unsupported field type: ${value.type} for field ${key}`); } if (!value.required) { fieldSchema = fieldSchema.optional(); } zodSchema[key] = fieldSchema; } return z.object(zodSchema); } // Zod schemas for validation export const createFileMetadataSchema = (additionalFields) => { if (!additionalFields || Object.keys(additionalFields).length === 0) { return z.record(z.any()).optional(); } return convertFieldAttributesToZodSchema(additionalFields).optional(); }; export const fileIdSchema = z.object({ fileId: z.string().min(1, "File ID is required"), }); export const listFilesSchema = z .object({ limit: z.number().min(1).max(100).optional(), cursor: z.string().optional(), // File ID to start listing from }) .optional(); // Standard upload schema - everything in the body /** * Creates upload schema dynamically based on additionalFields configuration */ export const createUploadFileSchema = (additionalFields) => { const baseShape = { file: z.instanceof(File), }; if (!additionalFields || Object.keys(additionalFields).length === 0) { return z.object(baseShape); } // Add additionalFields to the schema for (const [key, value] of Object.entries(additionalFields)) { let fieldSchema; if (value.type === "string") { fieldSchema = z.string(); } else if (value.type === "number") { fieldSchema = z.number(); } else if (value.type === "boolean") { fieldSchema = z.boolean(); } else if (value.type === "date") { fieldSchema = z.date(); } else if (value.type === "string[]") { fieldSchema = z.array(z.string()); } else if (value.type === "number[]") { fieldSchema = z.array(z.number()); } else { throw new Error(`Unsupported field type: ${value.type} for field ${key}`); } if (!value.required) { fieldSchema = fieldSchema.optional(); } baseShape[key] = fieldSchema; } return z.object(baseShape); }; /** * Sanitizes a filename to prevent path traversal and ensure safe storage */ const sanitizeFilename = (filename) => { // Remove path separators and other dangerous characters // Keep only alphanumeric, dots, dashes, and underscores return filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 255); // Also limit length }; /** * Validates file constraints using Result pattern */ export const createFileValidator = (config) => { const { maxFileSize = 10485760, // 10MB default allowedTypes, } = config; return { validateFile: (file, ctx) => { // Validate file size if (file.size > maxFileSize) { const maxSizeMB = Math.round(maxFileSize / (1024 * 1024)); const errorMsg = `${R2_ERROR_CODES.FILE_TOO_LARGE} (max ${maxSizeMB}MB)`; ctx?.logger?.error(`[R2]: File size validation failed:`, errorMsg); return error(errorMsg, "FILE_TOO_LARGE"); } // Validate file type if restrictions are set if (allowedTypes && allowedTypes.length > 0) { if (!file.name) { const errorMsg = "File must have a name when file type restrictions are enabled"; ctx?.logger?.error(`[R2]: File name validation failed:`, errorMsg); return error(errorMsg, "INVALID_FILE_NAME"); } const getFileExtension = (filename) => { const lastDotIndex = filename.lastIndexOf("."); return lastDotIndex === -1 ? "" : filename.slice(lastDotIndex + 1).toLowerCase(); }; const extension = getFileExtension(file.name); if (!extension) { const errorMsg = "File must have an extension when file type restrictions are enabled"; ctx?.logger?.error(`[R2]: File extension validation failed:`, errorMsg); return error(errorMsg, "INVALID_FILE_EXTENSION"); } // Normalize allowed types and check const normalizedAllowedTypes = allowedTypes.map(type => type.startsWith(".") ? type.slice(1).toLowerCase() : type.toLowerCase()); const isAllowed = normalizedAllowedTypes.includes(extension); if (!isAllowed) { const allowedTypesFormatted = allowedTypes .map(type => (type.startsWith(".") ? type : `.${type}`)) .join(", "); const errorMsg = `${R2_ERROR_CODES.INVALID_FILE_TYPE}. Supported formats: ${allowedTypesFormatted}`; ctx?.logger?.error(`[R2]: File type validation failed:`, errorMsg); return error(errorMsg, "INVALID_FILE_TYPE"); } } return success(true); }, validateMetadata: (metadata, ctx) => { const metadataSchema = createFileMetadataSchema(config.additionalFields); const result = metadataSchema.safeParse(metadata); if (!result.success) { // Extract detailed error information from Zod const errorMessages = result.error.errors .map(err => { const path = err.path.length > 0 ? `${err.path.join(".")}: ` : ""; return `${path}${err.message}`; }) .join(", "); const detailedError = `Invalid metadata: ${errorMessages}`; ctx?.logger?.error(`[R2]: Metadata validation failed:`, { error: detailedError, metadata, zodErrors: result.error.errors, }); return error(detailedError, "INVALID_METADATA"); } return success(result.data); }, }; }; /** * Creates R2 storage utilities with Better Auth context for error handling and logging */ export const createR2Storage = (config, generateId) => { const { bucket } = config; const validator = createFileValidator(config); return { /** * Uploads a file to R2 and returns metadata */ async uploadFile(file, originalName, userId, ctx, // Better Auth context for error handling and logging customMetadata, modelName // Model name for ID generation ) { let r2Key = null; try { // Create a File object for validation if we have a Blob // Ensure the file name is properly set for validation const fileForValidation = file instanceof File ? file : new File([file], originalName, { type: file.type }); // Validate file using Result pattern const fileValidation = validator.validateFile(fileForValidation, ctx); if (!fileValidation.success) { throw new Error(fileValidation.error); } // Validate metadata if provided let validatedMetadata = undefined; if (customMetadata) { const metadataValidation = validator.validateMetadata(customMetadata, ctx); if (!metadataValidation.success) { throw new Error(metadataValidation.error); } validatedMetadata = metadataValidation.data; } const fileId = generateId({ model: modelName || "userFile" }); if (!fileId) { throw new Error("Failed to generate unique file ID. Please try again."); } const filename = `${fileId}-${sanitizeFilename(originalName)}`; r2Key = `user-files/${userId}/${filename}`; // Create metadata for callbacks const metadata = { id: fileId, userId, filename, originalName, contentType: file.type, size: file.size, r2Key, uploadedAt: new Date(), ...validatedMetadata, }; // Call beforeUpload hook if (config.hooks?.upload?.before) { const result = await config.hooks.upload.before(Object.assign(fileForValidation, { userId, r2Key, metadata }), ctx); if (result === null) { throw new Error("Upload prevented by beforeUpload hook"); } } ctx?.logger?.info(`[R2]: Uploading file for user "${userId}": ${filename}`); // Upload to R2 with proper typing and improved metadata handling const uploadOptions = { httpMetadata: { contentType: file.type, }, ...(validatedMetadata && { customMetadata: Object.fromEntries(Object.entries(validatedMetadata).map(([k, v]) => [ k, v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v), ])), }), }; const result = await bucket.put(r2Key, file, uploadOptions); // Basic result validation - R2 put typically returns an object on success if (!result) { throw new Error(R2_ERROR_CODES.UPLOAD_FAILED); } ctx?.logger?.info(`[R2]: Successfully uploaded file for user "${userId}": ${filename}`); // Call afterUpload hook if (config.hooks?.upload?.after) { await config.hooks.upload.after(metadata, ctx); } return metadata; } catch (error) { // Rollback: delete from R2 if upload succeeded but something else failed if (r2Key) { try { await bucket.delete(r2Key); ctx?.logger?.info(`[R2]: Cleaned up failed upload: ${r2Key}`); } catch (cleanupError) { ctx?.logger?.error(`[R2]: Failed to cleanup after upload failure:`, cleanupError); } } ctx?.logger?.error(`[R2]: Upload failed for user "${userId}":`, error); throw error; } }, /** * Downloads a file from R2 */ async downloadFile(fileMetadata, ctx) { // Call beforeDownload hook if (config.hooks?.download?.before) { const result = await config.hooks.download.before(fileMetadata, ctx); if (result === null) { throw new Error("Download prevented by beforeDownload hook"); } } const object = await bucket.get(fileMetadata.r2Key); const downloadResult = object?.body || null; // Call afterDownload hook if (config.hooks?.download?.after) { await config.hooks.download.after(fileMetadata, ctx); } return downloadResult; }, /** * Deletes a file from R2 */ async deleteFile(fileMetadata, ctx) { // Call beforeDelete hook if (config.hooks?.delete?.before) { const result = await config.hooks.delete.before(fileMetadata, ctx); if (result === null) { throw new Error("Delete prevented by beforeDelete hook"); } } await bucket.delete(fileMetadata.r2Key); // Call afterDelete hook if (config.hooks?.delete?.after) { await config.hooks.delete.after(fileMetadata, ctx); } }, /** * Gets file metadata from R2 */ async getFileInfo(r2Key) { return await bucket.head(r2Key); }, /** * Lists files for a user */ async listUserFiles(userId, ctx) { // Call beforeList hook if (config.hooks?.list?.before) { const result = await config.hooks.list.before(userId, ctx); if (result === null) { throw new Error("List prevented by beforeList hook"); } } const files = await bucket.list({ prefix: `user-files/${userId}/`, }); // Call afterList hook if (config.hooks?.list?.after) { await config.hooks.list.after(userId, files, ctx); } return files; }, }; }; /** * Helper function to get the correct model name - just returns userFile * The adapter handles plural/singular internally */ const getModelName = (ctx, baseName = "userFile") => { return "userFile"; }; /** * Validates that the auth context and adapter are properly configured */ const validateAuthContext = (ctx) => { if (!ctx) { throw new Error("Auth context is not available"); } if (!ctx.adapter) { throw new Error("Database adapter is not properly configured"); } }; /** * Creates R2 endpoints for Better Auth plugin */ export const createR2Endpoints = (getR2Storage, r2Config) => { return { upload: createAuthEndpoint("/files/upload-raw", { method: "POST", }, async (ctx) => { // Manually get session instead of using middleware const session = await getSessionFromCtx(ctx); if (!session) { throw ctx.error("UNAUTHORIZED", { message: "Please sign in to upload files" }); } // Validate auth context before proceeding try { validateAuthContext(ctx.context); } catch (error) { ctx.context.logger?.error("[R2]: Auth context validation failed:", error); throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.DB_OPERATION_FAILED, }); } try { ctx.context.logger?.info("[R2]: Starting blob file upload"); const r2Storage = getR2Storage(); if (!r2Storage) { ctx.context.logger?.error("[R2]: R2 storage not configured"); throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.R2_STORAGE_NOT_CONFIGURED, }); } // Get configured max file size (preemptively checking for DoS attacks) const maxFileSize = r2Config?.maxFileSize || 10485760; // 10MB default // Validate Content-Length header first to prevent DoS attacks const contentLength = ctx.request?.headers?.get("content-length"); if (contentLength) { const fileSize = parseInt(contentLength, 10); if (fileSize > maxFileSize) { const maxSizeMB = Math.round(maxFileSize / (1024 * 1024)); throw new Error(`${R2_ERROR_CODES.FILE_TOO_LARGE} (max ${maxSizeMB}MB)`); } } // Get filename and metadata from headers const filename = ctx.request?.headers?.get("x-filename"); const metadataHeader = ctx.request?.headers?.get("x-file-metadata"); if (!filename) { throw new Error("x-filename header is required"); } // Parse metadata from headers let additionalFields = {}; if (metadataHeader) { try { additionalFields = JSON.parse(metadataHeader); } catch (error) { ctx.context.logger?.warn("[R2]: Failed to parse metadata header:", error); throw new Error("Invalid JSON in x-file-metadata header"); } } // Validate metadata against schema if provided if (r2Config?.additionalFields && Object.keys(additionalFields).length > 0) { const metadataSchema = createFileMetadataSchema(r2Config.additionalFields); const validationResult = metadataSchema.safeParse(additionalFields); if (!validationResult.success) { throw new Error(`Invalid additional fields: ${validationResult.error.message}`); } additionalFields = validationResult.data || {}; } // The file is directly in the request body const file = ctx.body; // Validate that we have a file body if (!file) { throw new Error(R2_ERROR_CODES.NO_FILE_PROVIDED); } // Convert body to File if it's not already one let fileToUpload; if (file instanceof File) { fileToUpload = file; } else { fileToUpload = new File([file], filename, { type: file.type || "application/octet-stream", }); } const customMetadata = additionalFields || {}; // Use userFile - adapter handles plural/singular internally const modelName = "userFile"; // Upload the file using existing R2 storage utility const fileMetadata = await r2Storage.uploadFile(fileToUpload, filename, session.session.userId, ctx.context, customMetadata, modelName); // Store file metadata in database try { await ctx.context.adapter.create({ model: modelName, data: { id: fileMetadata.id, userId: fileMetadata.userId, filename: fileMetadata.filename, originalName: fileMetadata.originalName, contentType: fileMetadata.contentType, size: fileMetadata.size, r2Key: fileMetadata.r2Key, uploadedAt: fileMetadata.uploadedAt, ...customMetadata, }, }); ctx.context.logger?.info("[R2]: File metadata saved to database:", fileMetadata.id); } catch (dbError) { ctx.context.logger?.error("[R2]: Failed to save to database:", dbError); // Clean up R2 file if database save failed try { await r2Storage.deleteFile(fileMetadata, ctx.context); } catch (cleanupError) { ctx.context.logger?.error("[R2]: Failed to cleanup R2 file after DB error:", cleanupError); } throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.DB_OPERATION_FAILED, }); } return ctx.json({ success: true, data: fileMetadata, }); } catch (error) { ctx.context.logger?.error("[R2]: Upload failed:", error); if (error instanceof Error) { throw ctx.error("INTERNAL_SERVER_ERROR", { message: error.message, }); } throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.UPLOAD_FAILED, }); } }), download: createAuthEndpoint("/files/download", { method: "POST", use: [sessionMiddleware], body: fileIdSchema, }, async (ctx) => { const session = ctx.context.session; const r2Storage = getR2Storage(); if (!r2Storage) { ctx.context.logger?.error("[R2]: R2 storage not configured"); throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.R2_STORAGE_NOT_CONFIGURED, }); } const { fileId } = ctx.body; // Query the database to get file metadata and verify ownership const fileRecord = await ctx.context.adapter.findOne({ model: "userFile", where: [ { field: "id", value: fileId }, { field: "userId", value: session.session.userId }, ], }); if (!fileRecord) { ctx.context.logger?.warn(`[R2]: File not found or access denied for user "${session.session.userId}": ${fileId}`); throw ctx.error("NOT_FOUND", { message: "File not found. It may have been deleted or you don't have permission to access it", }); } // Validate the file record structure if (!validateFileMetadata(fileRecord)) { ctx.context.logger?.error(`[R2]: Invalid file record structure for file: ${fileId}`); throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.INVALID_FILE_RECORD, }); } // Download the file from R2 const fileData = await r2Storage.downloadFile(fileRecord, ctx.context); if (!fileData) { ctx.context.logger?.error(`[R2]: File data not found in R2 for file: ${fileId}`); throw ctx.error("NOT_FOUND", { message: "File content is temporarily unavailable. Please try again later", }); } ctx.context.logger?.info(`[R2]: File downloaded successfully for user "${session.session.userId}": ${fileId}`); // Return the file with appropriate headers return new Response(fileData, { headers: { "Content-Type": fileRecord.contentType || "application/octet-stream", "Content-Disposition": `attachment; filename="${fileRecord.originalName}"`, "Content-Length": fileRecord.size?.toString() || "0", }, }); }), delete: createAuthEndpoint("/files/delete", { method: "POST", use: [sessionMiddleware], body: fileIdSchema, }, async (ctx) => { const session = ctx.context.session; const r2Storage = getR2Storage(); if (!r2Storage) { ctx.context.logger?.error("[R2]: R2 storage not configured"); throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.R2_STORAGE_NOT_CONFIGURED, }); } const { fileId } = ctx.body; // Query the database to get file metadata and verify ownership const fileRecord = await ctx.context.adapter.findOne({ model: "userFile", where: [ { field: "id", value: fileId }, { field: "userId", value: session.session.userId }, ], }); if (!fileRecord) { ctx.context.logger?.warn(`[R2]: File not found or access denied for user "${session.session.userId}": ${fileId}`); throw ctx.error("NOT_FOUND", { message: "File not found. It may have been deleted or you don't have permission to delete it", }); } // Validate the file record structure if (!validateFileMetadata(fileRecord)) { ctx.context.logger?.error(`[R2]: Invalid file record structure for file: ${fileId}`); throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.INVALID_FILE_RECORD, }); } // Delete from R2 first await r2Storage.deleteFile(fileRecord, ctx.context); // Delete from database await ctx.context.adapter.delete({ model: "userFile", where: [{ field: "id", value: fileId }], }); ctx.context.logger?.info(`[R2]: File deleted successfully for user "${session.session.userId}": ${fileId}`); return ctx.json({ message: "File deleted successfully", fileId: fileId, }); }), list: createAuthEndpoint("/files/list", { method: "GET", use: [sessionMiddleware], }, async (ctx) => { const session = ctx.context.session; // Validate auth context before proceeding try { validateAuthContext(ctx.context); } catch (error) { ctx.context.logger?.error("[R2]: Auth context validation failed:", error); throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.DB_OPERATION_FAILED, }); } const r2Storage = getR2Storage(); if (!r2Storage) { ctx.context.logger?.error("[R2]: R2 storage not configured"); throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.R2_STORAGE_NOT_CONFIGURED, }); } // Get query parameters from URL const limit = ctx.query?.limit ? parseInt(ctx.query.limit) : 50; const cursor = ctx.query?.cursor; try { // Query the database for user files instead of R2 directly const modelName = "userFile"; ctx.context.logger?.info(`[R2]: Using model name "${modelName}" for listing files`); let fileRecords = []; const actualLimit = Math.min(limit, 100) + 1; // Request one extra to check if there are more try { const whereConditions = [{ field: "userId", value: session.session.userId }]; // For cursor-based pagination, we'll use a simple approach with uploadedAt timestamp // since Better Auth adapters may not support complex operators fileRecords = await ctx.context.adapter.findMany({ model: modelName, where: whereConditions, limit: actualLimit, sortBy: { field: "uploadedAt", direction: "desc" }, // Most recent first }); // If cursor provided, filter results client-side for simplicity if (cursor) { const cursorIndex = fileRecords.findIndex(file => file.id === cursor); if (cursorIndex !== -1) { fileRecords = fileRecords.slice(cursorIndex + 1); } } } catch (dbError) { ctx.context.logger?.error(`[R2]: Database query failed for model "${modelName}":`, dbError); // Log more details about the error if (dbError instanceof Error) { ctx.context.logger?.error(`[R2]: Error message: ${dbError.message}`); ctx.context.logger?.error(`[R2]: Error stack: ${dbError.stack}`); } throw dbError; } // Validate and filter valid file records const validFileRecords = fileRecords.filter(record => validateFileMetadata(record)); // Check if there are more results and prepare response const hasMore = validFileRecords.length > limit; const files = hasMore ? validFileRecords.slice(0, -1) : validFileRecords; const nextCursor = hasMore ? files[files.length - 1].id : null; ctx.context.logger?.info(`[R2]: Listed ${files.length} files for user "${session.session.userId}" (hasMore: ${hasMore})`); // Return paginated response return ctx.json({ files, nextCursor, hasMore, }); } catch (error) { ctx.context.logger?.error("[R2]: Failed to list files:", error); throw ctx.error("INTERNAL_SERVER_ERROR", { message: R2_ERROR_CODES.LIST_FILES_FAILED, }); } }), get: createAuthEndpoint("/files/get", { method: "POST", use: [sessionMiddleware], body: fileIdSchema, }, async (ctx) => { const session = ctx.context.session; const { fileId } = ctx.body; // Query the database to get file metadata and verify ownership const fileRecord = await ctx.context.adapter.findOne({ model: "userFile", where: [ { field: "id", value: fileId }, { field: "userId", value: session.session.userId }, ], }); if (!fileRecord) { ctx.context.logger?.warn(`[R2]: File not found or access denied for user "${session.session.userId}": ${fileId}`); throw ctx.error("NOT_FOUND", { message: "File not found. It may have been deleted or you don't have permission to access it", }); } return ctx.json({ data: fileRecord }); }), }; };