better-auth-cloudflare
Version:
Seamlessly integrate better-auth with Cloudflare Workers, D1, Hyperdrive, KV, R2, and geolocation services.
694 lines (690 loc) • 26.3 kB
JavaScript
const api = require('better-auth/api');
const zod = require('zod');
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"
};
const success = (data) => ({ success: true, data });
const error = (message, code) => ({
success: false,
error: message,
code
});
function validateFileMetadata(record) {
if (!record || typeof record !== "object") {
return false;
}
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;
}
if (typeof record.size !== "number") {
return false;
}
if (record.uploadedAt) {
const date = record.uploadedAt instanceof Date ? record.uploadedAt : new Date(record.uploadedAt);
if (isNaN(date.getTime())) {
return false;
}
if (!(record.uploadedAt instanceof Date)) {
record.uploadedAt = date;
}
} else {
return false;
}
return true;
}
function convertFieldAttributesToZodSchema(additionalFields) {
const zodSchema = {};
for (const [key, value] of Object.entries(additionalFields)) {
let fieldSchema;
if (value.type === "string") {
fieldSchema = zod.z.string();
} else if (value.type === "number") {
fieldSchema = zod.z.number();
} else if (value.type === "boolean") {
fieldSchema = zod.z.boolean();
} else if (value.type === "date") {
fieldSchema = zod.z.date();
} else if (value.type === "string[]") {
fieldSchema = zod.z.array(zod.z.string());
} else if (value.type === "number[]") {
fieldSchema = zod.z.array(zod.z.number());
} else {
throw new Error(`Unsupported field type: ${value.type} for field ${key}`);
}
if (!value.required) {
fieldSchema = fieldSchema.optional();
}
zodSchema[key] = fieldSchema;
}
return zod.z.object(zodSchema);
}
const createFileMetadataSchema = (additionalFields) => {
if (!additionalFields || Object.keys(additionalFields).length === 0) {
return zod.z.record(zod.z.string(), zod.z.any()).optional();
}
return convertFieldAttributesToZodSchema(additionalFields).optional();
};
const fileIdSchema = zod.z.object({
fileId: zod.z.string().min(1, "File ID is required")
});
const listFilesSchema = zod.z.object({
limit: zod.z.number().min(1).max(100).optional(),
cursor: zod.z.string().optional()
// File ID to start listing from
}).optional();
const createUploadFileSchema = (additionalFields) => {
const baseShape = {
file: zod.z.instanceof(File)
};
if (!additionalFields || Object.keys(additionalFields).length === 0) {
return zod.z.object(baseShape);
}
for (const [key, value] of Object.entries(additionalFields)) {
let fieldSchema;
if (value.type === "string") {
fieldSchema = zod.z.string();
} else if (value.type === "number") {
fieldSchema = zod.z.number();
} else if (value.type === "boolean") {
fieldSchema = zod.z.boolean();
} else if (value.type === "date") {
fieldSchema = zod.z.date();
} else if (value.type === "string[]") {
fieldSchema = zod.z.array(zod.z.string());
} else if (value.type === "number[]") {
fieldSchema = zod.z.array(zod.z.number());
} else {
throw new Error(`Unsupported field type: ${value.type} for field ${key}`);
}
if (!value.required) {
fieldSchema = fieldSchema.optional();
}
baseShape[key] = fieldSchema;
}
return zod.z.object(baseShape);
};
const sanitizeFilename = (filename) => {
return filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 255);
};
const createFileValidator = (config) => {
const {
maxFileSize = 10485760,
// 10MB default
allowedTypes
} = config;
return {
validateFile: (file, ctx) => {
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");
}
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");
}
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) {
const errorMessages = result.error.issues.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.issues
});
return error(detailedError, "INVALID_METADATA");
}
return success(result.data);
}
};
};
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, customMetadata, modelName) {
let r2Key = null;
try {
const fileForValidation = file instanceof File ? file : new File([file], originalName, { type: file.type });
const fileValidation = validator.validateFile(fileForValidation, ctx);
if (!fileValidation.success) {
throw new Error(fileValidation.error);
}
let validatedMetadata = void 0;
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}`;
const metadata = {
id: fileId,
userId,
filename,
originalName,
contentType: file.type,
size: file.size,
r2Key,
uploadedAt: /* @__PURE__ */ new Date(),
...validatedMetadata
};
if (config.hooks?.upload?.before) {
const result2 = await config.hooks.upload.before(
Object.assign(fileForValidation, { userId, r2Key, metadata }),
ctx
);
if (result2 === null) {
throw new Error("Upload prevented by beforeUpload hook");
}
}
ctx?.logger?.info(`[R2]: Uploading file for user "${userId}": ${filename}`);
const uploadOptions = {
httpMetadata: {
contentType: file.type
},
...validatedMetadata && {
customMetadata: Object.fromEntries(
Object.entries(validatedMetadata).map(([k, v]) => [
k,
v === null || v === void 0 ? "" : typeof v === "object" ? JSON.stringify(v) : String(v)
])
)
}
};
const result = await bucket.put(r2Key, file, uploadOptions);
if (!result) {
throw new Error(R2_ERROR_CODES.UPLOAD_FAILED);
}
ctx?.logger?.info(`[R2]: Successfully uploaded file for user "${userId}": ${filename}`);
if (config.hooks?.upload?.after) {
await config.hooks.upload.after(metadata, ctx);
}
return metadata;
} catch (error2) {
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}":`, error2);
throw error2;
}
},
/**
* Downloads a file from R2
*/
async downloadFile(fileMetadata, ctx) {
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;
if (config.hooks?.download?.after) {
await config.hooks.download.after(fileMetadata, ctx);
}
return downloadResult;
},
/**
* Deletes a file from R2
*/
async deleteFile(fileMetadata, ctx) {
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);
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) {
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}/`
});
if (config.hooks?.list?.after) {
await config.hooks.list.after(userId, files, ctx);
}
return files;
}
};
};
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");
}
};
const createR2Endpoints = (getR2Storage, r2Config) => {
return {
upload: api.createAuthEndpoint(
"/files/upload-raw",
{
method: "POST"
},
async (ctx) => {
const session = await api.getSessionFromCtx(ctx);
if (!session) {
throw ctx.error("UNAUTHORIZED", { message: "Please sign in to upload files" });
}
try {
validateAuthContext(ctx.context);
} catch (error2) {
ctx.context.logger?.error("[R2]: Auth context validation failed:", error2);
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
});
}
const maxFileSize = r2Config?.maxFileSize || 10485760;
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)`);
}
}
const rawFilename = ctx.request?.headers?.get("x-filename");
const rawMetadataHeader = ctx.request?.headers?.get("x-file-metadata");
if (!rawFilename) {
throw new Error("x-filename header is required");
}
const filename = rawFilename.split("").map((char) => {
const code = char.charCodeAt(0);
return code <= 127 ? char : "?";
}).join("");
const metadataHeader = rawMetadataHeader ? rawMetadataHeader.split("").map((char) => {
const code = char.charCodeAt(0);
return code <= 127 ? char : "?";
}).join("") : void 0;
let additionalFields = {};
if (metadataHeader) {
try {
additionalFields = JSON.parse(metadataHeader);
} catch (error2) {
ctx.context.logger?.warn("[R2]: Failed to parse metadata header:", error2);
throw new Error("Invalid JSON in x-file-metadata header");
}
}
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 || {};
}
const file = ctx.body;
if (!file) {
throw new Error(R2_ERROR_CODES.NO_FILE_PROVIDED);
}
let fileToUpload;
if (file instanceof File) {
fileToUpload = file;
} else {
fileToUpload = new File([file], filename, {
type: file.type || "application/octet-stream"
});
}
const customMetadata = additionalFields || {};
const modelName = "userFile";
const fileMetadata = await r2Storage.uploadFile(
fileToUpload,
filename,
session.session.userId,
ctx.context,
customMetadata,
modelName
);
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);
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 (error2) {
ctx.context.logger?.error("[R2]: Upload failed:", error2);
if (error2 instanceof Error) {
throw ctx.error("INTERNAL_SERVER_ERROR", {
message: error2.message
});
}
throw ctx.error("INTERNAL_SERVER_ERROR", {
message: R2_ERROR_CODES.UPLOAD_FAILED
});
}
}
),
download: api.createAuthEndpoint(
"/files/download",
{
method: "POST",
use: [api.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;
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"
});
}
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
});
}
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 new Response(fileData, {
headers: {
"Content-Type": fileRecord.contentType || "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileRecord.originalName}"`,
"Content-Length": fileRecord.size?.toString() || "0"
}
});
}
),
delete: api.createAuthEndpoint(
"/files/delete",
{
method: "POST",
use: [api.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;
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"
});
}
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
});
}
await r2Storage.deleteFile(fileRecord, ctx.context);
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
});
}
),
list: api.createAuthEndpoint(
"/files/list",
{
method: "GET",
use: [api.sessionMiddleware]
},
async (ctx) => {
const session = ctx.context.session;
try {
validateAuthContext(ctx.context);
} catch (error2) {
ctx.context.logger?.error("[R2]: Auth context validation failed:", error2);
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
});
}
const limit = ctx.query?.limit ? parseInt(ctx.query.limit) : 50;
const cursor = ctx.query?.cursor;
try {
const modelName = "userFile";
ctx.context.logger?.info(`[R2]: Using model name "${modelName}" for listing files`);
let fileRecords = [];
const actualLimit = Math.min(limit, 100) + 1;
try {
const whereConditions = [{ field: "userId", value: session.session.userId }];
fileRecords = await ctx.context.adapter.findMany({
model: modelName,
where: whereConditions,
limit: actualLimit,
sortBy: { field: "uploadedAt", direction: "desc" }
// Most recent first
});
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);
if (dbError instanceof Error) {
ctx.context.logger?.error(`[R2]: Error message: ${dbError.message}`);
ctx.context.logger?.error(`[R2]: Error stack: ${dbError.stack}`);
}
throw dbError;
}
const validFileRecords = fileRecords.filter((record) => validateFileMetadata(record));
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 ctx.json({
files,
nextCursor,
hasMore
});
} catch (error2) {
ctx.context.logger?.error("[R2]: Failed to list files:", error2);
throw ctx.error("INTERNAL_SERVER_ERROR", {
message: R2_ERROR_CODES.LIST_FILES_FAILED
});
}
}
),
get: api.createAuthEndpoint(
"/files/get",
{
method: "POST",
use: [api.sessionMiddleware],
body: fileIdSchema
},
async (ctx) => {
const session = ctx.context.session;
const { fileId } = ctx.body;
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 });
}
)
};
};
exports.R2_ERROR_CODES = R2_ERROR_CODES;
exports.createFileMetadataSchema = createFileMetadataSchema;
exports.createFileValidator = createFileValidator;
exports.createR2Endpoints = createR2Endpoints;
exports.createR2Storage = createR2Storage;
exports.createUploadFileSchema = createUploadFileSchema;
exports.error = error;
exports.fileIdSchema = fileIdSchema;
exports.listFilesSchema = listFilesSchema;
exports.success = success;
;