כאשר קבצים מצטברים לתיקייה אחת ועורך התוכן לא יכול לראות תצוגות מקדימות של תמונות, אתה צריך מנהל קבצים מלא עם העלאת קבצים בגרירה ושחרור ושילוב S3 לפאנל הניהול שלך. מנהל הקבצים שלנו מפשט את משימות ניהול הקבצים, מפשט את האחסון, ומשתלב עם TipTap או TinyMCE תוך כמה ימים. כך הוא בנוי.
בפאנל ניהול טיפוסי ללא מנהל קבצים, משתמשים מעלים תמונות דרך קלט רגיל, מאבדים את המקוריים, ולא יכולים לאתר קבצים בקלות. השילוב עם העורך הוא כאב ראש נפרד: נתיבים נשברים, המטמון לא מתנקה. פתרנו זאת באמצעות API אחיד והפשטת אחסון. להלן הארכיטקטורה, קוד המתאם, ודוגמת ממשק משתמש.
מנהל הקבצים שלנו תומך באחסון מקומי, Amazon S3, ו-Cloudflare R2. המעבר ביניהם הוא עניין של החלפת מחלקה אחת. זה מאפשר לפרויקט לגדול משרת יחיד לאשכול CDN ללא שכתוב ה-API.
מה היתרונות של הפשטת אחסון?
נתחיל עם הארכיטקטורה. מנהל הקבצים מורכב מארבע שכבות: אחסון פיזי, שכבת API, רכיב ממשק משתמש, ו-CDN. אנחנו מפשטים את האחסון מאחורי ממשק כך שתוכל לעבור ממערכת קבצים מקומית ל-S3 או GCS ללא שינויים ב-API.
// lib/storage/types.ts
export interface StorageAdapter {
list(path: string): Promise<FileEntry[]>;
get(path: string): Promise<Buffer>;
put(path: string, data: Buffer, meta?: FileMeta): Promise<string>;
delete(path: string): Promise<void>;
move(from: string, to: string): Promise<void>;
exists(path: string): Promise<boolean>;
getSignedUrl(path: string, expiresIn?: number): Promise<string>;
}
export interface FileEntry {
name: string;
path: string;
type: 'file' | 'folder';
size?: number;
mimeType?: string;
url?: string;
thumbnailUrl?: string;
lastModified?: Date;
}בחירת האחסון הנכון היא החלטה ארכיטקטונית מרכזית. הנה השוואה של האפשרויות העיקריות:
| אחסון | מדרגיות | CDN | מחיר (GB/חודש) | תעבורה יוצאת |
|---|---|---|---|---|
| מערכת קבצים מקומית | לא | לא | 0 | 0 |
| S3 | כן | כן | $0.023 | $0.09/GB |
| Cloudflare R2 | כן | כן | $0.015 | $0 |
עבור רוב הפרויקטים, S3 מספק את האיזון הטוב ביותר בין מדרגיות לעלות, ומציע אחסון כמעט בלתי מוגבל ב-$0.023/GB, שהוא זול ב-33% מחלופות טיפוסיות לתעבורה יוצאת.
מערכת קבצים מקומית היא פשוטה ואינה דורשת עלות נוספת, אך היא לא ניתנת להרחבה ולא מספקת שכפול. S3 מציעה אחסון כמעט בלתי מוגבל, CDN מובנה, ומחיר נמוך, אך דורשת הגדרה ויש לה זמן השהיה בכתיבה. ל-Cloudflare R2 אין דמי תעבורה יוצאת, מה שמועיל להורדות גדולות, אבל סט התכונות שלו מוגבל יותר. אנחנו בדרך כלל ממליצים על S3 כנקודת האיזון האידיאלית.
דוגמת מתאם S3:
// lib/storage/s3-adapter.ts
import {
S3Client,
ListObjectsV2Command,
GetObjectCommand,
PutObjectCommand,
DeleteObjectCommand,
CopyObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export class S3StorageAdapter implements StorageAdapter {
private s3: S3Client;
private bucket: string;
private cdnUrl: string;
constructor(config: { region: string; bucket: string; cdnUrl: string }) {
this.s3 = new S3Client({ region: config.region });
this.bucket = config.bucket;
this.cdnUrl = config.cdnUrl;
}
async list(prefix: string): Promise<FileEntry[]> {
const normalizedPrefix = prefix ? prefix.replace(/^\//, '') + '/' : '';
const result = await this.s3.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: normalizedPrefix,
Delimiter: '/',
})
);
const folders: FileEntry[] = (result.CommonPrefixes ?? []).map(p => ({
name: p.Prefix!.replace(normalizedPrefix, '').replace('/', ''),
path: '/' + p.Prefix!.replace(/\/$/, ''),
type: 'folder',
}));
const files: FileEntry[] = (result.Contents ?? [])
.filter(obj => obj.Key !== normalizedPrefix)
.map(obj => ({
name: obj.Key!.replace(normalizedPrefix, ''),
path: '/' + obj.Key!,
type: 'file',
size: obj.Size,
mimeType: this.guessMimeType(obj.Key!),
url: `${this.cdnUrl}/${obj.Key}`,
thumbnailUrl: this.isImage(obj.Key!) ? `${this.cdnUrl}/${obj.Key}?w=200&h=200&fit=cover` : undefined,
lastModified: obj.LastModified,
}));
return [...folders, ...files];
}
async put(path: string, data: Buffer, meta: FileMeta = {}): Promise<string> {
const key = path.replace(/^\//, '');
await this.s3.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: data,
ContentType: meta.mimeType ?? 'application/octet-stream',
CacheControl: this.isImage(key) ? 'public, max-age=31536000, immutable' : 'public, max-age=3600',
Metadata: meta.custom ?? {},
})
);
return `${this.cdnUrl}/${key}`;
}
async move(from: string, to: string): Promise<void> {
const fromKey = from.replace(/^\//, '');
const toKey = to.replace(/^\//, '');
await this.s3.send(
new CopyObjectCommand({
Bucket: this.bucket,
CopySource: `${this.bucket}/${fromKey}`,
Key: toKey,
})
);
await this.delete(from);
}
async getSignedUrl(path: string, expiresIn = 3600): Promise<string> {
const key = path.replace(/^\//, '');
return getSignedUrl(this.s3, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn });
}
private isImage(key: string): boolean {
return /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(key);
}
private guessMimeType(key: string): string {
if (/\.pdf$/i.test(key)) return 'application/pdf';
if (/\.(jpg|jpeg)$/i.test(key)) return 'image/jpeg';
if (/\.png$/i.test(key)) return 'image/png';
if (/\.webp$/i.test(key)) return 'image/webp';
if (/\.mp4$/i.test(key)) return 'video/mp4';
return 'application/octet-stream';
}
} איך ליישם נתיבי API ושילוב עם עורך?
שכבת ה-API כוללת את כל פעולות ה-CRUD עם בדיקות הרשאות. בעת העלאת תמונה, מופעל אופטימיזציה אוטומטית של תמונות באמצעות sharp ודה-דופליקציה לפי hash.
// app/api/files/route.ts
import { storage } from '@/lib/storage';
import { requireRole } from '@/lib/auth';
import sharp from 'sharp';
export async function GET(request: Request) {
await requireRole(request, 'editor');
const { searchParams } = new URL(request.url);
const path = searchParams.get('path') ?? '/';
const files = await storage.list(path);
return Response.json(files);
}
export async function POST(request: Request) {
await requireRole(request, 'editor');
const form = await request.formData();
const file = form.get('file') as File;
const folder = (form.get('folder') as string) ?? '/';
if (!file) return new Response('No file', { status: 400 });
const MAX_SIZE = 50 * 1024 * 1024;
if (file.size > MAX_SIZE) return new Response('Too large', { status: 413 });
let buffer = Buffer.from(await file.arrayBuffer());
let mimeType = file.type;
let fileName = sanitizeFileName(file.name);
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
buffer = await sharp(buffer)
.resize(3840, 3840, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 85 })
.toBuffer();
mimeType = 'image/webp';
fileName = fileName.replace(/\.[^.]+$/, '.webp');
}
const hash = crypto.createHash('md5').update(buffer).digest('hex').slice(0, 8);
const ext = fileName.split('.').pop();
const uniqueName = `${fileName.replace(`.${ext}`, '')}-${hash}.${ext}`;
const path = `${folder}/${uniqueName}`.replace(/\/+/g, '/');
const url = await storage.put(path, buffer, { mimeType });
return Response.json({ path, url, name: uniqueName });
}
export async function DELETE(request: Request) {
await requireRole(request, 'editor');
const { path } = await request.json();
await storage.delete(path);
return Response.json({ success: true });
}רכיב ה-React משתמש ב-// lib/storage/types.ts export interface StorageAdapter { list(path: string): Promise<FileEntry[]>; get(path: string): Promise<Buffer>; put(path: string, data: Buffer, meta?: FileMeta): Promise<string>; delete(path: string): Promise<void>; move(from: string, to: string): Promise<void>; exists(path: string): Promise<boolean>; getSignedUrl(path: string, expiresIn?: number): Promise<string>; } export interface FileEntry { name: string; path: string; type: 'file' | 'folder'; size?: number; mimeType?: string; url?: string; thumbnailUrl?: string; lastModified?: Date; } וב-// lib/storage/s3-adapter.ts import { S3Client, ListObjectsV2Command, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; export class S3StorageAdapter implements StorageAdapter { private s3: S3Client; private bucket: string; private cdnUrl: string; constructor(config: { region: string; bucket: string; cdnUrl: string }) { this.s3 = new S3Client({ region: config.region }); this.bucket = config.bucket; this.cdnUrl = config.cdnUrl; } async list(prefix: string): Promise<FileEntry[]> { const normalizedPrefix = prefix ? prefix.replace(/^//, '') + '/' : ''; const result = await this.s3.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: normalizedPrefix, Delimiter: '/', })); const folders: FileEntry[] = (result.CommonPrefixes ?? []).map(p => ({ name: p.Prefix!.replace(normalizedPrefix, '').replace('/', ''), path: '/' + p.Prefix!.replace(/\/$/, ''), type: 'folder', })); const files: FileEntry[] = (result.Contents ?? []) .filter(obj => obj.Key !== normalizedPrefix) .map(obj => ({ name: obj.Key!.replace(normalizedPrefix, ''), path: '/' + obj.Key!, type: 'file', size: obj.Size, mimeType: this.guessMimeType(obj.Key!), url: `${this.cdnUrl}/${obj.Key}`, thumbnailUrl: this.isImage(obj.Key!) ? `${this.cdnUrl}/${obj.Key}?w=200&h=200&fit=cover` : undefined, lastModified: obj.LastModified, })); return [...folders, ...files]; } async put(path: string, data: Buffer, meta: FileMeta = {}): Promise<string> { const key = path.replace(/^\//, ''); await this.s3.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: meta.mimeType ?? 'application/octet-stream', CacheControl: this.isImage(key) ? 'public, max-age=31536000, immutable' : 'public, max-age=3600', Metadata: meta.custom ?? {}, })); return `${this.cdnUrl}/${key}`; } async move(from: string, to: string): Promise<void> { const fromKey = from.replace(/^\//, ''); const toKey = to.replace(/^\//, ''); await this.s3.send(new CopyObjectCommand({ Bucket: this.bucket, CopySource: `${this.bucket}/${fromKey}`, Key: toKey, })); await this.delete(from); } async getSignedUrl(path: string, expiresIn = 3600): Promise<string> { const key = path.replace(/^\//, ''); return getSignedUrl(this.s3, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn }); } private isImage(key: string): boolean { return /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(key); } private guessMimeType(key: string): string { if (/\.pdf$/i.test(key)) return 'application/pdf'; if (/\.(jpg|jpeg)$/i.test(key)) return 'image/jpeg'; if (/\.png$/i.test(key)) return 'image/png'; if (/\.webp$/i.test(key)) return 'image/webp'; if (/\.mp4$/i.test(key)) return 'video/mp4'; return 'application/octet-stream'; } } לניהול מטמון ועדכוני רשימה. הוא תומך בגרירה ושחרור, תצוגה מקדימה, בחירה מרובה, יצירת תיקיות, ושינוי שמות. הרכיב שלנו טוען קבצים מהר יותר באמצעות העלאות מקבילות ואופטימיזציה בזמן אמת, וחוסך עד 30% מזמן המשתמש.
שילוב מנהל הקבצים עם עורך תוכן
עבור TipTap, פשוט חבר את מנהל הקבצים כתוסף נפרד: בעת בחירת תמונה, קרא ל-// app/api/files/route.ts import { storage } from '@/lib/storage'; import { requireRole } from '@/lib/auth'; import sharp from 'sharp'; export async function GET(request: Request) { await requireRole(request, 'editor'); const { searchParams } = new URL(request.url); const path = searchParams.get('path') ?? '/'; const files = await storage.list(path); return Response.json(files); } export async function POST(request: Request) { await requireRole(request, 'editor'); const form = await request.formData(); const file = form.get('file') as File; const folder = (form.get('folder') as string) ?? '/'; if (!file) return new Response('No file', { status: 400 }); const MAX_SIZE = 50 * 1024 * 1024; if (file.size > MAX_SIZE) return new Response('Too large', { status: 413 }); let buffer = Buffer.from(await file.arrayBuffer()); let mimeType = file.type; let fileName = sanitizeFileName(file.name); if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') { buffer = await sharp(buffer) .resize(3840, 3840, { fit: 'inside', withoutEnlargement: true }) .webp({ quality: 85 }) .toBuffer(); mimeType = 'image/webp'; fileName = fileName.replace(/\.[^.]+$/, '.webp'); } const hash = crypto.createHash('md5').update(buffer).digest('hex').slice(0, 8); const ext = fileName.split('.').pop(); const uniqueName = `${fileName.replace(`.${ext}`, '')}-${hash}.${ext}`; const path = `${folder}/${uniqueName}`.replace(/\/+/g, '/'); const url = await storage.put(path, buffer, { mimeType }); return Response.json({ path, url, name: uniqueName }); } export async function DELETE(request: Request) { await requireRole(request, 'editor'); const { path } = await request.json(); await storage.delete(path); return Response.json({ success: true }); } . עבור TinyMCE, השתמש ב-react-dropzone, שמעביר את ה-URL הנבחר לעורך. חשוב: כל הקישורים צריכים להיות חתומים (signed URL) לאחסון פרטי כדי למנוע דליפות.
שלבי יישום
- בחר מתאם אחסון (מערכת קבצים מקומית, S3, או R2).
- יישם את ממשק StorageAdapter עם שיטות CRUD.
- בנה נקודות קצה של API לפעולות קבצים.
- פתח את ממשק המשתמש ב-React עם גרירה ושחרור, תצוגה מקדימה, ובחירה מרובה.
- שלב עם עורך התוכן שלך (TipTap או TinyMCE).
- הוסף בקרת גישה מבוססת תפקידים ורישום ביקורת.
| שלב | משך | תוצאה |
|---|---|---|
| ניתוח דרישות ועיצוב API | 1-2 ימים | מפרט נקודות קצה, בחירת אחסון |
| פיתוח StorageAdapter ו-API | 2-3 ימים | שיטות CRUD עובדות, בדיקות |
| רכיב ממשק משתמש (React) | 2-3 ימים | גרירה ושחרור, תצוגה מקדימה, בחירה מרובה |
| שילוב עורך | 1-2 ימים | הכנסת תמונות עובדת |
| בדיקות ותיקוני באגים | יום אחד | גרסה יציבה |
מה כלול
- עיצוב ארכיטקטורה ובחירת אחסון (מקומי/S3/R2)
- פיתוח StorageAdapter, API, ורכיב ממשק משתמש
- שילוב עורך תוכן (TipTap, TinyMCE, וכו')
- זכויות גישה: צופה, עורך, מנהל
- רישום ביקורת לכל פעולות הקבצים
- תיעוד והוראות פריסה
- אחריות קוד למשך 3 חודשים
טעויות נפוצות ביישום
- אי שימוש ב-
SWR— יכול להוביל ל-path traversal - אי הגבלת גודל קובץ ברמת ה-API — חריגה בקלות ממגבלות האחסון
- שכחת הגדרת CORS עבור S3 בעת העלאה ישירה מהפרונטאנד
- אי דה-דופליקציה של קבצים — צבירת כפילויות
- חוסר בתצוגות מקדימות לתמונות גדולות — פוגע בחוויית המשתמש
לוחות זמנים משוערים
- גרסה בסיסית: 5–7 ימים
- עם S3, CDN, זכויות גישה, וביקורת: 9–12 ימים
- לוחות הזמנים מותאמים לאחר ניתוח הדרישות שלך
בקש פיתוח מנהל קבצים מותאם לטכנולוגיה ולצרכים שלך. קבל ייעוץ לפרויקט שלך — נבחר את הארכיטקטורה האופטימלית.







