- Replace next-auth v5 beta with better-auth v1.6.2 (stable releases)
- Add multi-provider OAuth support with admin UI configuration
- New oauthProviders table with encrypted secrets (AES-256-GCM)
- Env var bootstrap (OAUTH_*) syncs to DB, UI-created providers fully editable
- OAuth provider REST API: GET/POST/PUT/DELETE /api/v1/oauth-providers
- Settings page "Authentication Providers" section for admin management
- Account linking uses new accounts table (multi-provider per user)
- Username plugin for credentials sign-in (replaces email@localhost pattern)
- bcrypt password compatibility (existing hashes work)
- Database-backed sessions via Kysely adapter (bun:sqlite direct)
- Configurable rate limiting via AUTH_RATE_LIMIT_* env vars
- All DB columns migrated from snake_case to camelCase
- All TypeScript types/models migrated to camelCase properties
- Removed casing: "snake_case" from Drizzle config
- Callback URL format: {baseUrl}/api/auth/oauth2/callback/{providerId}
- package-lock.json removed and gitignored (using bun.lock)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
82 lines
2.4 KiB
TypeScript
82 lines
2.4 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { auth, checkSameOrigin } from "@/src/lib/auth";
|
|
import { updateUserProfile } from "@/src/lib/models/user";
|
|
import { createAuditEvent } from "@/src/lib/models/audit";
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const originCheck = checkSameOrigin(request);
|
|
if (originCheck) return originCheck;
|
|
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const userId = Number(session.user.id);
|
|
const body = await request.json();
|
|
const { avatarUrl } = body;
|
|
|
|
// Validate avatarUrl is either null or a base64 image string
|
|
if (avatarUrl !== null && typeof avatarUrl !== "string") {
|
|
return NextResponse.json(
|
|
{ error: "Invalid avatar data" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// If avatarUrl is provided, validate it's a base64 image (png/jpeg/webp only)
|
|
if (avatarUrl !== null) {
|
|
const match = avatarUrl.match(/^data:(image\/(png|jpeg|jpg|webp));base64,/i);
|
|
if (!match) {
|
|
return NextResponse.json(
|
|
{ error: "Avatar must be a base64-encoded PNG, JPEG, or WebP image" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check base64 size (rough estimate: base64 is ~33% larger than binary)
|
|
// 2MB binary = ~2.7MB base64, so limit to 3MB base64 string
|
|
if (avatarUrl.length > 3 * 1024 * 1024) {
|
|
return NextResponse.json(
|
|
{ error: "Avatar image is too large" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Update user avatar
|
|
const updatedUser = await updateUserProfile(userId, {
|
|
avatarUrl: avatarUrl
|
|
});
|
|
|
|
if (!updatedUser) {
|
|
return NextResponse.json(
|
|
{ error: "User not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Audit log
|
|
await createAuditEvent({
|
|
userId,
|
|
action: avatarUrl ? "avatar_updated" : "avatar_deleted",
|
|
entityType: "user",
|
|
entityId: userId,
|
|
summary: avatarUrl ? "User updated profile picture" : "User removed profile picture",
|
|
data: JSON.stringify({ hasAvatar: !!avatarUrl })
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
avatarUrl: updatedUser.avatarUrl
|
|
});
|
|
} catch (error) {
|
|
console.error("Avatar update error:", error);
|
|
return NextResponse.json(
|
|
{ error: "Failed to update avatar" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|