آپلود عکس و فایل در اپلیکیشن؛ بدون سرور، با یک درخواست
آپلود فایل از اپ به بکاند با یک درخواست multipart: آدرس عمومی برای تگ img، فایل خصوصی پشت توکن، تصویر کوچک با ?w=، سقف حجم هر پلن و سه خطایی که وقت میگیرد.
در این مطلب
آپلود عکس در اپلیکیشن، اگر بکاند آماده داشته باشید، یک درخواست است: فایل را بهصورت multipart به مسیر /v1/files میفرستید و در جواب یک شناسه و یک آدرس میگیرید. شناسه را در رکوردتان ذخیره میکنید و آدرس را در تگ img. نه سروری لازم است، نه پوشهی uploads، نه کدی که پسوند فایل را چک کند. در این راهنما همان یک درخواست را با curl، جاوااسکریپت و فلاتر میزنیم، بعد سراغ چیزهایی میرویم که در آموزشهای فارسی نیست و بیشترین وقت را میگیرد: فرق آدرس عمومی و خصوصی، وصل کردن فایل به رکورد، تصویر کوچک بدون سرویس دوم، و سقف حجمی که هر پلن دارد.
چرا عکس را با base64 داخل دیتابیس نگذاریم؟
بیشتر سورسهای فارسیِ «آپلود عکس در اندروید» عکس را base64 میکنند، داخل JSON میگذارند و سمت سرور دیکد میکنند. کار میکند، و سه هزینه دارد که روز اول دیده نمیشود: حجم یکسوم بیشتر میشود، چون base64 هر سه بایت را چهار کاراکتر میکند. دیتابیس سنگین میشود، چون هر خواندن آن جدول عکسها را هم میخواند و بکاپ چند برابر میشود. و کش مرورگر بیاستفاده میماند، چون عکسی که داخل JSON آمده هر بار با داده دوباره دانلود میشود.
راه درست همان چیزی است که HTTP برایش ساخته شده: multipart/form-data. فایل جدا از داده میرود، آدرس خودش را میگیرد، و رکورد فقط شناسهاش را نگه میدارد.
آپلود فایل با یک درخواست multipart
اندپوینت آپلود در فیکارو POST /v1/files است و فایل را در فیلدی به نام file میگیرد. احراز هویت همان لایهای است که برای بقیهی API دارید: کلید API از سمت سرور، یا توکن JWT کاربری که وارد اپ شده. سادهترین شکلش با curl:
curl -X POST https://api.fikaro.ir/my-shop/v1/files \
-H "Authorization: Bearer $TOKEN" \
-F "file=@photo.jpg" \
-F "public=true"
جواب با کد 201 برمیگردد و همهچیزی که بعداً لازم دارید در همان است:
{
"id": "0192b6e4-3f6a-7c3e-9a1d-5f2c8e7a4b10",
"filename": "photo.jpg",
"contentType": "image/jpeg",
"size": 248113,
"checksum": "a3f1…",
"isPublic": true,
"createdAt": "2026-09-05T08:12:41Z",
"url": "https://api.fikaro.ir/my-shop/files/0192b6e4-…"
}
در مرورگر و ریاکتنیتیو همین درخواست با FormData ساخته میشود:
const form = new FormData();
form.append("file", input.files[0]);
form.append("public", "true");
const res = await fetch("https://api.fikaro.ir/my-shop/v1/files", {
method: "POST",
headers: { Authorization: "Bearer " + userToken },
body: form,
});
const file = await res.json(); // file.id و file.url
وقتی بدنه FormData است، هدر Content-Type را خودتان ننویسید. مرورگر باید
آن را همراه با رشتهی boundary بسازد؛ اگر دستی multipart/form-data بگذارید،
boundary نمیرود و سرور با خطای file_required جواب میدهد، در حالی که
فایل واقعاً فرستاده شده. این رایجترین خطای آپلود در جاوااسکریپت است.
در فلاتر همان پکیج http که برای بقیهی درخواستها دارید کافی است:
Future<String> uploadImage(File image) async {
final req = http.MultipartRequest('POST', Uri.parse('$base/files'))
..headers['Authorization'] = 'Bearer $userToken'
..fields['public'] = 'true'
..files.add(
await http.MultipartFile.fromPath('file', image.path),
);
final res = await http.Response.fromStream(await req.send());
if (res.statusCode != 201) {
final err = jsonDecode(utf8.decode(res.bodyBytes));
throw ApiException(err['code'] as String);
}
return jsonDecode(utf8.decode(res.bodyBytes))['id'] as String;
}
سه فیلد اختیاری دیگر هم میتوانید کنار file بفرستید: public که در بخش بعد توضیح میدهیم، و entity و recordId که فایل را به یک رکورد گره میزنند تا بعداً با GET /v1/files?entity=product&recordId=… همهی فایلهای یک رکورد را یکجا بگیرید.
آدرس عمومی یا خصوصی؛ کدام را در تگ img بگذاریم؟
هر فایل یکی از دو حالت را دارد و آدرسش بسته به همان حالت فرق میکند؛ این فرق دلیل بیشتر خطاهای ۴۰۱ بعد از آپلود است.
| عمومی (public=true) | خصوصی (پیشفرض) | |
|---|---|---|
| آدرس | /my-shop/files/{id} | /my-shop/v1/files/{id} |
| هدر لازم | هیچ | Authorization با کلید یا توکن |
| قابل استفاده در img و video | بله | نه — مرورگر هدر نمیفرستد |
| کش | یک سال، تغییرناپذیر | بدون کش |
| اگر با آدرس اشتباه بخوانید | — | ۴۰۴، نه ۴۰۳ |
| مناسب برای | عکس محصول، آواتار، کاور | فاکتور، مدرک، فایل داخلی |
فیلد url در جواب آپلود همیشه آدرس درستِ همان حالت است؛ اگر فایل را عمومی آپلود کردهاید، مستقیم در src بگذارید و تمام. اگر بعداً نظرتان عوض شد، با PATCH /v1/files/{id} و بدنهی {"isPublic": true} حالتش را عوض میکنید و آدرس تازه در همان جواب برمیگردد.
دو نکته دربارهی مسیر عمومی. اول، فایل خصوصی از آن مسیر 404 میگیرد نه 403، تا کسی نتواند با حدس زدن شناسهها بفهمد کدام فایل وجود دارد. دوم، آدرس عمومی یک سال و immutable کش میشود، چون محتوای یک شناسه هرگز عوض نمیشود؛ عکس تازه یعنی فایل تازه و بهروز کردن شناسه در رکورد. برای دانلود اجباری بهجای نمایش در مرورگر هم ?download=1 را به آدرس اضافه کنید.
فایل را چطور به رکورد وصل کنیم؟
رکورد، آدرس فایل را نگه نمیدارد؛ شناسهاش را نگه میدارد. اگر روزی دامنهی API عوض شود یا فایل از عمومی به خصوصی برود، رکورد همچنان درست است. قالبهای آمادهی فیکارو از همین الگو استفاده میکنند: در قالب فروشگاه، جدول product فیلدی به نام image از نوع «فایل» دارد که فقط شناسهی یک فایل آپلودشده را میپذیرد و شناسهی نامعتبر را با خطای invalid_format رد میکند.
پس جریان کامل در اپ دو درخواست است: اول آپلود، بعد ساخت رکورد با شناسهای که برگشت.
const photo = await upload(input.files[0]);
await fetch("https://api.fikaro.ir/my-shop/v1/product", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + userToken,
},
body: JSON.stringify({
name: "کیف چرم",
price: 890000,
image: photo.id,
}),
});
و در پنل داده، فیلد از نوع فایل یک انتخابگر دارد که هم از فایلهای موجود انتخاب میکند و هم همانجا آپلود میکند، تا برای اضافه کردن یک محصول لازم نباشد بین دو صفحه رفتوآمد کنید.
یک مرز صادقانه: در ویرایشگر مدل داده، نوع «فایل» هنوز در فهرست انواع فیلد نیامده و فعلاً از راه قالبهای آماده یا دستیار هوش مصنوعی به مدل میرسد. برای مدلی که خودتان از صفر میسازید، یک فیلد متنی بسازید و شناسه را در آن بگذارید؛ همهچیز همانطور کار میکند و تنها چیزی که از دست میدهید اعتبارسنجی خودکار شناسه و انتخابگر فایل در پنل است.
تصویر کوچک بدون سرویس دوم
عکس سهمگابایتی دوربین را نباید در لیست محصولات با عرض ۸۰ پیکسل نشان داد. راه رایج یا ساخت چند نسخه سمت اپ قبل از آپلود است، یا یک سرویس پردازش تصویر جدا. در فیکارو پارامتر ?w= روی آدرس عمومی همین کار را میکند:
<img src="https://api.fikaro.ir/my-shop/files/0192b6e4-…?w=256" />
عرضهای مجاز ثابتاند: 64، 128، 256، 400، 800 و 1200. عدد دیگری بفرستید خطای invalid_width میگیرید و همانجا فهرست عرضهای مجاز را میبینید. محدودیت تصادفی نیست: اگر هر عرضی مجاز بود، یک عکس و یک حلقه از ۱ تا ۴۰۰۰ میشد چهار هزار پردازش تصویر روی سرور.
اگر فایل تصویر نباشد، یا از عرض خواستهشده کوچکتر باشد، خودِ فایل اصلی سرو میشود نه خطا؛ یک گالری میتواند ?w= را روی هر آیتمی بگذارد و روی تک PDF لیست نشکند. هر عرض هم ETag خودش را دارد، پس درخواست دوم برای همان تصویر کوچک یک 304 است، نه پردازش دوباره.
سقف حجم و فضای هر پلن
دو سقف جدا وجود دارد و پیام خطایشان هم جداست: بزرگترین فایلی که در یک آپلود میپذیریم، و کل فضایی که پروژه دارد.
| پلن | حداکثر هر فایل | فضای کل پروژه |
|---|---|---|
| اسپرسو | ۱۰ مگابایت | ۵۰ مگابایت |
| کاپوچینو | ۵۰ مگابایت | ۲۵۰ مگابایت |
| موکا | ۲۵۰ مگابایت | ۱٫۲۵ گیگابایت |
| آفوگاتو | ۵۰۰ مگابایت | ۶٫۲۵ گیگابایت |
فایل بزرگتر از سقف پلن با کد 413 و file_too_large رد میشود و متن خطا حداکثر مجاز پلن شما را میگوید؛ پر شدن فضا هم 413 است با پیامی که میگوید فایل قدیمی حذف کنید یا پلن را بالا ببرید. حجم از روی بایتهای رسیده حساب میشود نه هدر multipart، پس کم اعلام کردن حجم چیزی عوض نمیکند. مصرف فعلی در صفحهی «فایلها»ی پنل است و اعداد بالاتر در صفحهی تعرفهها.
سه خطایی که سر راهاندازی میگیرید
۴۰۱ روی عکسی که همین الان آپلود کردید. فایل را بدون public=true فرستادهاید، آدرس /v1/files/… گرفتهاید و آن را در تگ img گذاشتهاید. مرورگر برای تگ img هدر Authorization نمیفرستد، پس هر بار ۴۰۱ میگیرید. یا فایل را عمومی آپلود کنید، یا با PATCH عمومیاش کنید.
لوگوی SVG بهصورت متن باز میشود. نوع فایل از روی بایتهای خودش تشخیص داده میشود، نه از روی پسوند یا چیزی که کلاینت ادعا کرده. هر چیزی که HTML، SVG یا جاوااسکریپت باشد بهعنوان text/plain ذخیره و سرو میشود، چون SVG میتواند اسکریپت داشته باشد و اجرای آن روی دامنهی API یعنی اجرای اسکریپت با اعتبار همهی فایلهای شما. برای لوگو و آیکون، PNG یا WebP آپلود کنید؛ SVG را داخل خود اپ نگه دارید.
آپلود با کلید عمومی رد میشود. کلید apck_pub_ که داخل اپ میگذارید فقط میخواند و فقط چیزی را که عمومی کردهاید. آپلود، نوشتن است؛ پس یا کاربر باید وارد شده باشد و با توکن خودش آپلود کند، یا آپلود از سرور خودتان با کلید API برود. تفاوت این سه اعتبارنامه در امنیت API بهتفصیل آمده است.
چه چیزی هنوز کار شماست
دسترسی به فایل امروز سطح پروژه است، نه سطح کاربر. هر کاربر واردشده که شناسهی یک فایل خصوصی را داشته باشد میتواند از مسیر احراز هویتشده آن را بخواند، و حذف فایل هم به همین شکل محدود به آپلودکننده نیست. برای عکس محصول و آواتار این مسئلهای نیست؛ برای مدرک شناسایی یا فاکتور شخصی هر کاربر، فعلاً فضای فایل فیکارو جای درستی نیست. فشردهسازی و برش تصویر قبل از آپلود هم کار اپ است؛ سرور فقط تصویر کوچک با عرض ثابت میسازد، نه برش و واترمارک. و انتخاب فایل از گالری یا دوربین رابط کاربری خودتان است، مثل بقیهی رابط کاربری.
اگر مدل دادهای که این فایلها به آن وصل میشوند را هنوز ندارید، بکاند فروشگاه اینترنتی همان جدول product را با فیلد تصویر میسازد، و کد اتصال کامل از اپ موبایل، از ورود تا لیستگیری، در بکاند فلاتر و ریاکتنیتیو آمده است. جزئیات توکن کاربر هم در کلیدها و احراز هویت است.
سوالات متداول
- عکس را در اپلیکیشن کجا ذخیره کنیم، دیتابیس یا سرور فایل؟
- فایل را جدا از دیتابیس ذخیره کنید و در رکورد فقط شناسهاش را نگه دارید. ذخیرهی base64 داخل دیتابیس حجم را یکسوم بیشتر میکند، هر خواندن جدول را سنگین میکند و کش مرورگر را بیاستفاده میگذارد. با بکاند آماده، آپلود یک درخواست multipart به /v1/files است و شناسه و آدرس در همان جواب برمیگردد.
- چرا عکس آپلودشده در تگ img خطای ۴۰۱ میدهد؟
- چون فایل خصوصی است و آدرس /v1/files/{id} هدر Authorization میخواهد، در حالی که مرورگر برای تگ img هدر نمیفرستد. فایل را با public=true آپلود کنید یا با PATCH عمومیاش کنید؛ آدرس عمومی /files/{id} بدون هدر باز میشود و یک سال کش میماند.
- برای تصویر کوچک (thumbnail) باید چند نسخه آپلود کنم؟
- نه. روی آدرس عمومی پارامتر ?w= را بگذارید؛ عرضهای ۶۴، ۱۲۸، ۲۵۶، ۴۰۰، ۸۰۰ و ۱۲۰۰ مجازند و هر عرض ETag خودش را دارد. اگر فایل تصویر نباشد یا از عرض خواستهشده کوچکتر باشد، فایل اصلی سرو میشود، نه خطا.
- حداکثر حجم فایل برای آپلود چقدر است؟
- به پلن بستگی دارد: از ۱۰ مگابایت برای هر فایل در پلن اسپرسو تا ۵۰۰ مگابایت در آفوگاتو، و فضای کل از ۵۰ مگابایت تا ۶٫۲۵ گیگابایت. فایل بزرگتر با کد ۴۱۳ و file_too_large رد میشود و متن خطا سقف پلن شما را میگوید.
مطالب مرتبط
- ۳ دقیقه مطالعه
بکاند فروشگاه اینترنتی؛ کاتالوگ، سبد، سفارش و پرداخت
مدل دادهی کامل یک فروشگاه با سطح دسترسی هر جدول، و دو چیزی که همیشه اشتباه پیاده میشوند: قیمت لحظهی خرید، و اینکه موجودی را کِی باید کم کرد.
- ۴ دقیقه مطالعه
سیستم نوبتدهی آنلاین؛ مدل داده و API یک اپ رزرو
سیستم نوبتدهی از چهار موجودیت ساخته میشود: خدمت، کارشناس، بازه و نوبت. مدل داده، سطح دسترسی هرکدام، و تلهی تداخل نوبت که پیادهسازی اول را میشکند.