From bbb8532f5f7cb99d43ca4c0845f1532772e3af58 Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Mon, 11 Aug 2025 14:17:22 -0400 Subject: [PATCH 01/68] feat: added r2 image upload --- betterauth-astro/cloudflare-env.d.ts | 22 +- betterauth-astro/public/default-avatar.svg | 5 + .../src/pages/api/avatars/[...key].ts | 53 ++ .../src/pages/api/test-profile.ts | 59 +++ betterauth-astro/src/pages/api/test-r2.ts | 40 ++ .../src/pages/api/user/profile.ts | 212 ++++++++ betterauth-astro/src/pages/index.astro | 473 ++++++++++++++++-- betterauth-astro/src/utils/profile-api.ts | 138 +++++ betterauth-astro/src/utils/r2.ts | 185 +++++++ betterauth-astro/webflow.json | 8 +- betterauth-astro/worker-configuration.d.ts | 13 +- betterauth-astro/wrangler.json | 22 +- package-lock.json | 6 + 13 files changed, 1150 insertions(+), 86 deletions(-) create mode 100644 betterauth-astro/public/default-avatar.svg create mode 100644 betterauth-astro/src/pages/api/avatars/[...key].ts create mode 100644 betterauth-astro/src/pages/api/test-profile.ts create mode 100644 betterauth-astro/src/pages/api/test-r2.ts create mode 100644 betterauth-astro/src/pages/api/user/profile.ts create mode 100644 betterauth-astro/src/utils/profile-api.ts create mode 100644 betterauth-astro/src/utils/r2.ts create mode 100644 package-lock.json diff --git a/betterauth-astro/cloudflare-env.d.ts b/betterauth-astro/cloudflare-env.d.ts index 34d7a36..8fb40cc 100644 --- a/betterauth-astro/cloudflare-env.d.ts +++ b/betterauth-astro/cloudflare-env.d.ts @@ -1,24 +1,14 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts` (hash: 6d5c961e233ab43ca4c2230e4430c16c) +// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts` (hash: a8f74b3fb5379096c285fe3f8355417a) // Runtime types generated with workerd@1.20250705.0 2025-04-15 nodejs_compat declare namespace Cloudflare { - interface Env { - WEBFLOW_SITE_ID: string; - WEBFLOW_SITE_API_TOKEN: string; - BETTER_AUTH_SECRET: string; - BETTER_AUTH_URL: string; - PRODUCTION_ORIGIN_URL: string; - DB: D1Database; - ASSETS: Fetcher; - } + interface Env { + USER_AVATARS: R2Bucket; + DB: D1Database; + ASSETS: Fetcher; + } } interface CloudflareEnv extends Cloudflare.Env {} -type StringifyValues> = { - [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; -}; -declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} -} // Begin runtime types /*! ***************************************************************************** diff --git a/betterauth-astro/public/default-avatar.svg b/betterauth-astro/public/default-avatar.svg new file mode 100644 index 0000000..2a80601 --- /dev/null +++ b/betterauth-astro/public/default-avatar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/betterauth-astro/src/pages/api/avatars/[...key].ts b/betterauth-astro/src/pages/api/avatars/[...key].ts new file mode 100644 index 0000000..3695ce5 --- /dev/null +++ b/betterauth-astro/src/pages/api/avatars/[...key].ts @@ -0,0 +1,53 @@ +import type { APIRoute } from "astro"; + +export const GET: APIRoute = async ({ params, locals }) => { + try { + const { key } = params; + + if (!key) { + return new Response("Avatar key is required", { status: 400 }); + } + + const env = locals.runtime.env; + const bucket = env.USER_AVATARS; + + // Get the object from R2 + const object = await bucket.get(key); + + if (!object) { + return new Response("Avatar not found", { status: 404 }); + } + + // Get the object body + const body = await object.arrayBuffer(); + + // Create response with appropriate headers + const response = new Response(body, { + status: 200, + headers: { + "Content-Type": object.httpMetadata?.contentType || "image/jpeg", + "Cache-Control": + object.httpMetadata?.cacheControl || "public, max-age=31536000", + "Content-Length": object.size.toString(), + ETag: object.httpEtag, + }, + }); + + return response; + } catch (error) { + console.error("Error serving avatar:", error); + return new Response("Internal server error", { status: 500 }); + } +}; + +// Handle OPTIONS for CORS +export const OPTIONS: APIRoute = async () => { + return new Response(null, { + status: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +}; diff --git a/betterauth-astro/src/pages/api/test-profile.ts b/betterauth-astro/src/pages/api/test-profile.ts new file mode 100644 index 0000000..57d6eda --- /dev/null +++ b/betterauth-astro/src/pages/api/test-profile.ts @@ -0,0 +1,59 @@ +import type { APIRoute } from "astro"; +import { auth } from "../../utils/auth"; + +export const GET: APIRoute = async ({ request, locals }) => { + try { + // Test authentication + const authInstance = await auth(locals.runtime.env); + const session = await authInstance.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return new Response( + JSON.stringify({ + success: false, + error: "Not authenticated", + message: "Please log in to test profile functionality", + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response( + JSON.stringify({ + success: true, + message: "Profile API is accessible", + user: { + id: session.user.id, + name: session.user.name, + email: session.user.email, + hasImage: !!session.user.image, + }, + endpoints: { + getProfile: "GET /api/user/profile", + updateProfile: "POST /api/user/profile", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } catch (error) { + console.error("Profile test error:", error); + return new Response( + JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } +}; diff --git a/betterauth-astro/src/pages/api/test-r2.ts b/betterauth-astro/src/pages/api/test-r2.ts new file mode 100644 index 0000000..c2403dd --- /dev/null +++ b/betterauth-astro/src/pages/api/test-r2.ts @@ -0,0 +1,40 @@ +import type { APIRoute } from "astro"; + +export const GET: APIRoute = async ({ locals }) => { + try { + const env = locals.runtime.env; + const bucket = env.USER_AVATARS; + + // Test bucket access by listing objects + const objects = await bucket.list({ limit: 1 }); + + return new Response( + JSON.stringify({ + success: true, + message: "R2 bucket access is working", + bucketName: "user-avatars", + objectsCount: objects.objects.length, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + } + ); + } catch (error) { + console.error("R2 test error:", error); + return new Response( + JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + }, + } + ); + } +}; diff --git a/betterauth-astro/src/pages/api/user/profile.ts b/betterauth-astro/src/pages/api/user/profile.ts new file mode 100644 index 0000000..c84b9d0 --- /dev/null +++ b/betterauth-astro/src/pages/api/user/profile.ts @@ -0,0 +1,212 @@ +import type { APIRoute } from "astro"; +import { auth } from "../../../utils/auth"; +import { createAvatarService } from "../../../utils/r2"; +import { getDb } from "../../../db/getDb"; +import { user } from "../../../db/schema/auth-schema"; +import { eq } from "drizzle-orm"; + +export const GET: APIRoute = async ({ request, locals }) => { + try { + // Get the authenticated user + const authInstance = await auth(locals.runtime.env); + const session = await authInstance.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return new Response( + JSON.stringify({ success: false, error: "Unauthorized" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + const userId = session.user.id; + const db = getDb(locals.runtime.env.DB); + + // Get fresh user data from database + const userData = await db + .select({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + emailVerified: user.emailVerified, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + + if (userData.length === 0) { + return new Response( + JSON.stringify({ success: false, error: "User not found" }), + { status: 404, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response( + JSON.stringify({ + success: true, + user: userData[0], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error fetching user profile:", error); + return new Response( + JSON.stringify({ + success: false, + error: "Internal server error", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}; + +export const POST: APIRoute = async ({ request, locals }) => { + try { + // Get the authenticated user + const authInstance = await auth(locals.runtime.env); + const session = await authInstance.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return new Response( + JSON.stringify({ success: false, error: "Unauthorized" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + const userId = session.user.id; + const formData = await request.formData(); + const name = formData.get("name") as string; + const email = formData.get("email") as string; + const avatarFile = formData.get("avatar") as File | null; + + // Validate required fields + if (!name || !email) { + return new Response( + JSON.stringify({ + success: false, + error: "Name and email are required", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return new Response( + JSON.stringify({ + success: false, + error: "Invalid email format", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const db = getDb(locals.runtime.env.DB); + let avatarUrl = session.user.image; // Keep existing avatar if no new one uploaded + + // Handle avatar upload if provided + if (avatarFile && avatarFile.size > 0) { + const avatarService = createAvatarService( + locals.runtime.env.USER_AVATARS, + new URL(request.url).origin + ); + + // Validate the file + const validation = avatarService.validateFile(avatarFile); + if (!validation.valid) { + return new Response( + JSON.stringify({ + success: false, + error: validation.error, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Delete old avatar if it exists + if (session.user.image) { + // Extract key from existing URL + const existingKey = session.user.image.split("/api/avatars/")[1]; + if (existingKey) { + await avatarService.deleteAvatar(userId, existingKey); + } + } + + // Convert File to ArrayBuffer and upload + const fileBuffer = await avatarFile.arrayBuffer(); + const uploadResult = await avatarService.uploadAvatar( + userId, + fileBuffer, + avatarFile.name + ); + + if (!uploadResult.success) { + return new Response( + JSON.stringify({ + success: false, + error: uploadResult.error || "Failed to upload avatar", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + avatarUrl = uploadResult.url!; + } + + // Update user in database + const updateData: any = { + name, + email, + updatedAt: new Date(), + }; + + // Only update image if we have a new avatar URL + if (avatarUrl !== session.user.image) { + updateData.image = avatarUrl; + } + + await db.update(user).set(updateData).where(eq(user.id, userId)); + + return new Response( + JSON.stringify({ + success: true, + message: "Profile updated successfully", + user: { + id: userId, + name, + email, + image: avatarUrl, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error updating user profile:", error); + return new Response( + JSON.stringify({ + success: false, + error: "Internal server error", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}; + +// Handle OPTIONS for CORS +export const OPTIONS: APIRoute = async () => { + return new Response(null, { + status: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +}; diff --git a/betterauth-astro/src/pages/index.astro b/betterauth-astro/src/pages/index.astro index f31493b..03a5225 100644 --- a/betterauth-astro/src/pages/index.astro +++ b/betterauth-astro/src/pages/index.astro @@ -2,19 +2,22 @@ import Layout from '../layouts/Layout.astro'; import { Section, Container, Block, Link } from '../../devlink/_Builtin/Basic'; import { auth } from "../utils/auth"; +import { fetchProfile } from "../utils/profile-api"; const authInstance = await auth(Astro.locals.runtime.env); const session = await authInstance.api.getSession({ headers: Astro.request.headers, }); -const user = session?.user; -// Redirect if already authenticated +// Redirect if not authenticated if (!session) { return Astro.redirect("/app/login"); } +// Fetch fresh user data +const userData = await fetchProfile(); +const user = userData || session.user; --- @@ -24,54 +27,386 @@ if (!session) { className="margin-bottom-24px" style={{ minHeight: '100vh', - display: 'flex', - alignItems: 'center', - justifyContent: 'center' + padding: '2rem 0' }} > - -

Welcome to Webflow Cloud, {user?.name}

-

You are successfully logged in

-
- - Log out - + + +
+

+ User Profile +

+

+ Manage your account details and profile picture +

+
+ + +
+ +
+
+ Profile Picture +
+ + +

+ JPEG, PNG, GIF, or WebP (max 5MB) +

+
+ + +
+

+ Account Details +

+ +
+ +
+ + +
+ + +
+ + +
+ + + +
+
+ + +
+ + + + Log Out + +
+
+ + +
- - + + diff --git a/betterauth-astro/src/utils/profile-api.ts b/betterauth-astro/src/utils/profile-api.ts new file mode 100644 index 0000000..b910283 --- /dev/null +++ b/betterauth-astro/src/utils/profile-api.ts @@ -0,0 +1,138 @@ +export interface ProfileUpdateData { + name: string; + email: string; + avatar?: File; +} + +export interface ProfileResponse { + success: boolean; + message?: string; + error?: string; + user?: { + id: string; + name: string; + email: string; + image?: string; + }; +} + +export interface ProfileData { + id: string; + name: string; + email: string; + image?: string; + emailVerified: boolean; + createdAt: Date; + updatedAt: Date; +} + +/** + * Fetch the current user's profile data + */ +export async function fetchProfile(): Promise { + try { + const response = await fetch("/api/user/profile", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: ProfileResponse = await response.json(); + + if (!data.success) { + throw new Error(data.error || "Failed to fetch profile"); + } + + return data.user as ProfileData; + } catch (error) { + console.error("Error fetching profile:", error); + return null; + } +} + +/** + * Update the user's profile data + */ +export async function updateProfile( + profileData: ProfileUpdateData +): Promise { + try { + const formData = new FormData(); + formData.append("name", profileData.name); + formData.append("email", profileData.email); + + if (profileData.avatar) { + formData.append("avatar", profileData.avatar); + } + + const response = await fetch("/api/user/profile", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: ProfileResponse = await response.json(); + return data; + } catch (error) { + console.error("Error updating profile:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to update profile", + }; + } +} + +/** + * Validate profile data before submission + */ +export function validateProfileData(data: ProfileUpdateData): { + valid: boolean; + errors: string[]; +} { + const errors: string[] = []; + + // Validate name + if (!data.name || data.name.trim().length === 0) { + errors.push("Name is required"); + } else if (data.name.trim().length < 2) { + errors.push("Name must be at least 2 characters long"); + } + + // Validate email + if (!data.email || data.email.trim().length === 0) { + errors.push("Email is required"); + } else { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(data.email)) { + errors.push("Invalid email format"); + } + } + + // Validate avatar file if provided + if (data.avatar) { + const maxSize = 5 * 1024 * 1024; // 5MB + const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; + + if (data.avatar.size > maxSize) { + errors.push("Avatar file size must be less than 5MB"); + } + + if (!allowedTypes.includes(data.avatar.type)) { + errors.push("Avatar must be a JPEG, PNG, GIF, or WebP image"); + } + } + + return { + valid: errors.length === 0, + errors, + }; +} diff --git a/betterauth-astro/src/utils/r2.ts b/betterauth-astro/src/utils/r2.ts new file mode 100644 index 0000000..0491181 --- /dev/null +++ b/betterauth-astro/src/utils/r2.ts @@ -0,0 +1,185 @@ +import type { R2Bucket } from "@cloudflare/workers-types"; + +export interface AvatarUploadResult { + success: boolean; + url?: string; + key?: string; + error?: string; +} + +export interface AvatarDeleteResult { + success: boolean; + error?: string; +} + +export class AvatarService { + private bucket: R2Bucket; + private baseUrl: string; + + constructor(bucket: R2Bucket, baseUrl: string) { + this.bucket = bucket; + this.baseUrl = baseUrl; + } + + /** + * Upload a user avatar to R2 + */ + async uploadAvatar( + userId: string, + file: ArrayBuffer, + filename?: string + ): Promise { + try { + // Check if bucket is available + if (!this.bucket) { + return { + success: false, + error: "R2 bucket not available", + }; + } + + // Generate a unique key for the avatar + const timestamp = Date.now(); + const extension = filename ? this.getFileExtension(filename) : "jpg"; + const key = `avatars/${userId}/${timestamp}.${extension}`; + + // Upload the file to R2 + const uploadResult = await this.bucket.put(key, file, { + httpMetadata: { + contentType: this.getContentType(extension), + cacheControl: "public, max-age=31536000", // 1 year cache + }, + customMetadata: { + userId, + uploadedAt: timestamp.toString(), + }, + }); + + if (!uploadResult) { + return { + success: false, + error: "Failed to upload file to R2", + }; + } + + // Return the URL that can be used to access the image + const url = `${this.baseUrl}/api/avatars/${key}`; + + return { + success: true, + url, + key, + }; + } catch (error) { + console.error("Error uploading avatar:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Unknown error occurred", + }; + } + } + + /** + * Delete a user's avatar from R2 + */ + async deleteAvatar( + userId: string, + key?: string + ): Promise { + try { + if (key) { + // Delete specific avatar + await this.bucket.delete(key); + } else { + // Delete all avatars for the user + const objects = await this.bucket.list({ + prefix: `avatars/${userId}/`, + }); + + if (objects.objects.length > 0) { + const keys = objects.objects.map((obj) => obj.key); + await this.bucket.delete(keys); + } + } + + return { success: true }; + } catch (error) { + console.error("Error deleting avatar:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Unknown error occurred", + }; + } + } + + /** + * Get a user's avatar URL + */ + getAvatarUrl(userId: string, key?: string): string { + if (key) { + return `${this.baseUrl}/api/avatars/${key}`; + } + // Return a default avatar URL if no specific key is provided + return `${this.baseUrl}/api/avatars/default`; + } + + /** + * Get file extension from filename + */ + private getFileExtension(filename: string): string { + const parts = filename.split("."); + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "jpg"; + } + + /** + * Get content type based on file extension + */ + private getContentType(extension: string): string { + const contentTypes: Record = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + }; + + return contentTypes[extension] || "image/jpeg"; + } + + /** + * Validate file type and size + */ + validateFile(file: File): { valid: boolean; error?: string } { + const maxSize = 5 * 1024 * 1024; // 5MB + const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; + + if (file.size > maxSize) { + return { + valid: false, + error: "File size must be less than 5MB", + }; + } + + if (!allowedTypes.includes(file.type)) { + return { + valid: false, + error: "File type must be JPEG, PNG, GIF, or WebP", + }; + } + + return { valid: true }; + } +} + +/** + * Create an AvatarService instance + */ +export function createAvatarService( + bucket: R2Bucket, + baseUrl: string +): AvatarService { + return new AvatarService(bucket, baseUrl); +} diff --git a/betterauth-astro/webflow.json b/betterauth-astro/webflow.json index 4b960dc..e029425 100644 --- a/betterauth-astro/webflow.json +++ b/betterauth-astro/webflow.json @@ -1,6 +1,7 @@ { "cloud": { - "framework": "astro" + "framework": "astro", + "project_id": "5a43c332-91d1-49c3-bcc5-d838838b1b69" }, "devlink": { "rootDir": "./devlink", @@ -19,6 +20,11 @@ "allowTelemetry": true, "lastPrompted": 1752013838869, "version": "1.8.13" + }, + "global": { + "allowTelemetry": true, + "lastPrompted": 1754935538518, + "version": "1.8.22" } } } \ No newline at end of file diff --git a/betterauth-astro/worker-configuration.d.ts b/betterauth-astro/worker-configuration.d.ts index 0aaa13b..3c1adae 100644 --- a/betterauth-astro/worker-configuration.d.ts +++ b/betterauth-astro/worker-configuration.d.ts @@ -1,23 +1,14 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 1b55785773fc13f33306fbdb42e6b7eb) +// Generated by Wrangler by running `wrangler types` (hash: bee2343d32686e497c1b3108342fd5c7) // Runtime types generated with workerd@1.20250705.0 2025-04-15 nodejs_compat declare namespace Cloudflare { interface Env { - WEBFLOW_SITE_ID: string; - WEBFLOW_SITE_API_TOKEN: string; - BETTER_AUTH_SECRET: string; - BETTER_AUTH_URL: string; + USER_AVATARS: R2Bucket; DB: D1Database; ASSETS: Fetcher; } } interface Env extends Cloudflare.Env {} -type StringifyValues> = { - [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; -}; -declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} -} // Begin runtime types /*! ***************************************************************************** diff --git a/betterauth-astro/wrangler.json b/betterauth-astro/wrangler.json index 45ded6e..8895c6d 100644 --- a/betterauth-astro/wrangler.json +++ b/betterauth-astro/wrangler.json @@ -1,22 +1,6 @@ { - "$schema": "node_modules/wrangler/config-schema.json", - "name": "astro", + "name": "cosmic-builder-dry-run", "main": "./dist/_worker.js/index.js", - "compatibility_date": "2025-04-15", - "compatibility_flags": ["nodejs_compat"], - "assets": { - "binding": "ASSETS", - "directory": "./dist" - }, - "observability": { - "enabled": true - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "demo", - "database_id": "0", - "migrations_dir": "drizzle" - } - ] + "compatibility_date": "2025-03-03", + "compatibility_flags": ["nodejs_compat"] } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..00b90db --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "auth-cloud-webapp", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From e98fbe2dfc8319f9737904c0bb7c1c9fba44de3e Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Mon, 11 Aug 2025 14:26:13 -0400 Subject: [PATCH 02/68] readded bindings --- betterauth-astro/wrangler.json | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/betterauth-astro/wrangler.json b/betterauth-astro/wrangler.json index 8895c6d..09c80e3 100644 --- a/betterauth-astro/wrangler.json +++ b/betterauth-astro/wrangler.json @@ -1,6 +1,28 @@ { - "name": "cosmic-builder-dry-run", + "$schema": "node_modules/wrangler/config-schema.json", + "name": "astro", "main": "./dist/_worker.js/index.js", - "compatibility_date": "2025-03-03", - "compatibility_flags": ["nodejs_compat"] + "compatibility_date": "2025-04-15", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "binding": "ASSETS", + "directory": "./dist" + }, + "observability": { + "enabled": true + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "demo", + "database_id": "0", + "migrations_dir": "drizzle" + } + ], + "r2_buckets": [ + { + "binding": "USER_AVATARS", + "bucket_name": "user-avatars" + } + ] } From 3c19bf33ff6b8b84ea876af794dfbaabac394a56 Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Mon, 11 Aug 2025 14:32:45 -0400 Subject: [PATCH 03/68] chore: updated api paths to use the base path --- betterauth-astro/src/pages/api/test-profile.ts | 5 +++-- betterauth-astro/src/utils/profile-api.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/betterauth-astro/src/pages/api/test-profile.ts b/betterauth-astro/src/pages/api/test-profile.ts index 57d6eda..ed0555f 100644 --- a/betterauth-astro/src/pages/api/test-profile.ts +++ b/betterauth-astro/src/pages/api/test-profile.ts @@ -4,6 +4,7 @@ import { auth } from "../../utils/auth"; export const GET: APIRoute = async ({ request, locals }) => { try { // Test authentication + const basePath = process.env.BASE_PATH; const authInstance = await auth(locals.runtime.env); const session = await authInstance.api.getSession({ headers: request.headers, @@ -34,8 +35,8 @@ export const GET: APIRoute = async ({ request, locals }) => { hasImage: !!session.user.image, }, endpoints: { - getProfile: "GET /api/user/profile", - updateProfile: "POST /api/user/profile", + getProfile: `${basePath}/api/user/profile`, + updateProfile: `${basePath}/api/user/profile`, }, }), { diff --git a/betterauth-astro/src/utils/profile-api.ts b/betterauth-astro/src/utils/profile-api.ts index b910283..3d56e7b 100644 --- a/betterauth-astro/src/utils/profile-api.ts +++ b/betterauth-astro/src/utils/profile-api.ts @@ -31,7 +31,7 @@ export interface ProfileData { */ export async function fetchProfile(): Promise { try { - const response = await fetch("/api/user/profile", { + const response = await fetch(`${process.env.BASE_PATH}/api/user/profile`, { method: "GET", headers: { "Content-Type": "application/json", @@ -70,7 +70,7 @@ export async function updateProfile( formData.append("avatar", profileData.avatar); } - const response = await fetch("/api/user/profile", { + const response = await fetch(`${process.env.BASE_PATH}/api/user/profile`, { method: "POST", body: formData, }); From b37c0a2f4f3f84035c2ad8cdc0827cb771c1e78b Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Mon, 11 Aug 2025 14:38:07 -0400 Subject: [PATCH 04/68] updated base path for default avatar --- betterauth-astro/src/pages/index.astro | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/betterauth-astro/src/pages/index.astro b/betterauth-astro/src/pages/index.astro index 03a5225..7c3eb7e 100644 --- a/betterauth-astro/src/pages/index.astro +++ b/betterauth-astro/src/pages/index.astro @@ -77,7 +77,7 @@ const user = userData || session.user;
Profile Picture
Date: Mon, 11 Aug 2025 14:44:39 -0400 Subject: [PATCH 05/68] chore: switched to assets prefix --- betterauth-astro/src/pages/index.astro | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/betterauth-astro/src/pages/index.astro b/betterauth-astro/src/pages/index.astro index 7c3eb7e..b625678 100644 --- a/betterauth-astro/src/pages/index.astro +++ b/betterauth-astro/src/pages/index.astro @@ -18,6 +18,9 @@ if (!session) { // Fetch fresh user data const userData = await fetchProfile(); const user = userData || session.user; + +// Get the asset prefix from config +const assetsPrefix = import.meta.env.ASSETS_PREFIX || import.meta.env.BASE_URL || ''; --- @@ -77,7 +80,7 @@ const user = userData || session.user;
Profile Picture
Date: Mon, 11 Aug 2025 15:03:42 -0400 Subject: [PATCH 06/68] docs: tried fixing base path --- betterauth-astro/src/pages/api/user/profile.ts | 9 +++++++-- betterauth-astro/src/utils/profile-api.ts | 6 ++++-- betterauth-astro/src/utils/r2.ts | 2 -- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/betterauth-astro/src/pages/api/user/profile.ts b/betterauth-astro/src/pages/api/user/profile.ts index c84b9d0..412d7e2 100644 --- a/betterauth-astro/src/pages/api/user/profile.ts +++ b/betterauth-astro/src/pages/api/user/profile.ts @@ -65,6 +65,8 @@ export const GET: APIRoute = async ({ request, locals }) => { }; export const POST: APIRoute = async ({ request, locals }) => { + // Get the base URL from the request URL + const basePath = locals.runtime.env.BASE_URL; try { // Get the authenticated user const authInstance = await auth(locals.runtime.env); @@ -113,8 +115,9 @@ export const POST: APIRoute = async ({ request, locals }) => { // Handle avatar upload if provided if (avatarFile && avatarFile.size > 0) { + const bucket = locals.runtime.env.USER_AVATARS; const avatarService = createAvatarService( - locals.runtime.env.USER_AVATARS, + bucket, new URL(request.url).origin ); @@ -133,7 +136,9 @@ export const POST: APIRoute = async ({ request, locals }) => { // Delete old avatar if it exists if (session.user.image) { // Extract key from existing URL - const existingKey = session.user.image.split("/api/avatars/")[1]; + const existingKey = session.user.image.split( + `${basePath}/api/avatars/` + )[1]; if (existingKey) { await avatarService.deleteAvatar(userId, existingKey); } diff --git a/betterauth-astro/src/utils/profile-api.ts b/betterauth-astro/src/utils/profile-api.ts index 3d56e7b..6008a59 100644 --- a/betterauth-astro/src/utils/profile-api.ts +++ b/betterauth-astro/src/utils/profile-api.ts @@ -31,7 +31,8 @@ export interface ProfileData { */ export async function fetchProfile(): Promise { try { - const response = await fetch(`${process.env.BASE_PATH}/api/user/profile`, { + const basePath = import.meta.env.BASE_URL; + const response = await fetch(`${basePath}/api/user/profile`, { method: "GET", headers: { "Content-Type": "application/json", @@ -70,7 +71,8 @@ export async function updateProfile( formData.append("avatar", profileData.avatar); } - const response = await fetch(`${process.env.BASE_PATH}/api/user/profile`, { + const basePath = import.meta.env.BASE_URL; + const response = await fetch(`${basePath}/api/user/profile`, { method: "POST", body: formData, }); diff --git a/betterauth-astro/src/utils/r2.ts b/betterauth-astro/src/utils/r2.ts index 0491181..127f21b 100644 --- a/betterauth-astro/src/utils/r2.ts +++ b/betterauth-astro/src/utils/r2.ts @@ -1,5 +1,3 @@ -import type { R2Bucket } from "@cloudflare/workers-types"; - export interface AvatarUploadResult { success: boolean; url?: string; From 3e8e29bba4e268da9229f369179520e63b7a63ab Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Mon, 11 Aug 2025 15:08:34 -0400 Subject: [PATCH 07/68] chore: removed csrf protectiosn --- betterauth-astro/astro.config.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/betterauth-astro/astro.config.mjs b/betterauth-astro/astro.config.mjs index a623775..f384a03 100644 --- a/betterauth-astro/astro.config.mjs +++ b/betterauth-astro/astro.config.mjs @@ -7,6 +7,9 @@ import react from "@astrojs/react"; export default defineConfig({ base: "/app", output: "server", + security: { + checkOrigin: false, + }, adapter: cloudflare({ platformProxy: { enabled: true, From 1719e63436b8a0ca271a029955cbeb791a5d2990 Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Mon, 11 Aug 2025 15:17:27 -0400 Subject: [PATCH 08/68] chore: fixed base paths --- betterauth-astro/src/pages/api/avatars/[...key].ts | 5 +++-- betterauth-astro/src/pages/api/test-profile.ts | 2 +- betterauth-astro/src/pages/api/user/profile.ts | 5 +++-- betterauth-astro/src/utils/auth-client.ts | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/betterauth-astro/src/pages/api/avatars/[...key].ts b/betterauth-astro/src/pages/api/avatars/[...key].ts index 3695ce5..3613355 100644 --- a/betterauth-astro/src/pages/api/avatars/[...key].ts +++ b/betterauth-astro/src/pages/api/avatars/[...key].ts @@ -45,9 +45,10 @@ export const OPTIONS: APIRoute = async () => { return new Response(null, { status: 200, headers: { - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": "https://hello-webflow-cloud.webflow.io", "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Credentials": "true", }, }); }; diff --git a/betterauth-astro/src/pages/api/test-profile.ts b/betterauth-astro/src/pages/api/test-profile.ts index ed0555f..e3da0fd 100644 --- a/betterauth-astro/src/pages/api/test-profile.ts +++ b/betterauth-astro/src/pages/api/test-profile.ts @@ -4,7 +4,7 @@ import { auth } from "../../utils/auth"; export const GET: APIRoute = async ({ request, locals }) => { try { // Test authentication - const basePath = process.env.BASE_PATH; + const basePath = locals.runtime.env.BASE_URL; const authInstance = await auth(locals.runtime.env); const session = await authInstance.api.getSession({ headers: request.headers, diff --git a/betterauth-astro/src/pages/api/user/profile.ts b/betterauth-astro/src/pages/api/user/profile.ts index 412d7e2..a947bcf 100644 --- a/betterauth-astro/src/pages/api/user/profile.ts +++ b/betterauth-astro/src/pages/api/user/profile.ts @@ -209,9 +209,10 @@ export const OPTIONS: APIRoute = async () => { return new Response(null, { status: 200, headers: { - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": "https://hello-webflow-cloud.webflow.io", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Credentials": "true", }, }); }; diff --git a/betterauth-astro/src/utils/auth-client.ts b/betterauth-astro/src/utils/auth-client.ts index d9d4012..43e5847 100644 --- a/betterauth-astro/src/utils/auth-client.ts +++ b/betterauth-astro/src/utils/auth-client.ts @@ -4,5 +4,5 @@ export const authClient = createAuthClient({ baseURL: typeof window !== "undefined" ? `${window.location.origin}/app/api/auth` - : "http://localhost:8787/app/api/auth", + : "https://hello-webflow-cloud.webflow.io/app/api/auth", }); From 3d89f48e30a70d5c09d3df297b097f7b569027f2 Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Mon, 11 Aug 2025 15:23:24 -0400 Subject: [PATCH 09/68] chore : trying to fix cors --- betterauth-astro/cloudflare-env.d.ts | 2 ++ betterauth-astro/src/utils/auth.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/betterauth-astro/cloudflare-env.d.ts b/betterauth-astro/cloudflare-env.d.ts index 8fb40cc..7b9a010 100644 --- a/betterauth-astro/cloudflare-env.d.ts +++ b/betterauth-astro/cloudflare-env.d.ts @@ -6,6 +6,8 @@ declare namespace Cloudflare { USER_AVATARS: R2Bucket; DB: D1Database; ASSETS: Fetcher; + BETTER_AUTH_SECRET: string; + BETTER_AUTH_URL: string; } } interface CloudflareEnv extends Cloudflare.Env {} diff --git a/betterauth-astro/src/utils/auth.ts b/betterauth-astro/src/utils/auth.ts index 44d9137..e48d0a0 100644 --- a/betterauth-astro/src/utils/auth.ts +++ b/betterauth-astro/src/utils/auth.ts @@ -19,6 +19,7 @@ export const auth = async (envMap: Cloudflare.Env) => { trustedOrigins: [ "http://localhost:4321", "http://localhost:8787", + "https://hello-webflow-cloud.webflow.io", envMap.BETTER_AUTH_URL || "", ], }); From fd46142d6538d2f5a6264202f4a160bb4c5fb384 Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Mon, 11 Aug 2025 16:16:17 -0400 Subject: [PATCH 10/68] chore: trying to fix memory upload issue --- .../src/pages/api/user/profile.ts | 37 +++++++++++++++++-- betterauth-astro/src/utils/r2.ts | 2 +- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/betterauth-astro/src/pages/api/user/profile.ts b/betterauth-astro/src/pages/api/user/profile.ts index a947bcf..12e552e 100644 --- a/betterauth-astro/src/pages/api/user/profile.ts +++ b/betterauth-astro/src/pages/api/user/profile.ts @@ -68,6 +68,10 @@ export const POST: APIRoute = async ({ request, locals }) => { // Get the base URL from the request URL const basePath = locals.runtime.env.BASE_URL; try { + // Log request details for debugging + console.log("Profile update request received"); + console.log("Content-Type:", request.headers.get("content-type")); + console.log("Content-Length:", request.headers.get("content-length")); // Get the authenticated user const authInstance = await auth(locals.runtime.env); const session = await authInstance.api.getSession({ @@ -82,7 +86,24 @@ export const POST: APIRoute = async ({ request, locals }) => { } const userId = session.user.id; - const formData = await request.formData(); + + // Parse form data with error handling + let formData: FormData; + try { + formData = await request.formData(); + console.log("FormData parsed successfully"); + } catch (error) { + console.error("Error parsing FormData:", error); + return new Response( + JSON.stringify({ + success: false, + error: + "Failed to parse form data. File may be too large or corrupted.", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + const name = formData.get("name") as string; const email = formData.get("email") as string; const avatarFile = formData.get("avatar") as File | null; @@ -115,6 +136,11 @@ export const POST: APIRoute = async ({ request, locals }) => { // Handle avatar upload if provided if (avatarFile && avatarFile.size > 0) { + console.log("Avatar file detected:", { + name: avatarFile.name, + size: avatarFile.size, + type: avatarFile.type, + }); const bucket = locals.runtime.env.USER_AVATARS; const avatarService = createAvatarService( bucket, @@ -144,11 +170,14 @@ export const POST: APIRoute = async ({ request, locals }) => { } } - // Convert File to ArrayBuffer and upload - const fileBuffer = await avatarFile.arrayBuffer(); + // Upload file as a stream to avoid memory issues + console.log("Uploading file as stream..."); + const fileStream = avatarFile.stream(); + console.log("File stream created, size:", avatarFile.size); + const uploadResult = await avatarService.uploadAvatar( userId, - fileBuffer, + fileStream, avatarFile.name ); diff --git a/betterauth-astro/src/utils/r2.ts b/betterauth-astro/src/utils/r2.ts index 127f21b..c9698f2 100644 --- a/betterauth-astro/src/utils/r2.ts +++ b/betterauth-astro/src/utils/r2.ts @@ -24,7 +24,7 @@ export class AvatarService { */ async uploadAvatar( userId: string, - file: ArrayBuffer, + file: ArrayBuffer | ReadableStream, filename?: string ): Promise { try { From a76e7f37ba43280c520c53f5f93464f70945edd5 Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Mon, 11 Aug 2025 16:46:02 -0400 Subject: [PATCH 11/68] chore: fix size limit issues --- .../src/pages/api/upload-avatar.ts | 107 +++++++++++++++ .../src/pages/api/user/profile.ts | 124 +++++++++--------- betterauth-astro/src/pages/index.astro | 49 +++++-- betterauth-astro/src/utils/profile-api.ts | 69 +++++++--- 4 files changed, 263 insertions(+), 86 deletions(-) create mode 100644 betterauth-astro/src/pages/api/upload-avatar.ts diff --git a/betterauth-astro/src/pages/api/upload-avatar.ts b/betterauth-astro/src/pages/api/upload-avatar.ts new file mode 100644 index 0000000..b2cbebe --- /dev/null +++ b/betterauth-astro/src/pages/api/upload-avatar.ts @@ -0,0 +1,107 @@ +import type { APIRoute } from "astro"; +import { auth } from "../../utils/auth"; +import { createAvatarService } from "../../utils/r2"; + +export const POST: APIRoute = async ({ request, locals }) => { + try { + // Get the authenticated user + const authInstance = await auth(locals.runtime.env); + const session = await authInstance.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return new Response( + JSON.stringify({ success: false, error: "Unauthorized" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + const userId = session.user.id; + const formData = await request.formData(); + const avatarFile = formData.get("avatar") as File | null; + + if (!avatarFile || avatarFile.size === 0) { + return new Response( + JSON.stringify({ + success: false, + error: "No file provided", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const bucket = locals.runtime.env.USER_AVATARS; + const avatarService = createAvatarService( + bucket, + new URL(request.url).origin + ); + + // Validate the file + const validation = avatarService.validateFile(avatarFile); + if (!validation.valid) { + return new Response( + JSON.stringify({ + success: false, + error: validation.error, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Upload file as a stream + console.log("Uploading avatar file:", { + name: avatarFile.name, + size: avatarFile.size, + type: avatarFile.type, + }); + + const fileStream = avatarFile.stream(); + const uploadResult = await avatarService.uploadAvatar( + userId, + fileStream, + avatarFile.name + ); + + if (!uploadResult.success) { + return new Response( + JSON.stringify({ + success: false, + error: uploadResult.error || "Failed to upload avatar", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response( + JSON.stringify({ + success: true, + url: uploadResult.url, + key: uploadResult.key, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error uploading avatar:", error); + return new Response( + JSON.stringify({ + success: false, + error: "Internal server error", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}; + +// Handle OPTIONS for CORS +export const OPTIONS: APIRoute = async () => { + return new Response(null, { + status: 200, + headers: { + "Access-Control-Allow-Origin": "https://hello-webflow-cloud.webflow.io", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Credentials": "true", + }, + }); +}; diff --git a/betterauth-astro/src/pages/api/user/profile.ts b/betterauth-astro/src/pages/api/user/profile.ts index 12e552e..7e5ed8e 100644 --- a/betterauth-astro/src/pages/api/user/profile.ts +++ b/betterauth-astro/src/pages/api/user/profile.ts @@ -134,65 +134,8 @@ export const POST: APIRoute = async ({ request, locals }) => { const db = getDb(locals.runtime.env.DB); let avatarUrl = session.user.image; // Keep existing avatar if no new one uploaded - // Handle avatar upload if provided - if (avatarFile && avatarFile.size > 0) { - console.log("Avatar file detected:", { - name: avatarFile.name, - size: avatarFile.size, - type: avatarFile.type, - }); - const bucket = locals.runtime.env.USER_AVATARS; - const avatarService = createAvatarService( - bucket, - new URL(request.url).origin - ); - - // Validate the file - const validation = avatarService.validateFile(avatarFile); - if (!validation.valid) { - return new Response( - JSON.stringify({ - success: false, - error: validation.error, - }), - { status: 400, headers: { "Content-Type": "application/json" } } - ); - } - - // Delete old avatar if it exists - if (session.user.image) { - // Extract key from existing URL - const existingKey = session.user.image.split( - `${basePath}/api/avatars/` - )[1]; - if (existingKey) { - await avatarService.deleteAvatar(userId, existingKey); - } - } - - // Upload file as a stream to avoid memory issues - console.log("Uploading file as stream..."); - const fileStream = avatarFile.stream(); - console.log("File stream created, size:", avatarFile.size); - - const uploadResult = await avatarService.uploadAvatar( - userId, - fileStream, - avatarFile.name - ); - - if (!uploadResult.success) { - return new Response( - JSON.stringify({ - success: false, - error: uploadResult.error || "Failed to upload avatar", - }), - { status: 500, headers: { "Content-Type": "application/json" } } - ); - } - - avatarUrl = uploadResult.url!; - } + // Note: Avatar uploads are now handled by a separate endpoint (/api/upload-avatar) + // This endpoint only handles profile data updates // Update user in database const updateData: any = { @@ -245,3 +188,66 @@ export const OPTIONS: APIRoute = async () => { }, }); }; + +// New endpoint for getting presigned upload URLs +export const PUT: APIRoute = async ({ request, locals }) => { + try { + // Get the authenticated user + const authInstance = await auth(locals.runtime.env); + const session = await authInstance.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return new Response( + JSON.stringify({ success: false, error: "Unauthorized" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + const userId = session.user.id; + const body = (await request.json()) as { + filename: string; + contentType: string; + }; + const { filename, contentType } = body; + + if (!filename || !contentType) { + return new Response( + JSON.stringify({ + success: false, + error: "Filename and content type are required", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const bucket = locals.runtime.env.USER_AVATARS; + const timestamp = Date.now(); + const extension = filename.split(".").pop() || "jpg"; + const key = `avatars/${userId}/${timestamp}.${extension}`; + + // Create a presigned URL for direct upload (R2 doesn't support createMultipartUpload) + // Instead, we'll use a different approach - let's just return the key and handle upload differently + const uploadKey = key; + + return new Response( + JSON.stringify({ + success: true, + uploadKey: uploadKey, + key, + url: `${new URL(request.url).origin}/app/api/avatars/${key}`, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error creating upload URL:", error); + return new Response( + JSON.stringify({ + success: false, + error: "Internal server error", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}; diff --git a/betterauth-astro/src/pages/index.astro b/betterauth-astro/src/pages/index.astro index b625678..0b33f0d 100644 --- a/betterauth-astro/src/pages/index.astro +++ b/betterauth-astro/src/pages/index.astro @@ -310,7 +310,7 @@ const assetsPrefix = import.meta.env.ASSETS_PREFIX || import.meta.env.BASE_URL |
diff --git a/betterauth-astro/src/pages/index.astro b/betterauth-astro/src/pages/index.astro index fed368d..f6407a8 100644 --- a/betterauth-astro/src/pages/index.astro +++ b/betterauth-astro/src/pages/index.astro @@ -12,7 +12,7 @@ const session = await authInstance.api.getSession({ // Redirect if not authenticated if (!session) { - return Astro.redirect("/app/login"); + return Astro.redirect(import.meta.env.BASE_URL + "/login"); } // Use session user data directly since fetchProfile requires client-side authentication @@ -269,6 +269,29 @@ console.log("assetsPrefix", assetsPrefix); Save Changes + + File Manager + + { - window.location.href = "/app/login"; + window.location.href = import.meta.env.BASE_URL + "/login"; }, }, }); diff --git a/betterauth-astro/src/pages/login.astro b/betterauth-astro/src/pages/login.astro index dcb28b0..ad623ee 100644 --- a/betterauth-astro/src/pages/login.astro +++ b/betterauth-astro/src/pages/login.astro @@ -11,7 +11,7 @@ console.log("session", session); // Redirect if already authenticated if (session) { - return Astro.redirect("/app"); + return Astro.redirect(import.meta.env.BASE_URL + "/"); } --- @@ -25,7 +25,7 @@ if (session) {

Or create a new account @@ -111,7 +111,7 @@ if (session) { errorDiv.classList.remove('hidden'); } else if (data) { // Redirect on success - window.location.href = '/app'; + window.location.href = import.meta.env.BASE_URL + '/'; } } catch (err) { errorDiv.textContent = 'An unexpected error occurred'; diff --git a/betterauth-astro/src/pages/signup.astro b/betterauth-astro/src/pages/signup.astro index bf91471..3615f97 100644 --- a/betterauth-astro/src/pages/signup.astro +++ b/betterauth-astro/src/pages/signup.astro @@ -11,7 +11,7 @@ console.log("session", session); // Redirect if already authenticated if (session) { - return Astro.redirect("/app"); + return Astro.redirect(import.meta.env.BASE_URL + "/"); } --- @@ -25,7 +25,7 @@ if (session) {

Or sign in to your existing account @@ -128,7 +128,7 @@ if (session) { errorDiv.classList.remove('hidden'); } else if (data) { // Redirect on success - window.location.href = '/app'; + window.location.href = import.meta.env.BASE_URL + '/'; } } catch (err) { errorDiv.textContent = 'An unexpected error occurred'; diff --git a/betterauth-astro/src/utils/file-api.ts b/betterauth-astro/src/utils/file-api.ts new file mode 100644 index 0000000..e45cf7b --- /dev/null +++ b/betterauth-astro/src/utils/file-api.ts @@ -0,0 +1,206 @@ +export interface FileInfo { + key: string; + url: string; + filename: string; + contentType: string; + fileSize: number; + uploadedAt: string; + userId: string; +} + +export interface FileUploadResponse { + success: boolean; + url?: string; + key?: string; + filename?: string; + fileSize?: number; + contentType?: string; + error?: string; +} + +export interface FileListResponse { + success: boolean; + files?: FileInfo[]; + error?: string; +} + +export interface FileDeleteResponse { + success: boolean; + message?: string; + error?: string; +} + +/** + * Upload a file to the server + */ +export async function uploadFile(file: File): Promise { + try { + // Step 1: Generate upload URL with signature + const baseUrl = import.meta.env.BASE_URL as string; + const generateUrlResponse = await fetch( + `${baseUrl}/api/generate-file-upload-url`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + fileName: file.name, + fileType: file.type, + fileSize: file.size, + }), + } + ); + + if (!generateUrlResponse.ok) { + const errorText = await generateUrlResponse.text(); + console.error("Generate URL error:", errorText); + throw new Error( + `Failed to generate upload URL: ${generateUrlResponse.status}` + ); + } + + const generateData = (await generateUrlResponse.json()) as { + success: boolean; + uploadUrl?: string; + key?: string; + error?: string; + }; + + if (!generateData.success) { + throw new Error(generateData.error || "Failed to generate upload URL"); + } + + // Step 2: Upload file using the signed URL + const formData = new FormData(); + formData.append("file", file); + + const uploadResponse = await fetch(generateData.uploadUrl!, { + method: "POST", + body: formData, + }); + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text(); + console.error("Upload error:", errorText); + throw new Error(`Upload failed: ${uploadResponse.status}`); + } + + const data = (await uploadResponse.json()) as FileUploadResponse; + return data; + } catch (error) { + console.error("Error uploading file:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Upload failed", + }; + } +} + +/** + * List all files for the current user + */ +export async function listFiles(): Promise { + try { + const baseUrl = import.meta.env.BASE_URL as string; + const response = await fetch(`${baseUrl}/api/files/list`, { + method: "GET", + credentials: "include", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("List files error:", errorText); + throw new Error(`Failed to list files: ${response.status}`); + } + + const data = (await response.json()) as FileListResponse; + return data; + } catch (error) { + console.error("Error listing files:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to list files", + }; + } +} + +/** + * Delete a file + */ +export async function deleteFile(key: string): Promise { + try { + const baseUrl = import.meta.env.BASE_URL as string; + const response = await fetch(`${baseUrl}/api/files/delete`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ key }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Delete file error:", errorText); + throw new Error(`Failed to delete file: ${response.status}`); + } + + const data = (await response.json()) as FileDeleteResponse; + return data; + } catch (error) { + console.error("Error deleting file:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to delete file", + }; + } +} + +/** + * Get file type category for display + */ +export function getFileTypeCategory(contentType: string): string { + if (contentType.startsWith("image/")) return "Image"; + if (contentType.startsWith("video/")) return "Video"; + if (contentType.startsWith("audio/")) return "Audio"; + if (contentType.startsWith("text/")) return "Document"; + if (contentType.includes("pdf")) return "Document"; + if ( + contentType.includes("zip") || + contentType.includes("rar") || + contentType.includes("tar") + ) + return "Archive"; + return "Other"; +} + +/** + * Format file size for display + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + +/** + * Get file icon based on content type + */ +export function getFileIcon(contentType: string): string { + if (contentType.startsWith("image/")) return "🖼️"; + if (contentType.startsWith("video/")) return "🎥"; + if (contentType.startsWith("audio/")) return "🎵"; + if (contentType.startsWith("text/")) return "📄"; + if (contentType.includes("pdf")) return "📕"; + if ( + contentType.includes("zip") || + contentType.includes("rar") || + contentType.includes("tar") + ) + return "📦"; + return "📎"; +} diff --git a/betterauth-astro/src/utils/file-service.ts b/betterauth-astro/src/utils/file-service.ts new file mode 100644 index 0000000..cbebfc4 --- /dev/null +++ b/betterauth-astro/src/utils/file-service.ts @@ -0,0 +1,323 @@ +export interface FileUploadResult { + success: boolean; + url?: string; + key?: string; + error?: string; + fileSize?: number; + contentType?: string; +} + +export interface FileDeleteResult { + success: boolean; + error?: string; +} + +export interface FileValidationResult { + valid: boolean; + error?: string; +} + +export interface FileInfo { + key: string; + url: string; + filename: string; + contentType: string; + fileSize: number; + uploadedAt: string; + userId: string; +} + +export class FileService { + private bucket: R2Bucket; + private baseUrl: string; + + constructor(bucket: R2Bucket, baseUrl: string) { + this.bucket = bucket; + this.baseUrl = baseUrl; + } + + /** + * Upload any file to R2 + */ + async uploadFile( + userId: string, + file: ArrayBuffer | ReadableStream, + filename: string, + contentType: string + ): Promise { + const basePath = import.meta.env.ASSETS_PREFIX; + try { + if (!this.bucket) { + return { + success: false, + error: "R2 bucket not available", + }; + } + + // Generate a unique key for the file + const timestamp = Date.now(); + const extension = this.getFileExtension(filename); + const key = `files/${userId}/${timestamp}-${this.generateRandomString()}.${extension}`; + + return await this.uploadFileWithKey( + userId, + file, + key, + filename, + contentType + ); + } catch (error) { + console.error("Error uploading file:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Unknown error occurred", + }; + } + } + + /** + * Upload file with a specific key + */ + async uploadFileWithKey( + userId: string, + file: ArrayBuffer | ReadableStream, + key: string, + filename: string, + contentType?: string + ): Promise { + const basePath = import.meta.env.ASSETS_PREFIX; + try { + if (!this.bucket) { + return { + success: false, + error: "R2 bucket not available", + }; + } + + // Generate a unique key for the file + const timestamp = Date.now(); + const extension = this.getFileExtension(filename); + const key = `files/${userId}/${timestamp}-${this.generateRandomString()}.${extension}`; + + // Upload the file to R2 + console.log("Starting file upload to R2..."); + const uploadResult = await this.bucket.put(key, file, { + httpMetadata: { + contentType: + contentType || this.getContentType(this.getFileExtension(filename)), + cacheControl: "public, max-age=31536000", // 1 year cache + }, + customMetadata: { + userId, + filename, + uploadedAt: timestamp.toString(), + originalName: filename, + }, + }); + + if (!uploadResult) { + return { + success: false, + error: "Failed to upload file to R2", + }; + } + + // Return the URL that can be used to access the file + const url = `${basePath}/api/files/${key}`; + + return { + success: true, + url, + key, + fileSize: file instanceof ArrayBuffer ? file.byteLength : undefined, + contentType, + }; + } catch (error) { + console.error("Error uploading file:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Unknown error occurred", + }; + } + } + + /** + * Delete a file from R2 + */ + async deleteFile(key: string): Promise { + try { + await this.bucket.delete(key); + return { success: true }; + } catch (error) { + console.error("Error deleting file:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Unknown error occurred", + }; + } + } + + /** + * List all files for a user + */ + async listUserFiles(userId: string): Promise { + const basePath = import.meta.env.ASSETS_PREFIX; + try { + const objects = await this.bucket.list({ + prefix: `files/${userId}/`, + }); + + const files: FileInfo[] = []; + for (const obj of objects.objects) { + const metadata = await this.bucket.head(obj.key); + if (metadata) { + files.push({ + key: obj.key, + url: `${basePath}/api/files/${obj.key}`, + filename: + metadata.customMetadata?.originalName || + obj.key.split("/").pop() || + "Unknown", + contentType: + metadata.httpMetadata?.contentType || "application/octet-stream", + fileSize: obj.size, + uploadedAt: metadata.customMetadata?.uploadedAt + ? new Date( + parseInt(metadata.customMetadata.uploadedAt) + ).toISOString() + : new Date().toISOString(), + userId: metadata.customMetadata?.userId || userId, + }); + } + } + + // Sort by upload date (newest first) + return files.sort( + (a, b) => + new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime() + ); + } catch (error) { + console.error("Error listing files:", error); + return []; + } + } + + /** + * Validate uploaded file + */ + validateFile(file: File): FileValidationResult { + // Check file size (1GB limit for large files like videos) + const maxSize = 1000 * 1024 * 1024; // 100MB + if (file.size > maxSize) { + return { + valid: false, + error: "File size must be less than 1GB", + }; + } + + // Check for potentially dangerous file types + const dangerousExtensions = [ + "exe", + "bat", + "cmd", + "com", + "pif", + "scr", + "vbs", + "js", + ]; + const extension = this.getFileExtension(file.name).toLowerCase(); + if (dangerousExtensions.includes(extension)) { + return { + valid: false, + error: "This file type is not allowed for security reasons", + }; + } + + return { valid: true }; + } + + /** + * Get file extension from filename + */ + private getFileExtension(filename: string): string { + const parts = filename.split("."); + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; + } + + /** + * Get content type from file extension + */ + private getContentType(extension: string): string { + const typeMap: Record = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + mp4: "video/mp4", + webm: "video/webm", + mov: "video/quicktime", + avi: "video/x-msvideo", + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + pdf: "application/pdf", + txt: "text/plain", + zip: "application/zip", + rar: "application/x-rar-compressed", + tar: "application/x-tar", + }; + return typeMap[extension] || "application/octet-stream"; + } + + /** + * Generate a random string for unique file naming + */ + private generateRandomString(): string { + return Math.random().toString(36).substring(2, 15); + } + + /** + * Get file type category for display + */ + getFileTypeCategory(contentType: string): string { + if (contentType.startsWith("image/")) return "Image"; + if (contentType.startsWith("video/")) return "Video"; + if (contentType.startsWith("audio/")) return "Audio"; + if (contentType.startsWith("text/")) return "Document"; + if (contentType.includes("pdf")) return "Document"; + if ( + contentType.includes("zip") || + contentType.includes("rar") || + contentType.includes("tar") + ) + return "Archive"; + return "Other"; + } + + /** + * Format file size for display + */ + formatFileSize(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + } +} + +/** + * Create a file service instance + */ +export function createFileService( + bucket: R2Bucket, + baseUrl: string +): FileService { + return new FileService(bucket, baseUrl); +} diff --git a/betterauth-nextjs/src/app/api/files/[...key]/route.ts b/betterauth-nextjs/src/app/api/files/[...key]/route.ts new file mode 100644 index 0000000..c4a2451 --- /dev/null +++ b/betterauth-nextjs/src/app/api/files/[...key]/route.ts @@ -0,0 +1,63 @@ +import { NextRequest } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ key: string[] }> } +) { + try { + const resolvedParams = await params; + const key = resolvedParams.key?.join("/"); + + if (!key) { + return new Response("File key is required", { status: 400 }); + } + + const { env } = await getCloudflareContext({ async: true }); + const bucket = env.USER_AVATARS as any; + + // Get the object from R2 + const object = await bucket.get(key); + + if (!object) { + return new Response("File not found", { status: 404 }); + } + + // Get the object body + const body = await object.arrayBuffer(); + + // Create response with appropriate headers + const response = new Response(body, { + status: 200, + headers: { + "Content-Type": + object.httpMetadata?.contentType || "application/octet-stream", + "Cache-Control": + object.httpMetadata?.cacheControl || "public, max-age=31536000", + "Content-Length": object.size.toString(), + ETag: object.httpEtag, + // Add CORS headers for web access + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); + + return response; + } catch (error) { + console.error("Error serving file:", error); + return new Response("Internal server error", { status: 500 }); + } +} + +// Handle OPTIONS for CORS +export async function OPTIONS() { + return new Response(null, { + status: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +} diff --git a/betterauth-nextjs/src/app/api/files/delete/route.ts b/betterauth-nextjs/src/app/api/files/delete/route.ts new file mode 100644 index 0000000..bd6eec1 --- /dev/null +++ b/betterauth-nextjs/src/app/api/files/delete/route.ts @@ -0,0 +1,79 @@ +import { NextRequest } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { createAuth } from "@/lib/auth"; +import { createFileService } from "@/lib/file-service"; + +export async function DELETE(request: NextRequest) { + try { + // Get the authenticated user + const authInstance = await createAuth(); + const session = await authInstance.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return new Response( + JSON.stringify({ success: false, error: "Unauthorized" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + const userId = session.user.id; + const { key } = await request.json(); + + if (!key) { + return new Response( + JSON.stringify({ + success: false, + error: "File key is required", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Verify the file belongs to the user + if (!key.startsWith(`files/${userId}/`)) { + return new Response( + JSON.stringify({ + success: false, + error: "Unauthorized to delete this file", + }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + const { env } = await getCloudflareContext({ async: true }); + const bucket = env.USER_AVATARS as any; // Using the same bucket for now + const fileService = createFileService(bucket, new URL(request.url).origin); + + // Delete the file + const deleteResult = await fileService.deleteFile(key); + + if (!deleteResult.success) { + return new Response( + JSON.stringify({ + success: false, + error: deleteResult.error || "Failed to delete file", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response( + JSON.stringify({ + success: true, + message: "File deleted successfully", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error deleting file:", error); + return new Response( + JSON.stringify({ + success: false, + error: "Internal server error", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +} diff --git a/betterauth-nextjs/src/app/api/files/list/route.ts b/betterauth-nextjs/src/app/api/files/list/route.ts new file mode 100644 index 0000000..e278875 --- /dev/null +++ b/betterauth-nextjs/src/app/api/files/list/route.ts @@ -0,0 +1,47 @@ +import { NextRequest } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { createAuth } from "@/lib/auth"; +import { createFileService } from "@/lib/file-service"; + +export async function GET(request: NextRequest) { + try { + // Get the authenticated user + const authInstance = await createAuth(); + const session = await authInstance.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return new Response( + JSON.stringify({ success: false, error: "Unauthorized" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + const userId = session.user.id; + + const { env } = await getCloudflareContext({ async: true }); + const bucket = env.USER_AVATARS as any; // Using the same bucket for now + const fileService = createFileService(bucket, new URL(request.url).origin); + + // List user files + const files = await fileService.listUserFiles(userId); + + return new Response( + JSON.stringify({ + success: true, + files, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error listing files:", error); + return new Response( + JSON.stringify({ + success: false, + error: "Internal server error", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +} diff --git a/betterauth-nextjs/src/app/api/files/temp-upload/[...token]/route.ts b/betterauth-nextjs/src/app/api/files/temp-upload/[...token]/route.ts new file mode 100644 index 0000000..b59588d --- /dev/null +++ b/betterauth-nextjs/src/app/api/files/temp-upload/[...token]/route.ts @@ -0,0 +1,153 @@ +import { NextRequest } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { createFileService } from "@/lib/file-service"; +import crypto from "crypto"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ token: string[] }> } +) { + try { + const resolvedParams = await params; + const token = resolvedParams.token?.join("/"); + + if (!token) { + return new Response( + JSON.stringify({ + success: false, + error: "Upload token is required", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Decode and validate the token + let tokenData; + try { + const decodedToken = Buffer.from(token, "base64url").toString(); + tokenData = JSON.parse(decodedToken); + } catch (error) { + return new Response( + JSON.stringify({ + success: false, + error: "Invalid upload token", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Validate token expiration + if (Date.now() > tokenData.expires) { + return new Response( + JSON.stringify({ + success: false, + error: "Upload token has expired", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Validate signature + const { env } = await getCloudflareContext({ async: true }); + const expectedSignature = crypto + .createHmac("sha256", env.BETTER_AUTH_SECRET as string) + .update(`${tokenData.userId}:${tokenData.key}:${tokenData.expires}`) + .digest("hex"); + + if (tokenData.signature !== expectedSignature) { + return new Response( + JSON.stringify({ + success: false, + error: "Invalid upload token signature", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const userId = tokenData.userId; + const key = tokenData.key; + + const formData = await request.formData(); + const file = formData.get("file") as File | null; + + if (!file || file.size === 0) { + return new Response( + JSON.stringify({ + success: false, + error: "No file provided", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const bucket = env.USER_AVATARS as any; + const fileService = createFileService(bucket, new URL(request.url).origin); + + // Validate the file + const validation = fileService.validateFile(file); + if (!validation.valid) { + return new Response( + JSON.stringify({ + success: false, + error: validation.error, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Upload the file using the provided key + const fileBuffer = await file.arrayBuffer(); + const uploadResult = await fileService.uploadFileWithKey( + userId, + fileBuffer, + key, + file.name, + file.type + ); + + if (!uploadResult.success) { + return new Response( + JSON.stringify({ + success: false, + error: uploadResult.error || "Failed to upload file", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + // Token is automatically expired after use (no cleanup needed) + + return new Response( + JSON.stringify({ + success: true, + url: uploadResult.url, + key: uploadResult.key, + filename: file.name, + fileSize: file.size, + contentType: file.type, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error in temp upload:", error); + return new Response( + JSON.stringify({ + success: false, + error: "Internal server error", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +} + +// Handle OPTIONS for CORS +export async function OPTIONS() { + return new Response(null, { + status: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +} diff --git a/betterauth-nextjs/src/app/api/files/upload/route.ts b/betterauth-nextjs/src/app/api/files/upload/route.ts new file mode 100644 index 0000000..ba96e23 --- /dev/null +++ b/betterauth-nextjs/src/app/api/files/upload/route.ts @@ -0,0 +1,91 @@ +import { NextRequest } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { createAuth } from "@/lib/auth"; +import { createFileService } from "@/lib/file-service"; + +export async function POST(request: NextRequest) { + try { + // Get the authenticated user + const authInstance = await createAuth(); + const session = await authInstance.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return new Response( + JSON.stringify({ success: false, error: "Unauthorized" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + const userId = session.user.id; + const formData = await request.formData(); + const file = formData.get("file") as File | null; + + if (!file || file.size === 0) { + return new Response( + JSON.stringify({ + success: false, + error: "No file provided", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const { env } = await getCloudflareContext({ async: true }); + const bucket = env.USER_AVATARS as any; // Using the same bucket for now + const fileService = createFileService(bucket, new URL(request.url).origin); + + // Validate the file + const validation = fileService.validateFile(file); + if (!validation.valid) { + return new Response( + JSON.stringify({ + success: false, + error: validation.error, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Convert to ArrayBuffer for upload + const fileBuffer = await file.arrayBuffer(); + const uploadResult = await fileService.uploadFile( + userId, + fileBuffer, + file.name, + file.type + ); + + if (!uploadResult.success) { + return new Response( + JSON.stringify({ + success: false, + error: uploadResult.error || "Failed to upload file", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response( + JSON.stringify({ + success: true, + url: uploadResult.url, + key: uploadResult.key, + filename: file.name, + fileSize: file.size, + contentType: file.type, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error uploading file:", error); + return new Response( + JSON.stringify({ + success: false, + error: "Internal server error", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +} diff --git a/betterauth-nextjs/src/app/api/generate-file-upload-url/route.ts b/betterauth-nextjs/src/app/api/generate-file-upload-url/route.ts new file mode 100644 index 0000000..8167bc3 --- /dev/null +++ b/betterauth-nextjs/src/app/api/generate-file-upload-url/route.ts @@ -0,0 +1,97 @@ +import { NextRequest } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { createAuth } from "@/lib/auth"; +import { createFileService } from "@/lib/file-service"; +import crypto from "crypto"; +import config from "../../../../next.config"; + +export async function POST(request: NextRequest) { + try { + // Get the authenticated user + const authInstance = await createAuth(); + const session = await authInstance.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return new Response( + JSON.stringify({ success: false, error: "Unauthorized" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + const userId = session.user.id; + const body = (await request.json()) as { + fileName: string; + fileType: string; + fileSize: number; + }; + const { fileName, fileType, fileSize } = body; + + if (!fileName || !fileType || !fileSize) { + return new Response( + JSON.stringify({ + success: false, + error: "Missing required fields: fileName, fileType, fileSize", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Validate file size (100MB limit) + const maxSize = 100 * 1024 * 1024; // 100MB + if (fileSize > maxSize) { + return new Response( + JSON.stringify({ + success: false, + error: "File size must be less than 100MB", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Generate a unique key for the file + const fileExtension = fileName.split(".").pop() || ""; + const key = `files/${userId}/${Date.now()}-${Math.random() + .toString(36) + .substring(2)}.${fileExtension}`; + + // Generate a temporary upload token with embedded data + const { env } = await getCloudflareContext({ async: true }); + const expires = Date.now() + 5 * 60 * 1000; // 5 minutes + const tokenData = { + userId, + key, + expires, + signature: crypto + .createHmac("sha256", env.BETTER_AUTH_SECRET as string) + .update(`${userId}:${key}:${expires}`) + .digest("hex"), + }; + + const token = Buffer.from(JSON.stringify(tokenData)).toString("base64url"); + + // Create the upload URL - use the assets prefix (worker URL) for the actual upload + const uploadUrl = `${new URL(request.url).origin}${ + config.assetPrefix + }/api/files/temp-upload/${token}`; + + return new Response( + JSON.stringify({ + success: true, + uploadUrl, + key, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error generating upload URL:", error); + return new Response( + JSON.stringify({ + success: false, + error: "Internal server error", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +} diff --git a/betterauth-nextjs/src/app/files/page.tsx b/betterauth-nextjs/src/app/files/page.tsx new file mode 100644 index 0000000..0839541 --- /dev/null +++ b/betterauth-nextjs/src/app/files/page.tsx @@ -0,0 +1,437 @@ +"use client"; + +import { useSession } from "@/lib/auth-client"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, useRef } from "react"; +import { + uploadFile, + listFiles, + deleteFile, + FileInfo, + getFileTypeCategory, + formatFileSize, + getFileIcon, +} from "@/lib/file-api"; +import config from "../../../next.config"; + +export default function FilesPage() { + const { data: session, isPending } = useSession(); + const router = useRouter(); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [deleting, setDeleting] = useState(null); + const [statusMessage, setStatusMessage] = useState<{ + text: string; + type: "success" | "error" | "info"; + } | null>(null); + const [selectedFile, setSelectedFile] = useState(null); + const [dragActive, setDragActive] = useState(false); + const [copiedUrl, setCopiedUrl] = useState(null); + + const fileInputRef = useRef(null); + + useEffect(() => { + if (!isPending && !session) { + router.push("/login"); + return; + } + + if (session) { + loadFiles(); + } + }, [session, isPending, router]); + + const loadFiles = async () => { + try { + setLoading(true); + const response = await listFiles(); + if (response.success && response.files) { + setFiles(response.files); + } else { + showStatus(response.error || "Failed to load files", "error"); + } + } catch (error) { + showStatus("Failed to load files", "error"); + } finally { + setLoading(false); + } + }; + + const showStatus = (text: string, type: "success" | "error" | "info") => { + setStatusMessage({ text, type }); + setTimeout(() => setStatusMessage(null), 5000); + }; + + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + setSelectedFile(file); + } + }; + + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } else if (e.type === "dragleave") { + setDragActive(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + setSelectedFile(e.dataTransfer.files[0]); + } + }; + + const handleUpload = async () => { + if (!selectedFile) return; + + setUploading(true); + showStatus("Uploading file...", "info"); + + try { + const response = await uploadFile(selectedFile); + if (response.success) { + showStatus("File uploaded successfully!", "success"); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + await loadFiles(); // Reload the file list + } else { + showStatus(response.error || "Failed to upload file", "error"); + } + } catch (error) { + showStatus("Failed to upload file", "error"); + } finally { + setUploading(false); + } + }; + + const handleDelete = async (key: string, filename: string) => { + if (!confirm(`Are you sure you want to delete "${filename}"?`)) return; + + setDeleting(key); + try { + const response = await deleteFile(key); + if (response.success) { + showStatus("File deleted successfully!", "success"); + await loadFiles(); // Reload the file list + } else { + showStatus(response.error || "Failed to delete file", "error"); + } + } catch (error) { + showStatus("Failed to delete file", "error"); + } finally { + setDeleting(null); + } + }; + + const copyToClipboard = async (url: string) => { + try { + await navigator.clipboard.writeText(url); + setCopiedUrl(url); + showStatus("URL copied to clipboard!", "success"); + setTimeout(() => setCopiedUrl(null), 2000); + } catch (error) { + showStatus("Failed to copy URL", "error"); + } + }; + + const openFile = (url: string) => { + window.open(url, "_blank"); + }; + + if (isPending || loading) { + return ( +

+ ); + } + + if (!session) { + return null; // Will redirect to login + } + + return ( +
+
+
+

+ File Manager +

+

+ Upload and manage your files for use in Webflow sites +

+ + {/* Status Message */} + {statusMessage && ( +
+ {statusMessage.text} +
+ )} + + {/* Upload Section */} +
+

Upload New File

+
+ + + {!selectedFile ? ( +
+
📁
+

+ Drag and drop a file here, or{" "} + +

+

+ Supports any file type up to 100MB +

+
+ ) : ( +
+
+ {getFileIcon(selectedFile.type)} +
+

+ {selectedFile.name} +

+

+ {formatFileSize(selectedFile.size)} •{" "} + {getFileTypeCategory(selectedFile.type)} +

+ + {/* Upload Progress */} + {uploading && ( +
+
+
+
+

Uploading file...

+
+ )} + +
+ + +
+
+ )} +
+
+ + {/* Files List */} +
+

Your Files

+ {loading ? ( +
+
📂
+

Loading files...

+
+
+
+
+ ) : files.length === 0 ? ( +
+
📂
+

No files uploaded yet

+

Upload your first file to get started

+
+ ) : ( +
+ {files.map((file) => ( +
+
+
+ {getFileIcon(file.contentType)} +
+ +
+ +

+ {file.filename} +

+ +

+ {formatFileSize(file.fileSize)} •{" "} + {getFileTypeCategory(file.contentType)} +

+ +

+ Uploaded {new Date(file.uploadedAt).toLocaleDateString()} +

+ +
+ + +
+
+ ))} +
+ )} +
+ + {/* Back Button */} +
+ +
+
+
+
+ ); +} diff --git a/betterauth-nextjs/src/app/page.tsx b/betterauth-nextjs/src/app/page.tsx index b0260e7..5e65d32 100644 --- a/betterauth-nextjs/src/app/page.tsx +++ b/betterauth-nextjs/src/app/page.tsx @@ -5,6 +5,7 @@ import { Section, Block, Link } from "@/devlink/_Builtin"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; import os from "os"; +import config from "../../next.config"; export default function Home() { const { data: session, isPending } = useSession(); @@ -82,7 +83,7 @@ export default function Home() { Account Details + + File Manager + { - router.push("/login"); + router.push(`${config.assetPrefix}/login`); }, }, }); diff --git a/betterauth-nextjs/src/app/profile/page.tsx b/betterauth-nextjs/src/app/profile/page.tsx index 243e0d2..7a2b570 100644 --- a/betterauth-nextjs/src/app/profile/page.tsx +++ b/betterauth-nextjs/src/app/profile/page.tsx @@ -250,11 +250,18 @@ export default function ProfilePage() {
+ -

-

- Supports any file type up to 1GB -

-
- -
@@ -119,11 +75,66 @@ if (!session?.user) {
+ + From d6dc7801142258c9a46e1031dbf84fdb28b7807c Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Fri, 15 Aug 2025 11:32:09 -0400 Subject: [PATCH 64/68] Added progress tracking --- betterauth-astro/src/pages/files.astro | 54 +++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/betterauth-astro/src/pages/files.astro b/betterauth-astro/src/pages/files.astro index 1b9a02b..5edb3d6 100644 --- a/betterauth-astro/src/pages/files.astro +++ b/betterauth-astro/src/pages/files.astro @@ -39,6 +39,17 @@ if (!session?.user) {
+ + + @@ -221,6 +232,10 @@ if (!session?.user) { const videoModal = document.getElementById('videoModal') as HTMLElement; const videoPlayer = document.getElementById('videoPlayer') as HTMLVideoElement; const videoTitle = document.getElementById('videoTitle') as HTMLElement; + const customProgress = document.getElementById('customProgress') as HTMLElement; + const progressText = document.getElementById('progressText') as HTMLElement; + const progressPercent = document.getElementById('progressPercent') as HTMLElement; + const progressBar = document.getElementById('progressBar') as HTMLElement; // State management let currentFiles: any[] = []; @@ -247,6 +262,31 @@ if (!session?.user) { hideAfterFinish: true, // Hide after finish to avoid duplicate }); + // Progress functions + function showProgress() { + if (customProgress) { + customProgress.classList.remove('hidden'); + } + } + + function hideProgress() { + if (customProgress) { + customProgress.classList.add('hidden'); + } + } + + function updateProgress(percent: number, text?: string) { + if (progressPercent) { + progressPercent.textContent = `${Math.round(percent)}%`; + } + if (progressBar) { + progressBar.style.width = `${percent}%`; + } + if (progressText && text) { + progressText.textContent = text; + } + } + // Initialize loadFiles(); @@ -476,6 +516,8 @@ if (!session?.user) { uppy.on('file-added', async (file: any) => { try { showStatus('Starting upload...', 'info'); + showProgress(); + updateProgress(0, `Preparing to upload ${file.data.name}...`); let result; @@ -483,30 +525,40 @@ if (!session?.user) { if (file.data.size > 100 * 1024 * 1024) { // Large file - use multipart upload console.log('Large file detected, using multipart upload'); + updateProgress(5, `Large file detected, using multipart upload for ${file.data.name}...`); const { uploadFileMultipart } = await import('../utils/file-api.js'); result = await uploadFileMultipart(file.data, (progress: any) => { console.log(`Multipart upload progress: ${progress.percent.toFixed(1)}%`); + updateProgress(progress.percent, `Uploading ${file.data.name}...`); }); } else { // Small file - use regular upload + updateProgress(5, `Uploading ${file.data.name}...`); result = await uppyR2S3Uploader.uploadFile(file.data, { onProgress: (progress) => { console.log(`Upload progress: ${progress.percent.toFixed(1)}%`); + updateProgress(progress.percent, `Uploading ${file.data.name}...`); }, }); } if (result.success) { - showStatus('File uploaded successfully!', 'success'); + updateProgress(100, `Upload complete!`); + setTimeout(() => { + hideProgress(); + showStatus('File uploaded successfully!', 'success'); + }, 1000); loadFiles(); // Refresh file list // Remove the file from Uppy's state uppy.removeFile(file.id); } else { + hideProgress(); showStatus(`Upload failed: ${result.error}`, 'error'); // Remove the file from Uppy's state uppy.removeFile(file.id); } } catch (error) { + hideProgress(); showStatus(`Upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error'); // Remove the file from Uppy's state uppy.removeFile(file.id); From f79e2309a00bc23fa9df912a13e10a0d7209d506 Mon Sep 17 00:00:00 2001 From: victoriaplummer Date: Fri, 15 Aug 2025 18:34:55 -0400 Subject: [PATCH 65/68] Daisy ui --- CF_WORKER_OPTIMIZATION.md | 81 --- UPPY_COMPARISON.md | 160 ----- UPPY_MULTIPART_COMPARISON.md | 150 ----- betterauth-astro/.vscode/css_custom_data.json | 15 + betterauth-astro/.vscode/settings.json | 12 +- betterauth-astro/astro.config.mjs | 2 + betterauth-astro/package-lock.json | 206 ++++--- betterauth-astro/package.json | 8 +- betterauth-astro/postcss.config.mjs | 5 - betterauth-astro/src/assets/astro.svg | 1 - betterauth-astro/src/assets/background.svg | 1 - .../src/components/UppyUploader.astro | 124 ---- betterauth-astro/src/components/Welcome.astro | 210 ------- betterauth-astro/src/layouts/Layout.astro | 64 +- .../src/pages/api/files/delete.ts | 6 +- .../pages/api/files/generate-presigned-url.ts | 8 +- betterauth-astro/src/pages/files.astro | 461 +++++++-------- betterauth-astro/src/pages/index.astro | 556 ++++++------------ betterauth-astro/src/pages/login.astro | 148 +++-- betterauth-astro/src/pages/signup.astro | 182 +++--- betterauth-astro/src/styles/global.css | 3 + betterauth-astro/src/utils/uppy-upload.ts | 239 -------- betterauth-astro/tailwind.config.mjs | 8 - package-lock.json | 6 - 24 files changed, 777 insertions(+), 1879 deletions(-) delete mode 100644 CF_WORKER_OPTIMIZATION.md delete mode 100644 UPPY_COMPARISON.md delete mode 100644 UPPY_MULTIPART_COMPARISON.md create mode 100644 betterauth-astro/.vscode/css_custom_data.json delete mode 100644 betterauth-astro/postcss.config.mjs delete mode 100644 betterauth-astro/src/assets/astro.svg delete mode 100644 betterauth-astro/src/assets/background.svg delete mode 100644 betterauth-astro/src/components/UppyUploader.astro delete mode 100644 betterauth-astro/src/components/Welcome.astro delete mode 100644 betterauth-astro/src/utils/uppy-upload.ts delete mode 100644 betterauth-astro/tailwind.config.mjs delete mode 100644 package-lock.json diff --git a/CF_WORKER_OPTIMIZATION.md b/CF_WORKER_OPTIMIZATION.md deleted file mode 100644 index 80060fb..0000000 --- a/CF_WORKER_OPTIMIZATION.md +++ /dev/null @@ -1,81 +0,0 @@ -# Cloudflare Worker Timeout Optimization - -## **The Problem** ⚠️ - -The original Uppy implementation would **definitely timeout** on large files because: - -- **Single Request**: Entire file (up to 5GB) sent to one endpoint -- **Worker Timeout**: Cloudflare Workers have 30-second limit -- **No Chunking**: Whole file processed at once - -## **The Solution** ✅ - -**Hybrid Approach**: Combine Uppy's UI with optimized multipart logic - -### **How It Works** - -``` -1. User selects file in Uppy Dashboard - ↓ -2. File size check: - - < 100MB: Use Uppy's simple upload - - > 100MB: Use optimized multipart upload - ↓ -3. Large files: Cancel Uppy upload, use custom multipart - ↓ -4. Small files: Let Uppy handle normally -``` - -### **File Size Thresholds** - -| File Size | Upload Method | Worker Timeout Risk | -| ----------- | ---------------- | ------------------- | -| < 100MB | Uppy XHRUpload | ✅ Safe | -| 100MB - 1GB | Custom Multipart | ✅ Safe (chunked) | -| 1GB - 5GB | Custom Multipart | ✅ Safe (chunked) | - -### **Code Implementation** - -```typescript -// Check file size and choose upload method -uppy.on("file-added", (file) => { - const largeFileThreshold = 100 * 1024 * 1024; // 100MB - if (file.size && file.size > largeFileThreshold) { - // Cancel Uppy upload, use optimized multipart - uppy.cancelAll(); - handleLargeFileUpload(file); - } -}); -``` - -### **Benefits** - -✅ **No Timeouts**: Large files use chunked uploads -✅ **Best UX**: Uppy's professional interface -✅ **Optimized**: Uses your existing multipart infrastructure -✅ **Automatic**: Seamless fallback based on file size -✅ **Reliable**: Proven multipart logic for large files - -### **Performance Comparison** - -| Method | Small Files | Large Files | Worker Timeout | -| -------------------- | ----------- | ----------- | -------------- | -| **Original Uppy** | ✅ Fast | ❌ Timeout | High Risk | -| **Custom Multipart** | ⚠️ Complex | ✅ Reliable | Low Risk | -| **Hybrid Solution** | ✅ Fast | ✅ Reliable | ✅ Safe | - -### **Why This Works** - -1. **Small Files**: Uppy handles efficiently with simple upload -2. **Large Files**: Your optimized multipart logic prevents timeouts -3. **Seamless UX**: User doesn't know the difference -4. **Best of Both**: Professional UI + reliable uploads - -## **Testing Recommendations** - -1. **Test small files** (< 100MB) - Should use Uppy -2. **Test medium files** (100MB-1GB) - Should use multipart -3. **Test large files** (1GB+) - Should use multipart -4. **Monitor logs** to verify correct method is used - -This solution gives you the **best user experience** with **zero timeout risk**! 🚀 diff --git a/UPPY_COMPARISON.md b/UPPY_COMPARISON.md deleted file mode 100644 index a05d0e2..0000000 --- a/UPPY_COMPARISON.md +++ /dev/null @@ -1,160 +0,0 @@ -# Uppy vs Custom Multipart Upload Implementation - -## **Code Comparison** - -### **Custom Implementation (Current)** - -```typescript -// ~600 lines of complex multipart upload logic -export async function uploadFileMultipart( - file: File, - onProgress?: (progress: UploadProgress) => void -): Promise { - // 1. Initialize multipart upload - const initResponse = await fetch(`${baseUrl}/api/files/multipart/init`, {...}); - - // 2. Split file into parts - const partSize = 10 * 1024 * 1024; - const totalParts = Math.ceil(file.size / partSize); - - // 3. Generate URLs in batches - for (let batchStart = 1; batchStart <= totalParts; batchStart += batchSize) { - const batchUrlsResponse = await fetch(`${baseUrl}/api/files/multipart/get-part-urls-batch`, {...}); - } - - // 4. Upload parts in parallel with concurrency control - const uploadPromises = []; - const partsMap = new Map(); - // ... complex parallel upload logic - - // 5. Complete multipart upload - const completeResponse = await fetch(`${baseUrl}/api/files/multipart/complete`, {...}); -} -``` - -### **Uppy Implementation** - -```typescript -// ~50 lines of simple upload logic -export async function uploadFile(file: File, options: UppyUploadOptions = {}) { - return new Promise((resolve, reject) => { - // Set up progress tracking - if (options.onProgress) { - this.uppy.on("upload-progress", (file, progress) => { - options.onProgress!({ - loaded: progress.bytesUploaded, - total: progress.bytesTotal, - percent: (progress.bytesUploaded / progress.bytesTotal) * 100, - speed: progress.bytesPerSecond || 0, - eta: progress.eta || 0, - }); - }); - } - - // Handle success/error - this.uppy.on("upload-success", (file, response) => resolve(response)); - this.uppy.on("upload-error", (file, error) => reject(error)); - - // Upload - this.uppy.addFile({ name: file.name, type: file.type, data: file }); - this.uppy.upload(); - }); -} -``` - -## **Feature Comparison** - -| Feature | Custom Implementation | Uppy | -| --------------------- | ------------------------ | ----------- | -| **Multipart Uploads** | ✅ Manual implementation | ✅ Built-in | -| **Progress Tracking** | ✅ Custom logic | ✅ Built-in | -| **Retry Logic** | ✅ Custom implementation | ✅ Built-in | -| **File Validation** | ✅ Manual | ✅ Built-in | -| **Cross-browser** | ❌ Manual testing needed | ✅ Tested | -| **Error Handling** | ✅ Custom | ✅ Built-in | -| **Resume Uploads** | ❌ Not implemented | ✅ Built-in | -| **Drag & Drop** | ❌ Not implemented | ✅ Built-in | -| **File Preview** | ❌ Not implemented | ✅ Built-in | -| **Multiple Files** | ❌ Complex | ✅ Simple | -| **Upload Queue** | ❌ Not implemented | ✅ Built-in | - -## **Server Endpoints Comparison** - -### **Custom Implementation (5 endpoints)** - -- `POST /api/files/multipart/init` - Initialize upload -- `POST /api/files/multipart/get-part-urls-batch` - Get part URLs -- `POST /api/files/multipart/upload-part` - Upload individual parts -- `POST /api/files/multipart/complete` - Complete upload -- `POST /api/files/multipart/get-all-part-urls` - Get all URLs (deprecated) - -### **Uppy Implementation (1 endpoint)** - -- `POST /api/files/upload` - Handle all uploads - -## **Benefits of Uppy** - -### **1. Reduced Code Complexity** - -- **Before**: ~600 lines of custom multipart logic -- **After**: ~50 lines of simple upload logic -- **Reduction**: 92% less code - -### **2. Built-in Features** - -- ✅ **Automatic retry** with exponential backoff -- ✅ **Progress tracking** with speed and ETA -- ✅ **File validation** and restrictions -- ✅ **Cross-browser compatibility** -- ✅ **Resume uploads** after network interruption -- ✅ **Drag & drop** interface -- ✅ **File preview** and metadata - -### **3. Better User Experience** - -- ✅ **Visual upload interface** with Dashboard -- ✅ **Real-time progress** with speed and ETA -- ✅ **File preview** before upload -- ✅ **Drag & drop** support -- ✅ **Upload queue** management - -### **4. Maintainability** - -- ✅ **Well-tested** library used by thousands -- ✅ **Active development** and community support -- ✅ **Plugin ecosystem** for additional features -- ✅ **TypeScript support** out of the box - -## **Migration Path** - -### **Option 1: Gradual Migration** - -1. Keep existing multipart endpoints -2. Add Uppy for new uploads -3. Gradually migrate existing uploads -4. Remove old endpoints when ready - -### **Option 2: Complete Replacement** - -1. Replace all upload logic with Uppy -2. Remove multipart endpoints -3. Update all components to use Uppy -4. Test thoroughly - -## **Recommended Approach** - -**Use Uppy for new uploads** and gradually migrate existing ones. The benefits are: - -1. **Immediate**: Better user experience with visual interface -2. **Long-term**: Reduced maintenance burden -3. **Scalable**: Easy to add new features (resume, preview, etc.) -4. **Reliable**: Well-tested library handles edge cases - -## **Next Steps** - -1. **Install Uppy**: `npm install @uppy/core @uppy/dashboard @uppy/xhr-upload @uppy/progress-bar` -2. **Create Uppy service**: Use the provided `uppy-upload.ts` -3. **Add upload endpoint**: Use the provided `upload.ts` -4. **Create component**: Use the provided `UppyUploader.astro` -5. **Test thoroughly**: Ensure all features work as expected -6. **Migrate gradually**: Replace old uploads one by one diff --git a/UPPY_MULTIPART_COMPARISON.md b/UPPY_MULTIPART_COMPARISON.md deleted file mode 100644 index 2b8523f..0000000 --- a/UPPY_MULTIPART_COMPARISON.md +++ /dev/null @@ -1,150 +0,0 @@ -# Uppy Multipart Upload Options for Cloudflare Workers - -## **Available Options** - -### **1. @uppy/aws-s3-multipart** 🎯 **RECOMMENDED** - -Uppy's **native multipart plugin** that works with S3-compatible services like R2. - -```typescript -import AwsS3Multipart from "@uppy/aws-s3-multipart"; - -uppy.use(AwsS3Multipart, { - createMultipartUpload: async (file) => { - // Call your /api/files/multipart/init endpoint - }, - signPart: async (file, { key, uploadId, partNumber }) => { - // Call your /api/files/multipart/get-part-urls-batch endpoint - }, - completeMultipartUpload: async (file, { key, uploadId, parts }) => { - // Call your /api/files/multipart/complete endpoint - }, -}); -``` - -**✅ Pros:** - -- Built-in multipart support -- Automatic chunking and progress -- Resume uploads -- Uses your existing multipart endpoints -- No timeout issues - -**❌ Cons:** - -- More complex configuration -- Requires custom endpoint mapping - -### **2. @uppy/xhr-upload** (Current) - -Simple upload plugin that sends entire file in one request. - -```typescript -import XHRUpload from "@uppy/xhr-upload"; - -uppy.use(XHRUpload, { - endpoint: "/api/files/upload", - // No multipart support -}); -``` - -**✅ Pros:** - -- Simple configuration -- Works for small files - -**❌ Cons:** - -- No multipart support -- Timeout risk on large files -- Limited to ~100MB files - -### **3. @uppy/tus** - -TUS protocol for resumable uploads. - -```typescript -import Tus from "@uppy/tus"; - -uppy.use(Tus, { - endpoint: "https://your-tus-server.com/files", - chunkSize: 10 * 1024 * 1024, -}); -``` - -**✅ Pros:** - -- Resumable uploads -- Chunked uploads - -**❌ Cons:** - -- Requires TUS server (not available in Cloudflare Workers) -- Additional infrastructure needed - -## **Comparison Table** - -| Plugin | Multipart Support | CF Worker Compatible | Timeout Safe | Complexity | Resume Uploads | -| ------------------ | ----------------- | -------------------- | ------------------- | ---------- | -------------- | -| **XHRUpload** | ❌ No | ✅ Yes | ❌ No (large files) | 🟢 Low | ❌ No | -| **AwsS3Multipart** | ✅ Yes | ✅ Yes | ✅ Yes | 🟡 Medium | ✅ Yes | -| **TUS** | ✅ Yes | ❌ No | ✅ Yes | 🔴 High | ✅ Yes | - -## **Recommended Approach** - -### **Option A: Pure AwsS3Multipart** 🎯 - -Use Uppy's native multipart plugin with your existing R2 endpoints: - -```typescript -// Use the new uppy-r2-multipart.ts -import { uppyR2MultipartUploader } from "./uppy-r2-multipart"; -``` - -**Benefits:** - -- ✅ Full multipart support -- ✅ No timeout issues -- ✅ Resume uploads -- ✅ Professional UI -- ✅ Uses your existing infrastructure - -### **Option B: Hybrid Approach** (Current) - -Keep the hybrid approach for simplicity: - -```typescript -// Small files: XHRUpload -// Large files: Custom multipart -``` - -**Benefits:** - -- ✅ Simple for small files -- ✅ Reliable for large files -- ✅ No additional complexity - -## **Implementation** - -I've created `uppy-r2-multipart.ts` that: - -1. **Uses AwsS3Multipart plugin** -2. **Maps to your existing endpoints**: - - - `createMultipartUpload` → `/api/files/multipart/init` - - `signPart` → `/api/files/multipart/get-part-urls-batch` - - `completeMultipartUpload` → `/api/files/multipart/complete` - -3. **Provides the same interface** as your current Uppy setup - -## **Recommendation** - -**Use the AwsS3Multipart approach** (`uppy-r2-multipart.ts`) because: - -1. **Native multipart support** - No timeout issues -2. **Resume uploads** - Better user experience -3. **Professional UI** - Uppy's dashboard -4. **Uses your infrastructure** - No additional setup needed -5. **Future-proof** - Handles any file size - -This gives you the **best of both worlds**: Uppy's professional UI + reliable multipart uploads! 🚀 diff --git a/betterauth-astro/.vscode/css_custom_data.json b/betterauth-astro/.vscode/css_custom_data.json new file mode 100644 index 0000000..319b9f4 --- /dev/null +++ b/betterauth-astro/.vscode/css_custom_data.json @@ -0,0 +1,15 @@ +{ + "version": 1.1, + "atDirectives": [ + { + "name": "@plugin", + "description": "Tailwind CSS v4 plugin directive", + "references": [ + { + "name": "Tailwind CSS v4 Documentation", + "url": "https://tailwindcss.com/docs/installation" + } + ] + } + ] +} diff --git a/betterauth-astro/.vscode/settings.json b/betterauth-astro/.vscode/settings.json index 0126e59..54d27f0 100644 --- a/betterauth-astro/.vscode/settings.json +++ b/betterauth-astro/.vscode/settings.json @@ -1,5 +1,9 @@ { - "files.associations": { - "wrangler.json": "jsonc" - } -} \ No newline at end of file + "files.associations": { + "wrangler.json": "jsonc" + }, + "css.validate": false, + "less.validate": false, + "scss.validate": false, + "css.customData": [".vscode/css_custom_data.json"] +} diff --git a/betterauth-astro/astro.config.mjs b/betterauth-astro/astro.config.mjs index 3f563e7..17583ca 100644 --- a/betterauth-astro/astro.config.mjs +++ b/betterauth-astro/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from "astro/config"; import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; +import tailwindcss from "@tailwindcss/vite"; // https://astro.build/config export default defineConfig({ @@ -20,6 +21,7 @@ export default defineConfig({ }), integrations: [react()], vite: { + plugins: [tailwindcss()], resolve: { // Use react-dom/server.edge instead of react-dom/server.browser for React 19. // Without this, MessageChannel from node:worker_threads needs to be polyfilled. diff --git a/betterauth-astro/package-lock.json b/betterauth-astro/package-lock.json index b419588..9a426bb 100644 --- a/betterauth-astro/package-lock.json +++ b/betterauth-astro/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@astrojs/cloudflare": "^12.6.0", "@astrojs/react": "^4.2.5", - "@tailwindcss/vite": "^4.1.11", + "@tailwindcss/vite": "^4.1.12", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@uppy/aws-s3": "^4.3.2", @@ -21,33 +21,21 @@ "@webflow/webflow-cli": "^1.8.35", "astro": "^5.7.0", "better-auth": "^1.2.12", + "daisyui": "^5.0.50", "dotenv": "^17.1.0", "drizzle-orm": "^0.44.2", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "tailwindcss": "^4.1.12" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250415.0", - "@tailwindcss/postcss": "^4.1.11", "@types/node": "^24.0.12", "drizzle-kit": "^0.31.4", "postcss-import": "^16.1.1", - "tailwindcss": "^4.1.11", "wrangler": "^4.11.1" } }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -3162,6 +3150,15 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -4988,23 +4985,23 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", - "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.11" + "tailwindcss": "4.1.12" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", - "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", "hasInstallScript": true, "dependencies": { "detect-libc": "^2.0.4", @@ -5014,24 +5011,24 @@ "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.11", - "@tailwindcss/oxide-darwin-arm64": "4.1.11", - "@tailwindcss/oxide-darwin-x64": "4.1.11", - "@tailwindcss/oxide-freebsd-x64": "4.1.11", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", - "@tailwindcss/oxide-linux-x64-musl": "4.1.11", - "@tailwindcss/oxide-wasm32-wasi": "4.1.11", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", - "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", + "integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", "cpu": [ "arm64" ], @@ -5044,9 +5041,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", - "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", + "integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", "cpu": [ "arm64" ], @@ -5059,9 +5056,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", - "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", "cpu": [ "x64" ], @@ -5074,9 +5071,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", - "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", + "integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", "cpu": [ "x64" ], @@ -5089,9 +5086,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", - "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", + "integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", "cpu": [ "arm" ], @@ -5104,9 +5101,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", - "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", + "integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", "cpu": [ "arm64" ], @@ -5119,9 +5116,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", - "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", + "integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", "cpu": [ "arm64" ], @@ -5134,9 +5131,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", - "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", + "integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", "cpu": [ "x64" ], @@ -5149,9 +5146,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", - "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", + "integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", "cpu": [ "x64" ], @@ -5164,9 +5161,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", - "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", + "integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -5180,11 +5177,11 @@ ], "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.11", - "@tybys/wasm-util": "^0.9.0", + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "engines": { @@ -5192,9 +5189,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", - "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", + "integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", "cpu": [ "arm64" ], @@ -5207,9 +5204,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", - "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", + "integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", "cpu": [ "x64" ], @@ -5221,27 +5218,14 @@ "node": ">= 10" } }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz", - "integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==", - "dev": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.11", - "@tailwindcss/oxide": "4.1.11", - "postcss": "^8.4.41", - "tailwindcss": "4.1.11" - } - }, "node_modules/@tailwindcss/vite": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz", - "integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz", + "integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==", "dependencies": { - "@tailwindcss/node": "4.1.11", - "@tailwindcss/oxide": "4.1.11", - "tailwindcss": "4.1.11" + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", + "tailwindcss": "4.1.12" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -7599,6 +7583,14 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/daisyui": { + "version": "5.0.50", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.50.tgz", + "integrity": "sha512-c1PweK5RI1C76q58FKvbS4jzgyNJSP6CGTQ+KkZYzADdJoERnOxFoeLfDHmQgxLpjEzlYhFMXCeodQNLCC9bow==", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, "node_modules/data-uri-to-buffer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", @@ -8099,9 +8091,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", - "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -9929,9 +9921,9 @@ } }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -13853,9 +13845,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", - "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==" + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==" }, "node_modules/tapable": { "version": "2.2.2", diff --git a/betterauth-astro/package.json b/betterauth-astro/package.json index a9ec2ee..e0264e8 100644 --- a/betterauth-astro/package.json +++ b/betterauth-astro/package.json @@ -14,7 +14,7 @@ "dependencies": { "@astrojs/cloudflare": "^12.6.6", "@astrojs/react": "^4.2.5", - "@tailwindcss/vite": "^4.1.11", + "@tailwindcss/vite": "^4.1.12", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@uppy/aws-s3": "^4.3.2", @@ -25,18 +25,18 @@ "@webflow/webflow-cli": "^1.8.35", "astro": "^5.7.0", "better-auth": "^1.2.12", + "daisyui": "^5.0.50", "dotenv": "^17.1.0", "drizzle-orm": "^0.44.2", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "tailwindcss": "^4.1.12" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250415.0", - "@tailwindcss/postcss": "^4.1.11", "@types/node": "^24.0.12", "drizzle-kit": "^0.31.4", "postcss-import": "^16.1.1", - "tailwindcss": "^4.1.11", "wrangler": "^4.11.1" } } diff --git a/betterauth-astro/postcss.config.mjs b/betterauth-astro/postcss.config.mjs deleted file mode 100644 index a1aeab8..0000000 --- a/betterauth-astro/postcss.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import tailwindcss from "@tailwindcss/postcss"; - -export default { - plugins: [tailwindcss()], -}; diff --git a/betterauth-astro/src/assets/astro.svg b/betterauth-astro/src/assets/astro.svg deleted file mode 100644 index 8cf8fb0..0000000 --- a/betterauth-astro/src/assets/astro.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/betterauth-astro/src/assets/background.svg b/betterauth-astro/src/assets/background.svg deleted file mode 100644 index 4b2be0a..0000000 --- a/betterauth-astro/src/assets/background.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/betterauth-astro/src/components/UppyUploader.astro b/betterauth-astro/src/components/UppyUploader.astro deleted file mode 100644 index 79eeb72..0000000 --- a/betterauth-astro/src/components/UppyUploader.astro +++ /dev/null @@ -1,124 +0,0 @@ ---- -// UppyUploader.astro ---- - - - - -
-
-
-
- - - - diff --git a/betterauth-astro/src/components/Welcome.astro b/betterauth-astro/src/components/Welcome.astro deleted file mode 100644 index 52e0333..0000000 --- a/betterauth-astro/src/components/Welcome.astro +++ /dev/null @@ -1,210 +0,0 @@ ---- -import astroLogo from '../assets/astro.svg'; -import background from '../assets/background.svg'; ---- - -
- - diff --git a/betterauth-astro/src/layouts/Layout.astro b/betterauth-astro/src/layouts/Layout.astro index cf4f9bd..5d91d7f 100644 --- a/betterauth-astro/src/layouts/Layout.astro +++ b/betterauth-astro/src/layouts/Layout.astro @@ -1,11 +1,11 @@ --- import { DevLinkProvider } from '../../devlink/DevLinkProvider.jsx'; -import "../../devlink/global.css"; // Webflow Styles -import "../styles/global.css"; // Tailwind CSS - should come after Webflow +// import "../../devlink/global.css"; // Webflow Styles - temporarily disabled for pure DaisyUI +import "../styles/global.css"; // Tailwind CSS with DaisyUI --- - + @@ -17,16 +17,58 @@ import "../styles/global.css"; // Tailwind CSS - should come after Webflow + +
+ +
+
+ + - + diff --git a/betterauth-astro/src/pages/api/files/delete.ts b/betterauth-astro/src/pages/api/files/delete.ts index fbafada..58f9de4 100644 --- a/betterauth-astro/src/pages/api/files/delete.ts +++ b/betterauth-astro/src/pages/api/files/delete.ts @@ -2,6 +2,10 @@ import type { APIRoute } from "astro"; import { auth } from "../../../utils/auth"; import { createFileService } from "../../../utils/file-service"; +interface DeleteFileRequest { + key: string; +} + export const DELETE: APIRoute = async ({ request, locals }) => { try { // Get the authenticated user @@ -18,7 +22,7 @@ export const DELETE: APIRoute = async ({ request, locals }) => { } const userId = session.user.id; - const { key } = await request.json(); + const { key } = (await request.json()) as DeleteFileRequest; if (!key) { return new Response( diff --git a/betterauth-astro/src/pages/api/files/generate-presigned-url.ts b/betterauth-astro/src/pages/api/files/generate-presigned-url.ts index cd8b03f..803f5fe 100644 --- a/betterauth-astro/src/pages/api/files/generate-presigned-url.ts +++ b/betterauth-astro/src/pages/api/files/generate-presigned-url.ts @@ -2,6 +2,12 @@ import type { APIRoute } from "astro"; import { auth } from "../../../utils/auth"; import { createFileService } from "../../../utils/file-service"; +interface GeneratePresignedUrlRequest { + fileName: string; + fileType: string; + fileSize?: number; +} + export const POST: APIRoute = async ({ request, locals }) => { const corsOrigin = locals.runtime.env.BETTER_AUTH_URL; @@ -28,7 +34,7 @@ export const POST: APIRoute = async ({ request, locals }) => { ); } - const body = await request.json(); + const body = (await request.json()) as GeneratePresignedUrlRequest; const { fileName, fileType, fileSize } = body; if (!fileName || !fileType) { diff --git a/betterauth-astro/src/pages/files.astro b/betterauth-astro/src/pages/files.astro index 5edb3d6..185c24d 100644 --- a/betterauth-astro/src/pages/files.astro +++ b/betterauth-astro/src/pages/files.astro @@ -17,131 +17,133 @@ if (!session?.user) { -
+
-
-

- File Manager -

-

- Upload and manage your files for use in Webflow sites -

- - - - - -
-

Upload New File

-
- -
- - -
- - -