نظرة عامة على Active Storage في ريلز

من موسوعة حسوب
اذهب إلى التنقل اذهب إلى البحث

يغطّي هذا الدليل كيفيّة إرفاق ملفات بنماذج Active Record. ستتعلم بعد قراءة هذا الدليل:

  • كيفية إرفاق ملف أو عدّة ملفّات بسجل (record).
  • كيفيّة حذف ملف مُرفق.
  • كيفيّة الربط بملف مُرفق.
  • كيفيّة استخدام المتغيّرات (variants) لتحويل الصور.
  • كيفيّة إنشاء تمثيل صُوَري (image representation) لملف غير صُوَري، مثل ملف PDF أو فيديو.
  • كيفيّة إرسال تحميلات الملفّات مباشرةً من المتصفّحات إلى خدمة تخزين، دون المرور على خوادم تطبيقك.
  • كيفيّة تنظيف الملفّات المخزّنة أثناء الاختبار.
  • كيفيّة تعريف استخدام (implement) الدعم لخدمات تخزين إضافيّة.

ما هو Active Storage؟

يُسهّل Active Storage تحميل الملفّات إلى خدمة تخزين سحابية (cloud storage) مثل Amazon S3 أو Google Cloud Storage أو Microsoft Azure Storage وإرفاق هذه الملفّات بكائنات Active Storage. يأتي مع خدمات التخزين تلك خدمة تستند على أقراص تخزين محلية للتطوير والاختبار، كما تدعم نسخ الملفّات إلى خدمات تابعة (subordinate services) للنسخ الإحتياطي والتهجيرات.

باستخدام Active Storage، يمكن للتطبيق تحويل تحميلات الصور باستخدام ImageMagick، وإنشاء صور تمثيلية للتحميلات غير الصُوريّة مثل ملفّات PDF ومقاطع الفيديو، واستخراج البيانات الوصفيّة (metadata) من ملفّات عشوائيّة.

الإعداد

يستخدم Active Storage جدولين في قاعدة بيانات تطبيقك المسمّيَين active_storage_blobs و active_storage_attachments. بعد إنشاء تطبيق جديد (أو ترقية تطبيقك إلى الإصدار 5.2 من ريلز)، نفِّذ الأمر rails active_storage:install لإنشاء عملية تهجير تُنشئ هذين الجدولين. استخدم rails db:migrate لتشغيل التهجير.

صرّح عن خدمات Active Storage في config/storage.yml. قدّم اسمًا مع التهيئة المطلوبة لكل خدمة يستخدمها تطبيقك. يوضح المثال أدناه ثلاث خدمات تدعى local و test و amazon:

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>
 
test:
  service: Disk
  root: <%= Rails.root.join("tmp/storage") %>
 
amazon:
  service: S3
  access_key_id: ""
  secret_access_key: ""

أخبر Active Storage بأي خدمة تريد استخدامها من خلال تعيين Rails.application.config.active_storage.service. من المستحسن فعل هذا مع كل بيئة نظرًا لأن من المحتمل أنّ كلّ بيئة تستخدم خدمة مختلفة. لاستخدام خدمة القرص (disk service) من المثال السابق في بيئة التطوير، يمكنك إضافة ما يلي إلى الملف config/environments/development.rb:

# تخزين الملفات محليًّا
config.active_storage.service = :local

لاستخدام خدمة Amazon S3 في الإنتاج، يمكنك إضافة ما يلي إلى الملف config/environments/production.rb:

# Amazon S3 تخزين الملفات على
config.active_storage.service = :amazon

استمر في القراءة لمزيد من المعلومات حول مُهيئات الخدمة المضمّنة (مثل Disk و S3) والتهيئة التي تتطلّبها.

خدمة القرص

صرّح بتعريف خدمة قرص في config/storage.yml:

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

خدمة Amazon S3

صرّح بتعريف خدمة S3 في config/storage.yml:

amazon:
  service: S3
  access_key_id: ""
  secret_access_key: ""
  region: ""
  bucket: ""

أضف الجوهرة aws-sdk-s3 إلى Gemfile:

gem "aws-sdk-s3", require: false

ملاحظة: تتطلّب ميزات Active Storage الأساسيّة الأذونات (permissions) التالية: s3:ListBucket و s3:PutObject و s3:GetObject و s3:DeleteObject. قد تحتاج إلى أذونات إضافية إن كانت لديك خيارات تحميل إضافية مُهيّئة مثل إعداد قوائم التحكم في الوصول (ACL).

ملاحظة: إن أردت استخدام متغيّرات البيئة أو ملفّات ضبط SDK القياسية، أو الملفّات الشخصية، أو ملفّات تعريف نُسخ IAM أو أدوار المهام (task roles)، يمكنك تجاهل access_key_id و secret_access_key، ومفاتيح region في المثال أعلاه. تدعم خدمة Amazon S3 جميع خيارات الاستيثاق (authentication) الموضّحة في توثيق AWS SDK.

خدمة Microsoft Azure Storage

صرّح بتعريف خدمة Azure Storage في config/storage.yml:

azure:
  service: AzureStorage
  storage_account_name: ""
  storage_access_key: ""
  container: ""

أضف الجوهرة azure-storage إلى Gemfile:

gem "azure-storage", require: false

خدمة التخزين السحابي من Google

صرّح عن خدمة التخزين السحابي من Google في config/storage.yml:

google:
  service: GCS
  credentials: <%= Rails.root.join("path/to/keyfile.json") %>
  project: ""
  bucket: ""

يمكنك اختياريًّا تزويد جدول Hash من بيانات الاعتماد بدلًا من مسار ملف مفاتيح (keyfile path):

google:
  service: GCS
  credentials:
    type: "service_account"
    project_id: ""
    private_key_id: <%= Rails.application.credentials.dig(:gcs, :private_key_id) %>
    private_key: <%= Rails.application.credentials.dig(:gcs, :private_key).dump %>
    client_email: ""
    client_id: ""
    auth_uri: "https://accounts.google.com/o/oauth2/auth"
    token_uri: "https://accounts.google.com/o/oauth2/token"
    auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs"
    client_x509_cert_url: ""
  project: ""
  bucket: ""

أضف الجوهرة google-cloud-storage إلى Gemfile:

gem "google-cloud-storage", "~> 1.8", require: false

خدمة التماثل الانعكاسي (Mirror Service)

يمكنك الاحتفاظ بعدّة خدمات متزامنة من خلال تعريف خدمة التماثل الانعكاسي (Mirror Service). عندما يُحمّل ملف أو يُحذف، يُطبّق الفعل على جميع الخدمات المشتركة في التماثل الانعكاسي. يمكنك البدء في انعكاس (mirroring) خدمة جديدة، ونسخ الملفّات الموجودة من الخدمة القديمة للجديدة، ثم أخذ كامل راحتك مع النسخة الجديدة. عرّف كل الخدمات التي ترغب في استخدامها كما هو موضّح أعلاه وأشر إليها من إحدى الخدمات المُنعكسة (mirrored service).

s3_west_coast:
  service: S3
  access_key_id: ""
  secret_access_key: ""
  region: ""
  bucket: ""
 
s3_east_coast:
  service: S3
  access_key_id: ""
  secret_access_key: ""
  region: ""
  bucket: ""
 
production:
  service: Mirror
  primary: s3_east_coast
  mirrors:
    - s3_west_coast

ملاحظة: تُخدَّم الملفّات من الخدمة الأساسيّة (primary service).

ملاحظة: هذا غير متوافق مع ميزة التحميلات المباشرة.

إرفاق ملفات بالسجلات

has_one_attached

يُعدّ الماكرو has_one_attached رابطًا مباشرًا (one-to-one mapping) بين السجلّات والملفّات. يمكن أن يُرفق كل سجل بملف واحد فقط.

فلنفترض على سبيل المثال أنّ تطبيقك يحتوي على النموذج User. إن أردت أن يكون لكل مستخدم صورة شخصيّة، عرّف النموذج User كما يلي:

class User < ApplicationRecord
  has_one_attached :avatar
end

يمكنك إنشاء مستخدم مع صورة رمزيّة (avatar):

class SignupController < ApplicationController
  def create
    user = User.create!(user_params)
    session[:user_id] = user.id
    redirect_to root_path
  end
 
  private
    def user_params
      params.require(:user).permit(:email_address, :password, :avatar)
    end
end

استدع avatar.attach لإرفاق صورة شخصيّة لمُستخدم حالي:

Current.user.avatar.attach(params[:avatar])

استدع ?avatar.attached لتحديد ما إن امتلك لمستخدم معيّن صورة شخصية أم لا:

Current.user.avatar.attached?

has_many_attached

يُعدُّ الماكرو has_many_attached علاقة واحد لمتعدد (one-to-many relationship) بين السجلّات والملفّات. يمكن أن يُرفق بكل سجل العديد من الملفّات.

فلنفترض على سبيل المثال أنّ تطبيقك يحتوي على النموذج Message. إن أردت أن تحتوي كل رسالة على العديد من الصور، عرّف النموذج Message كما يلي:

class Message < ApplicationRecord
  has_many_attached :images
end

يمكنك إنشاء رسالة تحتوي على صور:

class MessagesController < ApplicationController
  def create
    message = Message.create!(message_params)
    redirect_to message
  end
 
  private
    def message_params
      params.require(:message).permit(:title, :content, images: [])
    end
end

استدع images.attach لإضافة صور جديدة لرسالة موجودة:

@message.images.attach(params[:images])

استدع ?images.attached لتحديد ما إن امتلكت رسالة محدَّدة أية صورة:

@message.images.attached?

إرفاق ملفات / كائنات IO

تحتاج أحيانًا لإرفاق ملف لا يصل عبر طلب HTTP. قد ترغب على سبيل المثال في إرفاق ملف أنشأته على القرص أو نزّلته من عنوان URL أرسله المستخدم. قد ترغب أيضًا في إرفاق ملف تجريبي (fixture file) في نموذج اختبار. للقيام بذلك، وفّر كائن Hash تحتوي على الأقل على كائن IO مفتوح واسم ملف:

@message.image.attach(io: File.open('/path/to/file'), filename: 'file.pdf')

اكتب عند الإمكان نوع المحتوى أيضًا. يحاول Active Storage تحديد نوع محتوى الملف من بياناته. يعود إلى نوع المحتوى الذي قدّمته إن عجز عن ذلك.

@message.image.attach(io: File.open('/path/to/file'), filename: 'file.pdf', content_type: 'application/pdf')

إن لم تقدّم نوع محتوى ولم يستطع Active Storage من تحديد نوع محتوى الملف تلقائيًا، فسيعيَّن افتراضيًا إلى application/octet-stream.

حذف الملفات

استدع purge على الملفّات المُرفقة لإزالتها من النماذج. يمكن إجراء الإزالة في الخلفيّة إن أُعدّ تطبيقك لاستخدام Active Job. تزيل عملية الحذف (Purging) كائن البيانات الثنائية (The Blob) والملف من خدمة التخزين.

# حذف الصورة الرمزية والملفات المرجعية الفعلية
ser.avatar.purge
 
# حذف النماذج المرتبطة، والملفات المرجعية الفعلية عبر الوظيفة الفعالة
user.avatar.purge_later

إنشاء روابط للملفات

أنشئ عنوان URL دائم لكائن البيانات الثنائية (blob) الذي يشير إلى التطبيق. يُعاد التوجيه عند الوصول إلى نقطة نهاية الخدمة (service endpoint) الفعليّة. تفصل إعادة التوجيه عنوان URL العام عن العنوان الفعلي ويسمح على سبيل المثال بعكس صور المُرفقات في خدمات مختلفة لتكون متوافرة دومًا. تنتهي الصلاحيّة HTTP لإعادة التوجيه بعد 5 دقائق.

url_for(user.avatar)

لإنشاء رابط تنزيل، استخدم rails_blob_{path|url}‎. يتيح لك استخدام هذا المساعد ضبط الترتيب (disposition).

rails_blob_path(user.avatar, disposition: "attachment")

إن إحتجت لإنشاء رابط من خارج سياق المتحكم/العرض (مهام الخلفية، Cronjobs، ...إلخ.) يمكنك الوصول إلى rails_blob_path كما يلي:

Rails.application.routes.url_helpers.rails_blob_path(user.avatar, only_path: true)

تنزيل الملفات

إن إحتجت لمعالجة كائنات البيانات الثنائية (blobs) من جانب الخادم مثل عند إجراء تحليل أو المزيد من التحويلات، يمكنك تنزيلها والحصول على البيانات الثنائية:

binary = user.avatar.download

قد ترغب في بعض الحالات بتحويله إلى ملف فعلي على القرص لتمرير مسار الملف إلى برامج خارجية (مثل برامج مكافحة الفيروسات، أو المحولات، أو المحسّنات ، أو المُصغّرات [minifiers]، ...إلخ). يمكنك في هذه الحالة تضمين الوحدة ActiveStorage::Downloading في صنفك والتي توفّر مساعدين لتنزيل الملفّات مباشرة مع تجنب تخزين الملف في الذاكرة. يتوقع ActiveStorage::Downloading تعريف تابع يدعى blob.

class VirusScanner
  include ActiveStorage::Downloading
 
  attr_reader :blob
 
  def initialize(blob)
    @blob = blob
  end
 
  def scan
    download_blob_to_tempfile do |file|
      system 'scan_virus', file.path
    end
  end
end

يُنشئ download_blob_to_tempfile ملفّات في Dir.tmpdir بشكل افتراضي. إن احتجت لاستخدام مُجلّد آخر، أعد تعريف ActiveStorage::Downloading#tempdir في صنفك:

class VirusScanner
  include ActiveStorage::Downloading
  # ...
 
  private
    def tempdir
      '/path/to/tmp'
    end
end

قد ترغب أيضًا في استخدام chmod على الملف ومجلّده إن شُغّل البرنامج الخارجي كبرنامج منفصل، حيث يتعذّر على المستخدمين الآخرين الوصول إليه لأن Tempfile سيُعيّن الأذونات إلى النمط 0600.

تحويل الصور

لإنشاء شكل مختلف للصورة، استدع variant على كائن البيانات الثنائية (the Bolb). يمكنك تمرير أي تحويل MiniMagick معتمد من قبل التابع.

أضف mini_magick إلى ملفك Gemfile لتفعيل المتغيرات:

gem 'mini_magick'

عندما يضغط المتصفح على العنوان URL البديل، يُحوّل Active Storage كائن البيانات الثنائي الأصلي إلى الصيغة (format) المحدّدة ويعيد توجيهه إلى موقع خدمته الجديد.

<%= image_tag user.avatar.variant(resize: "100x100") %>

معاينة الملفات (Previewing Files)

يمكن معاينة بعض الملفّات غير الصُوريّة: أي يمكن تقديمها كصور. يمكن على سبيل المثال معاينة ملف فيديو عن طريق استخراج أول إطار له. يدعم Active Storage معاينة مقاطع الفيديو ومستندات PDF مباشرة.

<ul>
  <% @message.files.each do |file| %>
    <li>
      <%= image_tag file.preview(resize: "100x100>") %>
    </li>
  <% end %>
</ul>

تحذير: يتطلّب استخراج المعاينات تطبيقات تابعة لجهات خارجية و ffmpeg للفيديو و mutool بالنسبة الى PDF. لا يُوفّر ريلز هذه المكتبات. عليك تثبيتها بنفسك لاستخدام المُعايِنات المضمّنة (built-in previewers). احرض على فهمك للتأثيرات المترتبة على ترخيص (licensing) تطبيقك قبل تثبيت برامج الجهات الخارجية واستخدامها.

التحميل المباشر

يدعم Active Storage، مع مكتبة JavaScript المضمنة ضمنه، التحميل مباشرة من العميل إلى السحابة.

تثبيت التحميل المباشر

1. ضمّن activestorage.js في حزمة JavaScript تطبيقك.

  • باستخدام خط أنابيب الأصول (asset pipeline):
//= require activestorage
  • باستخدام حزمة npm:
import * as ActiveStorage from "activestorage"
ActiveStorage.start()

2. علّق على مُدخلات الملف باستخدام عنوان URL للتحميل المباشر.

<%= form.file_field :attachments, multiple: true, direct_upload: true %>

3. انتهت العمليّة! تبدأ عمليّات التحميل عند إرسال الإستمارات (form).

أحداث تحميل JavaScript المباشرة

اسم الحدث هدف الحدث بيانات الحدث (event.detail) وصف
direct-uploads:start <form> None أُرسلت استمارة تحتوي على ملفّات لحقول التحميل المباشر.
direct-upload:initialize <input> {id, file} يُطلق لكل ملف بعد إرسال الاستمارة.
direct-upload:start <input> {id, file} بدءَ تحميل مباشر.
direct-upload:before-blob-request <input> {id, file, xhr} قبل تقديم طلب إلى تطبيقك للبيانات الوصفية للتحميل المباشر.
direct-upload:before-storage-request <input> {id, file, xhr} قبل تقديم طلب لتخزين ملف.
direct-upload:progress <input> {id, file, progress} بينما تتقدّم طلبات تخزين الملفّات.
direct-upload:error <input> {id, file, error} حدث خطأ. سيُعرض خطأ ما لم يُلغ هذا الحدث.
direct-upload:end <input> {id, file} انتهت عملية تحميل مباشر.
direct-uploads:end <form> None انتهت جميع التحميلات المباشرة.

مثال

يمكنك استخدام هذه الأحداث لإظهار تقدّم عملية التحميل.

إظهار تقدم عملية رفع ملفات عبر أحداث JavaScript.
إظهار تقدم عملية رفع ملفات عبر أحداث JavaScript.

لإظهار الملفّات المُحمّلة في استمارة:

// direct_uploads.js
 
addEventListener("direct-upload:initialize", event => {
  const { target, detail } = event
  const { id, file } = detail
  target.insertAdjacentHTML("beforebegin", `
    <div id="direct-upload-${id}" class="direct-upload direct-upload--pending">
      <div id="direct-upload-progress-${id}" class="direct-upload__progress" style="width: 0%"></div>
      <span class="direct-upload__filename">${file.name}</span>
    </div>
  `)
})
 
addEventListener("direct-upload:start", event => {
  const { id } = event.detail
  const element = document.getElementById(`direct-upload-${id}`)
  element.classList.remove("direct-upload--pending")
})
 
addEventListener("direct-upload:progress", event => {
  const { id, progress } = event.detail
  const progressElement = document.getElementById(`direct-upload-progress-${id}`)
  progressElement.style.width = `${progress}%`
})
 
addEventListener("direct-upload:error", event => {
  event.preventDefault()
  const { id, error } = event.detail
  const element = document.getElementById(`direct-upload-${id}`)
  element.classList.add("direct-upload--error")
  element.setAttribute("title", error)
})
 
addEventListener("direct-upload:end", event => {
  const { id } = event.detail
  const element = document.getElementById(`direct-upload-${id}`)
  element.classList.add("direct-upload--complete")
})

إضافة التنسيقات (styles):

/* direct_uploads.css */
 
.direct-upload {
  display: inline-block;
  position: relative;
  padding: 2px 4px;
  margin: 0 3px 3px 0;
  border: 1px solid rgba(0, 0, 0, 0.3);
  border-radius: 3px;
  font-size: 11px;
  line-height: 13px;
}
 
.direct-upload--pending {
  opacity: 0.6;
}
 
.direct-upload__progress {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  opacity: 0.2;
  background: #0076ff;
  transition: width 120ms ease-out, opacity 60ms 60ms ease-in;
  transform: translate3d(0, 0, 0);
}
 
.direct-upload--complete .direct-upload__progress {
  opacity: 0.4;
}
 
.direct-upload--error {
  border-color: red;
}
 
input[type=file][data-direct-upload-url][disabled] {
  display: none;
}

الدمج مع مكتبات أو أطر عمل

إن رغبت في استخدام ميزة "التحميل المباشر" (Direct Upload) من إطار عمل JavaScript أو إن أردت حلولًا أخرى مثل السحب والإفلات (drag and drop)، تستطيع استخدام الصنف DirectUpload لهذا الغرض. عند استلام ملف من مكتبتك المختارة، أنسخ (instantiate) نسخة DirectUpload واستدع تابعها create. يطلب create رد نداء يستدعى (invoke) عند اكتمال التحميل.

import { DirectUpload } from "activestorage"
 
const input = document.querySelector('input[type=file]')
// على عنصر أب أو  onDrop استخدم file drop أربط ب
//  Dropzone استخدم مكتبة مثل
const onDrop = (event) => {
  event.preventDefault()
  const files = event.dataTransfer.files;
  Array.from(files).forEach(file => uploadFile(file))
}
 
// أربط بعملية انتقاء ملف عاديّة
input.addEventListener('change', (event) => {
  Array.from(input.files).forEach(file => uploadFile(file))
  // تستطيع تفريغ الملفات المنتقاة من المُدخلات
  input.value = null
})
 
const uploadFile = (file) {
  // والذي file_field direct_upload: true تحتاج استمارتك
  //  data-direct-upload-url يوفّر
  const url = input.dataset.directUploadUrl
  const upload = new DirectUpload(file, url)
 
  upload.create((error, blob) => {
    if (error) {
      // عالج الخطأ
    } else {
      // أضف مُدخلًا مخفيًّا ذو اسم مناسب للاستمارة مع
      // (blob) النقط ids كي تُبث blob.signed_id قيمة
      //  مع تيّار التحميل العادي
      const hiddenField = document.createElement('input')
      hiddenField.setAttribute("type", "hidden");
      hiddenField.setAttribute("value", blob.signed_id);
      hiddenField.name = input.name
      document.querySelector('form').appendChild(hiddenField)
    }
  })
}

تستطيع إن إحتجت لتتبع تقدّم عمليّة تحميل الملف تمرير معامل (parameter) ثالث إلى الدالّة DirectUpload البانية (constructor). ستستدعي DirectUpload تابع الكائن directUploadWillStoreFileWithXHR أثناء التحميل. تستطيع بعد ذلك ربط معالج تقدّمك على XHR.

import { DirectUpload } from "activestorage"
 
class Uploader {
  constructor(file, url) {
    this.upload = new DirectUpload(this.file, this.url, this)
  }
 
  upload(file) {
    this.upload.create((error, blob) => {
      if (error) {
        // عالج الخطأ
      } else {
        // أضف حقل إدخال مخفي باسم مناسب إلى النموذج
        // blob.signed_id مع تعيين قيمته إلى
      }
    })
  }
 
  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener("progress",
      event => this.directUploadDidProgress(event))
  }
 
  directUploadDidProgress(event) {
    // لتحديث شريط التقدم event.loaded و event.total استعمل
  }
}

التخلص من الملفات المخزنة خلال اختبارات النظام

تنظّف اختبارات النظام بيانات الاختبار عبر تنفيذ عملية تراجع عكسية (rolling back). ولكن بما أن destroy لم يستدعَ قط، لا تُنظّف الملفّات المُرفقة. إن رغبت بمسح الملفّات، يمكنك ذلك في رد النداء after_teardown. ويضمن فعل هذا هنا التأكد من اكتمال كل الاتصالات المُنشئة أثناء الاختبار وعدم تلقي رسالة خطأ من Active Storage تفيد عدم إمكانية العثور على ملف.

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
 
  def remove_uploaded_files
    FileUtils.rm_rf("#{Rails.root}/storage_test")
  end
 
  def after_teardown
    super
    remove_uploaded_files
  end
end

إن تحقّقت اختبارات نظامك من حذف نموذج يحتوي مُرفقات وكنت تستخدم Active Job، اضبط بيئة اختبارك كي تستخدم محوّل قائمة الانتظار المضمنة (inline queue adapter) بحيث تُنفّذ مهمة التنظيف مباشرةً بدلًا من وقت غير معروف في المستقبل. قد ترغب أيضًا في استخدام تعريف خدمة منفصل لبيئة الاختبار كي لا تَحذف الاختبارات الملفّات التي تُنشئها أثناء التطوير.

# لتنفيذ أشياء مباشرةً (inline) استعمل وظيفة معالجة ضمنية
config.active_job.queue_adapter = :inline
 
# افصل ملف التخزين في بيئة الاختبار
config.active_storage.service = :local_test

تعريف استخدام الدعم لخدمات سحابية أخرى

ستحتاج لتعريف استخدام الخدمة إن إحتجت إلى دعم خدمة سحابية غير الخدمات المذكورة في هذا الدليل. توسع كل خدمة ActiveStorage::Service من خلال تعريف استخدام التوابع الضروريّة لتحميل وتنزيل الملفّات من وإلى السحابة.

مصادر