feat: comprehensive boilerplate improvements
Security & Stability: - Add Next.js 16 proxy.ts for BetterAuth cookie-based auth protection - Add rate limiting for API routes (src/lib/rate-limit.ts) - Add Zod validation for chat API request bodies - Add session auth check to chat and diagnostics endpoints - Add security headers in next.config.ts (CSP, X-Frame-Options, etc.) - Add file upload validation and sanitization in storage.ts Core UX Components: - Add error boundaries (error.tsx, not-found.tsx, chat/error.tsx) - Add loading states (skeleton.tsx, spinner.tsx, loading.tsx files) - Add toast notifications with Sonner - Add form components (input.tsx, textarea.tsx, label.tsx) - Add database indexes for performance (schema.ts) - Enhance chat UX: timestamps, copy-to-clipboard, thinking indicator, error display, localStorage message persistence Polish & Accessibility: - Add Open Graph and Twitter card metadata - Add JSON-LD structured data for SEO - Add sitemap.ts, robots.ts, manifest.ts - Add skip-to-content link and ARIA labels in site-header - Enable profile page quick action buttons with dialogs - Update Next.js 15 references to Next.js 16 Developer Experience: - Add GitHub Actions CI workflow (lint, typecheck, build) - Add Prettier configuration (.prettierrc, .prettierignore) - Add .nvmrc pinning Node 20 - Add ESLint rules: import/order, react-hooks/exhaustive-deps - Add stricter TypeScript settings (exactOptionalPropertyTypes, noImplicitOverride) - Add interactive setup script (scripts/setup.ts) - Add session utility functions (src/lib/session.ts) All changes mirrored to create-agentic-app/template/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
117
create-agentic-app/template/src/lib/env.ts
Normal file
117
create-agentic-app/template/src/lib/env.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Server-side environment variables schema.
|
||||
* These variables are only available on the server.
|
||||
*/
|
||||
const serverEnvSchema = z.object({
|
||||
// Database
|
||||
POSTGRES_URL: z.string().url("Invalid database URL"),
|
||||
|
||||
// Authentication
|
||||
BETTER_AUTH_SECRET: z
|
||||
.string()
|
||||
.min(32, "BETTER_AUTH_SECRET must be at least 32 characters"),
|
||||
|
||||
// OAuth
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
|
||||
// AI
|
||||
OPENROUTER_API_KEY: z.string().optional(),
|
||||
OPENROUTER_MODEL: z.string().default("openai/gpt-5-mini"),
|
||||
|
||||
// Storage
|
||||
BLOB_READ_WRITE_TOKEN: z.string().optional(),
|
||||
|
||||
// App
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Client-side environment variables schema.
|
||||
* These variables are exposed to the browser via NEXT_PUBLIC_ prefix.
|
||||
*/
|
||||
const clientEnvSchema = z.object({
|
||||
NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
|
||||
});
|
||||
|
||||
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||
export type ClientEnv = z.infer<typeof clientEnvSchema>;
|
||||
|
||||
/**
|
||||
* Validates and returns server-side environment variables.
|
||||
* Throws an error if validation fails.
|
||||
*/
|
||||
export function getServerEnv(): ServerEnv {
|
||||
const parsed = serverEnvSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error(
|
||||
"Invalid server environment variables:",
|
||||
parsed.error.flatten().fieldErrors
|
||||
);
|
||||
throw new Error("Invalid server environment variables");
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and returns client-side environment variables.
|
||||
* Throws an error if validation fails.
|
||||
*/
|
||||
export function getClientEnv(): ClientEnv {
|
||||
const parsed = clientEnvSchema.safeParse({
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error(
|
||||
"Invalid client environment variables:",
|
||||
parsed.error.flatten().fieldErrors
|
||||
);
|
||||
throw new Error("Invalid client environment variables");
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if required environment variables are set.
|
||||
* Logs warnings for missing optional variables.
|
||||
*/
|
||||
export function checkEnv(): void {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check required variables
|
||||
if (!process.env.POSTGRES_URL) {
|
||||
throw new Error("POSTGRES_URL is required");
|
||||
}
|
||||
|
||||
if (!process.env.BETTER_AUTH_SECRET) {
|
||||
throw new Error("BETTER_AUTH_SECRET is required");
|
||||
}
|
||||
|
||||
// Check optional variables and warn
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
||||
warnings.push("Google OAuth is not configured. Social login will be disabled.");
|
||||
}
|
||||
|
||||
if (!process.env.OPENROUTER_API_KEY) {
|
||||
warnings.push("OPENROUTER_API_KEY is not set. AI chat will not work.");
|
||||
}
|
||||
|
||||
if (!process.env.BLOB_READ_WRITE_TOKEN) {
|
||||
warnings.push("BLOB_READ_WRITE_TOKEN is not set. Using local storage for file uploads.");
|
||||
}
|
||||
|
||||
// Log warnings in development
|
||||
if (process.env.NODE_ENV === "development" && warnings.length > 0) {
|
||||
console.warn("\n⚠️ Environment warnings:");
|
||||
warnings.forEach((w) => console.warn(` - ${w}`));
|
||||
console.warn("");
|
||||
}
|
||||
}
|
||||
157
create-agentic-app/template/src/lib/rate-limit.ts
Normal file
157
create-agentic-app/template/src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Simple in-memory rate limiter for API routes.
|
||||
* For production, consider using a Redis-based solution.
|
||||
*/
|
||||
|
||||
interface RateLimitConfig {
|
||||
/** Number of requests allowed in the window */
|
||||
limit: number;
|
||||
/** Time window in milliseconds */
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
}
|
||||
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
|
||||
/**
|
||||
* Default rate limit configurations for different endpoints.
|
||||
*/
|
||||
export const rateLimitConfigs = {
|
||||
// AI chat endpoint - more restrictive
|
||||
chat: {
|
||||
limit: 20,
|
||||
windowMs: 60 * 1000, // 20 requests per minute
|
||||
},
|
||||
// Auth endpoints
|
||||
auth: {
|
||||
limit: 10,
|
||||
windowMs: 60 * 1000, // 10 requests per minute
|
||||
},
|
||||
// General API
|
||||
api: {
|
||||
limit: 100,
|
||||
windowMs: 60 * 1000, // 100 requests per minute
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check if a request should be rate limited.
|
||||
*
|
||||
* @param key - Unique identifier for the client (e.g., IP address, user ID)
|
||||
* @param config - Rate limit configuration
|
||||
* @returns Object with allowed status and remaining requests
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
key: string,
|
||||
config: RateLimitConfig
|
||||
): {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetTime: number;
|
||||
} {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitStore.get(key);
|
||||
|
||||
// Clean up expired entries periodically
|
||||
if (Math.random() < 0.01) {
|
||||
cleanupExpiredEntries();
|
||||
}
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
// Create new entry
|
||||
const newEntry: RateLimitEntry = {
|
||||
count: 1,
|
||||
resetTime: now + config.windowMs,
|
||||
};
|
||||
rateLimitStore.set(key, newEntry);
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.limit - 1,
|
||||
resetTime: newEntry.resetTime,
|
||||
};
|
||||
}
|
||||
|
||||
if (entry.count >= config.limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetTime: entry.resetTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Increment count
|
||||
entry.count++;
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.limit - entry.count,
|
||||
resetTime: entry.resetTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rate limit response with appropriate headers.
|
||||
*/
|
||||
export function createRateLimitResponse(resetTime: number): Response {
|
||||
const retryAfter = Math.ceil((resetTime - Date.now()) / 1000);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Too Many Requests",
|
||||
message: "Rate limit exceeded. Please try again later.",
|
||||
retryAfter,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": String(retryAfter),
|
||||
"X-RateLimit-Reset": String(Math.ceil(resetTime / 1000)),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client identifier from a request.
|
||||
* Uses IP address or forwarded header.
|
||||
*/
|
||||
export function getClientIdentifier(request: Request): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
const ip = forwarded.split(",")[0];
|
||||
if (ip) {
|
||||
return ip.trim();
|
||||
}
|
||||
}
|
||||
// Fallback to a hash of user-agent if no IP available
|
||||
const userAgent = request.headers.get("user-agent") || "unknown";
|
||||
return `ua-${hashString(userAgent)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple string hash for fallback identifier.
|
||||
*/
|
||||
function hashString(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired rate limit entries.
|
||||
*/
|
||||
function cleanupExpiredEntries(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of rateLimitStore.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,70 @@
|
||||
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
|
||||
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
});
|
||||
export const user = pgTable(
|
||||
"user",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("user_email_idx").on(table.email)]
|
||||
);
|
||||
|
||||
export const session = pgTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("session_user_id_idx").on(table.userId),
|
||||
index("session_token_idx").on(table.token),
|
||||
]
|
||||
);
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
});
|
||||
export const account = pgTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("account_user_id_idx").on(table.userId),
|
||||
index("account_provider_account_idx").on(table.providerId, table.accountId),
|
||||
]
|
||||
);
|
||||
|
||||
export const verification = pgTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
|
||||
48
create-agentic-app/template/src/lib/session.ts
Normal file
48
create-agentic-app/template/src/lib/session.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Protected routes that require authentication.
|
||||
* These are also configured in src/proxy.ts for optimistic redirects.
|
||||
*/
|
||||
export const protectedRoutes = ["/chat", "/dashboard", "/profile"];
|
||||
|
||||
/**
|
||||
* Checks if the current request is authenticated.
|
||||
* Should be called in Server Components for protected routes.
|
||||
*
|
||||
* @returns The session object if authenticated
|
||||
* @throws Redirects to home page if not authenticated
|
||||
*/
|
||||
export async function requireAuth() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current session without requiring authentication.
|
||||
* Returns null if not authenticated.
|
||||
*
|
||||
* @returns The session object or null
|
||||
*/
|
||||
export async function getOptionalSession() {
|
||||
return await auth.api.getSession({ headers: await headers() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given path is a protected route.
|
||||
*
|
||||
* @param path - The path to check
|
||||
* @returns True if the path requires authentication
|
||||
*/
|
||||
export function isProtectedRoute(path: string): boolean {
|
||||
return protectedRoutes.some(
|
||||
(route) => path === route || path.startsWith(`${route}/`)
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { put, del } from "@vercel/blob";
|
||||
import { existsSync } from "fs";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { put, del } from "@vercel/blob";
|
||||
|
||||
/**
|
||||
* Result from uploading a file to storage
|
||||
@@ -11,14 +11,123 @@ export interface StorageResult {
|
||||
pathname: string; // Path/key of the stored file
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage configuration
|
||||
*/
|
||||
export interface StorageConfig {
|
||||
/** Maximum file size in bytes (default: 5MB) */
|
||||
maxSize?: number;
|
||||
/** Allowed MIME types (default: images and documents) */
|
||||
allowedTypes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default storage configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<StorageConfig> = {
|
||||
maxSize: 5 * 1024 * 1024, // 5MB
|
||||
allowedTypes: [
|
||||
// Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
// Documents
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"application/json",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Allowed file extensions mapped from MIME types
|
||||
*/
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".svg",
|
||||
".pdf",
|
||||
".txt",
|
||||
".csv",
|
||||
".json",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Sanitize a filename by removing dangerous characters and path traversal attempts
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
// Remove path components (prevent directory traversal)
|
||||
const basename = filename.split(/[/\\]/).pop() || filename;
|
||||
|
||||
// Remove or replace dangerous characters
|
||||
const sanitized = basename
|
||||
.replace(/[<>:"|?*\x00-\x1f]/g, "") // Remove dangerous chars
|
||||
.replace(/\.{2,}/g, ".") // Collapse multiple dots
|
||||
.replace(/^\.+/, "") // Remove leading dots
|
||||
.trim();
|
||||
|
||||
// Ensure filename is not empty
|
||||
if (!sanitized || sanitized.length === 0) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
// Limit filename length
|
||||
if (sanitized.length > 255) {
|
||||
const ext = sanitized.slice(sanitized.lastIndexOf("."));
|
||||
const name = sanitized.slice(0, 255 - ext.length);
|
||||
return name + ext;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file for upload
|
||||
*/
|
||||
export function validateFile(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
config: StorageConfig = {}
|
||||
): { valid: true } | { valid: false; error: string } {
|
||||
const { maxSize } = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
// Check file size
|
||||
if (buffer.length > maxSize) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `File too large. Maximum size is ${Math.round(maxSize / 1024 / 1024)}MB`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `File type not allowed. Allowed extensions: ${Array.from(ALLOWED_EXTENSIONS).join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Optionally check MIME type if provided
|
||||
// Note: For full MIME type validation, consider using a library like 'file-type'
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to storage (Vercel Blob or local filesystem)
|
||||
*
|
||||
*
|
||||
* @param buffer - File contents as a Buffer
|
||||
* @param filename - Name of the file (e.g., "image.png")
|
||||
* @param folder - Optional folder/prefix (e.g., "avatars")
|
||||
* @param config - Optional storage configuration
|
||||
* @returns StorageResult with url and pathname
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = await upload(fileBuffer, "avatar.png", "avatars");
|
||||
@@ -28,13 +137,23 @@ export interface StorageResult {
|
||||
export async function upload(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
folder?: string
|
||||
folder?: string,
|
||||
config?: StorageConfig
|
||||
): Promise<StorageResult> {
|
||||
// Sanitize filename
|
||||
const sanitizedFilename = sanitizeFilename(filename);
|
||||
|
||||
// Validate file
|
||||
const validation = validateFile(buffer, sanitizedFilename, config);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
|
||||
const hasVercelBlob = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
|
||||
|
||||
if (hasVercelBlob) {
|
||||
// Use Vercel Blob storage
|
||||
const pathname = folder ? `${folder}/${filename}` : filename;
|
||||
const pathname = folder ? `${folder}/${sanitizedFilename}` : sanitizedFilename;
|
||||
const blob = await put(pathname, buffer, {
|
||||
access: "public",
|
||||
});
|
||||
@@ -54,11 +173,11 @@ export async function upload(
|
||||
}
|
||||
|
||||
// Write the file
|
||||
const filepath = join(targetDir, filename);
|
||||
const filepath = join(targetDir, sanitizedFilename);
|
||||
await writeFile(filepath, buffer);
|
||||
|
||||
// Return local URL
|
||||
const pathname = folder ? `${folder}/${filename}` : filename;
|
||||
const pathname = folder ? `${folder}/${sanitizedFilename}` : sanitizedFilename;
|
||||
const url = `/uploads/${pathname}`;
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user