Files
agentic-coding-starter-kit/create-agentic-app/template/src/lib/storage.ts
Leon van Zyl a3a151c67a 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>
2025-11-30 14:46:15 +02:00

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);
}
}
}