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>
223 lines
5.5 KiB
TypeScript
223 lines
5.5 KiB
TypeScript
import { existsSync } from "fs";
|
|
import { writeFile, mkdir } from "fs/promises";
|
|
import { join } from "path";
|
|
import { put, del } from "@vercel/blob";
|
|
|
|
/**
|
|
* Result from uploading a file to storage
|
|
*/
|
|
export interface StorageResult {
|
|
url: string; // Public URL to access the file
|
|
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");
|
|
* console.log(result.url); // https://blob.vercel.io/... or /uploads/avatars/avatar.png
|
|
* ```
|
|
*/
|
|
export async function upload(
|
|
buffer: Buffer,
|
|
filename: 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}/${sanitizedFilename}` : sanitizedFilename;
|
|
const blob = await put(pathname, buffer, {
|
|
access: "public",
|
|
});
|
|
|
|
return {
|
|
url: blob.url,
|
|
pathname: blob.pathname,
|
|
};
|
|
} else {
|
|
// Use local filesystem storage
|
|
const uploadsDir = join(process.cwd(), "public", "uploads");
|
|
const targetDir = folder ? join(uploadsDir, folder) : uploadsDir;
|
|
|
|
// Ensure the directory exists
|
|
if (!existsSync(targetDir)) {
|
|
await mkdir(targetDir, { recursive: true });
|
|
}
|
|
|
|
// Write the file
|
|
const filepath = join(targetDir, sanitizedFilename);
|
|
await writeFile(filepath, buffer);
|
|
|
|
// Return local URL
|
|
const pathname = folder ? `${folder}/${sanitizedFilename}` : sanitizedFilename;
|
|
const url = `/uploads/${pathname}`;
|
|
|
|
return {
|
|
url,
|
|
pathname,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a file from storage
|
|
*
|
|
* @param url - The URL of the file to delete
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* await deleteFile("https://blob.vercel.io/...");
|
|
* // or
|
|
* await deleteFile("/uploads/avatars/avatar.png");
|
|
* ```
|
|
*/
|
|
export async function deleteFile(url: string): Promise<void> {
|
|
const hasVercelBlob = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
|
|
|
|
if (hasVercelBlob) {
|
|
// Delete from Vercel Blob
|
|
await del(url);
|
|
} else {
|
|
// Delete from local filesystem
|
|
// Extract pathname from URL (e.g., /uploads/avatars/avatar.png -> avatars/avatar.png)
|
|
const pathname = url.replace(/^\/uploads\//, "");
|
|
const filepath = join(process.cwd(), "public", "uploads", pathname);
|
|
|
|
// Only attempt to delete if file exists
|
|
if (existsSync(filepath)) {
|
|
const { unlink } = await import("fs/promises");
|
|
await unlink(filepath);
|
|
}
|
|
}
|
|
}
|
|
|
|
|