Cloud Storage Afbeeldingen Laden in een Flutter Web App

by Toine Heuvelmans

June 7th, 2021

Eerder dit jaar bracht Flutter web-ondersteuning uit, waardoor ontwikkelaars met één dezelfde codebase hun mobiele applicaties tevens op het web (oh, en tegelijkertijd ook op desktop) kunnen publiceren. Wij werken momenteel aan het perfectioneren van de web-ervaring van een van onze Flutter apps. De app toont een lijst artikelen die opgehaald wordt uit Firebase. De artikelcodes zijn afkomstig uit Firebase Firestore, de afbeeldingen worden beschikbaar gesteld via Firebase Cloud Storage. Op mobiel werkt dit allemaal perfect, maar met de webversie liepen we tegen een aantal hordes aan. In deze blogpost lopen we door deze punten heen en bespreken we de oplossingen die we ervoor vonden.

Cross-Origin Resource Sharing

Het eerste en meest overduidelijke probleem was dat de afbeeldingen niet laadden. De Developer Tools in Chrome toonden deze foutmelding: 

Access to XMLHttpRequest at ''https://firebasestorage.googleapis.com/…F314419080.jpg'' from origin ''[…]'' has been blocked by CORS policy: No ''Access-Control-Allow-Origin'' header is present on the requested resource.

Afbeeldingen en andere bronbestanden worden in een Flutter web app asynchroon geladen middels Ajax requests (zoals onder andere XMLHttpRequest) en zijn zodoende onderhevig aan de Same-origin security policy die in de meeste moderne browsers is geïmplementeerd, en voorkomt dat externe scripts de bronbestanden van een andere website zonder toestemming kunnen inladen. Als een opgevraagd bronbestand niet dezelfde origin heeft (simpel gezegd: de domeinnaam) als de pagina die het bestand opvraagt, treedt een mechanisme in werk genaamd Cross-Origin Resource Sharing (CORS) om te verifiëren of het bronbestand geladen mag worden.

Een simpel CORS request en response
Een afbeelding van een kat, afkomstig van een ander domein, wordt opgevraagd door touchwonders.com. CORS voor de afbeelding staat ieder domein (*) toe om de afbeelding te laden.

In de meest simpele vorm bestaat CORS uit een request-bericht dat de Origin header bevat, waarop een antwoord wordt verwacht met daarin de header Access-Control-Allow-Origin die aangeeft welke domeinen toegestaan zijn. Voor “eenvoudige requests” vindt deze uitwisseling van headers plaats tijdens het opvragen van het bronbestand zelf, maar in de meeste gevallen zal een CORS preflight request worden gemaakt, omdat het daadwerkelijke request voor het bronbestand mogelijkerwijs effecten kan hebben op de server.
Een preflight request maakt gebruik van de OPTIONS methode en specifieke request headers om te verifiëren of de methode en headers in het daadwerkelijke request gebruikt mogen worden. De response moet headers bevatten die aangeven welke methodes en headers zijn toegestaan, en eventueel ook voor hoelang deze informatie geldig geacht kan worden alvorens een nieuw preflight request gemaakt moet worden. Enkel wanneer de browser de juiste headers en waardes daarin ontvangt zal het daadwerkelijke request voor het bronbestand worden uitgevoerd.

Cloud Storage biedt documentatie over het configureren van CORS op een storage bucket. Hun voorbeeld toont hoe enkel GET requests kunnen worden toegestaan, weliswaar ongeacht vanaf welk domein. De waarde van maxAgeSeconds geeft aan dat browsers de request een uur lang kunnen herhalen (3600 seconden) alvorens een nieuw preflight request benodigd is:

[
        {
          "origin": ["*"],
          "method": ["GET"],
          "maxAgeSeconds": 3600
        }
      ]
      

Deze configuratie kan, wanneer opgeslagen in een JSON bestand, geconfigureerd worden op een bucket met gsutil:

gsutil cors set cors.json gs://<your-cloud-storage-bucket>

Kijkend naar de requests die onze Flutter web app maakt zien we nu een drietal requests gemaakt worden per artikel-afbeelding, allemaal met een bevredigende status 200 (OK):

  1. Het CORS preflight request
  2. Het request voor de download URL van de afbeelding
  3. Het daadwerkelijke downloaden van de afbeelding
Drie requests: CORS preflight, download URL en afbeelding

Drie requests?! Jazeker. Hoewel we dit nog kunnen verbeteren (blijf lezen), had dit ook 4 requests kunnen zijn.
Onze CORS configuratie gebruikt dezelfde waarde voor maxAgeSeconds van 3600 seconden zoals ook in het voorbeeld te zien was, waardoor de browser het CORS preflight request een uur lang niet opnieuw hoeft uit te voeren. Het request voor de download URL van de afbeelding en het request voor het downloaden van de afbeelding zelf verschillen enkel in query parameters (alt=media&token=…) die in het download-request worden bijgevoegd, wat voor het CORS mechanisme neerkomt op hetzelfde bronbestand en zodoende geen nieuw preflight request vereist.

De Cloud Storage Download URL Cachen

Het moeten opvragen van de download URL voor een bronbestand is het standaard mechanisme voor het op een veilige manier interacteren met Cloud Storage; security rules kunnen zo worden ingezet om gedetailleerde controle te hebben over wie wat mag downloaden. Bovendien heeft ieder bestand een eigen toegangssleutel waardoor de download URL (waarin de sleutel verwerkt zit) een stuk moeilijker te raden is.

Dit mechanisme biedt ook de mogelijkheid om de toegangssleutel ongeldig te verklaren waardoor toegang via een eerder opgehaalde download URL ingetrokken wordt. In gevallen waar je je bestanden nog beter wil beschermen tegen ongeoorloofde toegang is er ook de mogelijkheid om een beperkt houdbare gesigneerde URL op te vragen die na een bepaalde tijd verloopt. Als je dit echter allemaal niet nodig hebt en ook niet de intentie hebt om ooit de toegangssleutel ongeldig te verklaren, is er geen enkele reden waarom je de download URL niet zou kunnen opslaan voor later hergebruik. Wanneer je je app dan weer gebruikt kan dit de hoeveelheid benodigde requests aanzienlijk verminderen.
Wij maakten gebruik van Hive, een Dart package dat een lichtgewicht NoSQL-gebaseerde key-value store biedt en – belangrijk – ook geschikt is voor web. Deze aanpak zorgt er feitelijk voor dat de download URL wordt opgeslagen in plaats van deze te cachen; er zijn geen regels of mechanismen van kracht die ervoor zorgen dat de opgeslagen download URL op een gegeven moment verloopt of wordt verwijderd. Voor onze app is dit prima, maar als je dergelijke mechanismen wel nodig hebt dan zou je eens kunnen kijken naar een package als Stash.

Kortom, een request minder per artikel.

De Afbeelding Cachen

Resterend zijn het CORS preflight request dat op zijn vaakst ieder uur wordt uitgevoerd, en het downloaden van de afbeelding, wat op dit punt nog iedere keer gebeurt wanneer de afbeelding getoond moet worden. Ik hoor je vragen: was dit dan niet al een probleem op mobiel?
Nee, want daar gebruiken we een eigen implementatie van een caching mechanisme dat middels het path_provider package een tijdelijke folder kan opvragen om daarin de afbeeldingen op te slaan. Dit package is echter niet ondersteund op web en zodoende worden afbeeldingen niet expliciet opgeslagen na het downloaden. Maar zorgen browsers er dan niet al voor dat afbeeldingen worden gecached? Ja, tenzij je de browser aangeeft dat niet te doen.
Er zijn meerdere manieren waarop caching wordt gestuurd en gevalideerd, waarvan de belangrijkste de Cache-Control response header is. Deze kan worden gebruikt om aan te geven of en waar een response kan worden gecached, voor hoe lang deze “vers” blijft en wanneer er moet worden gevalideerd of de gecachte response nog te gebruiken is. Deze header is gezet op Cloud Storage responses voor downloads, met een standaardwaarde van private, max-age=0. Dit houdt in dat het bestand iedere keer moet worden gevalideerd wanneer het opgevraagd wordt.
Hier komt de Expires header om de hoek kijken: het bestand is “vers” zolang de Date in het response bericht (de datum en tijd waarop de response wordt gegeven) niet  later is dan de datum in de Expires header. Cloud Storage zet de waarde voor de Expires header op de waarde van de response Date plus de max-age uit Cache-Control; met een waarde van 0 houdt dit in dat het bestand altijd opnieuw gedownload dient te worden. 

Gelukkig helpt gsutil ons ook hier uit de brand want het kan namelijk ook ingezet worden om metadata te zetten op Cloud Storage bestanden. Dit kan gedaan worden op individuele bestanden, bestanden die aan een bepaald patroon voldoen, of alle bestanden in de opgegeven bucket. Om de Cache-Control header op alle bestanden in je bucket te zetten:

gsutil -m setmeta -r -h "Cache-Control:private, max-age=86400" gs://<your-cloud-storage-bucket>
  • Het -h argument kan worden gebruikt om een enkele header met waarde op te geven
  • Het -r argument stelt je in staat om enkel de bucket op te geven, niet een naam of patroon
  • Het -m argument (bij gsutil) zal setmeta in parallel uitvoeren waardoor het geheel sneller klaar is

Nu zullen de afbeeldingen gecached worden en zullen – zolang een CORS preflight niet benodigd is en de afbeeldingen “vers” zijn – er geen requests meer gedaan worden. Althans voor een dag.