Home
> Firebase Storage User Profile Image
Get updates on future FREE course and blog posts!
Subscribe

Firebase Storage User Profile Image

13 min read

Jonathan Gamble

jdgamble555 on Sunday, June 23, 2024 (last modified on Sunday, June 23, 2024)

Many database platforms handle authentication with the database functions, but not many have built-in storage capabilities. Firebase Storage allows you to securely and easily upload files in buckets, similar to folders.

Firebase Storage Profile Image

TL;DR#

We use Svelte to update a user’s photoURL in the Firebase Authentication database. The image is stored in Firebase Storage, the old image is deleted automatically, and only image types are allowable.

Svelte Example#

If you read many articles on this site, you should know now that I use Svelte for easy prototyping. This app is no exception. However, the concept is the same in any framework.

Edit Image Component#

I use a custom image hook that returns a status and a files writable. In other frameworks, this would use signals instead of stores. You may also use a form and submit this to the backend.

	<script lang="ts">
    import { useUploadImage } from '$lib/use-upload-image';
    import Card from '@components/elements/card.svelte';

    const { status, files } = useUploadImage();
</script>

<div class="my-5 flex items-center justify-center">
    <div class="w-3/4 max-w-3xl">
        <Card class="flex flex-col gap-5">
            <h1 class="text-3xl font-bold">Upload Image</h1>
            <input type="file" bind:files={$files} accept="image/*" />
            {#if $status.progress}
                <p>Percent: {$status.progress.toString()}</p>
            {/if}
            {#if $status.error}
                <p class="text-red-600">
                    <span class="font-bold">Error: </span>
                    {$status.error}
                </p>
            {/if}
        </Card>
    </div>
</div>

Image Type#

You can tell an HTML Input Element what you want to accept. For any image type:

	<input type="file" bind:files={$files} accept="image/*" />

For specific MIME types:

	<input type="file" accept="image/png, image/webp, image/jpeg, image/jpg, image/gif" />

Or by extension:

	<input type="file" accept=".png, .webp, .jpg, .jpeg, .gif">

We will need to secure this later in Firebase Storage Rules.

Upload Image Hook#

The hook looks complicated.

	import { auth, storage } from "./firebase";
import {
    derived,
    writable,
    type Writable
} from "svelte/store";
import {
    deleteObject,
    getDownloadURL,
    ref,
    uploadBytesResumable,
    type TaskState
} from "firebase/storage";
import { updateProfile } from "firebase/auth";

type UploadState = {
    progress: number | null;
    state: TaskState | null;
    error: string | null;
    downloadURL: string | null;
}

export const useUploadImage = () => {

    const files = writable<FileList>();

    const status = derived<
        Writable<FileList>,
        UploadState
    >(files, ($files, set) => {

        if (!$files) {
            set({
                progress: null,
                state: null,
                error: null,
                downloadURL: null
            });
            return;
        }

        if (!auth.currentUser) {
            throw 'Not logged in!';
        }

        const uid = auth.currentUser.uid;

        const new_file = $files[0];

        if (new_file.size >= 1 * 1024 * 1024) {
            set({
                progress: null,
                state: null,
                error: 'Image size must be less than 1MB!',
                downloadURL: null
            })
            return;
        }

        const uploadTask = uploadBytesResumable(
            ref(storage, `profiles/${uid}/${new_file.name}`),
            new_file
        );

        uploadTask.on('state_changed',
            (snapshot) => {

                // handle upload progress
                const progress = (
                    snapshot.bytesTransferred / snapshot.totalBytes
                ) * 100;
                set({
                    progress,
                    state: snapshot.state,
                    error: null,
                    downloadURL: null
                });

            },
            (error) => {

                // error handling
                set({
                    progress: null,
                    state: 'error',
                    error: error.message,
                    downloadURL: null
                })
            },
            () => {

                // success, get download URL
                getDownloadURL(uploadTask.snapshot.ref)
                    .then((downloadURL) => {

                        // delete current image
                        deleteImage().then(() => {
                            if (!auth.currentUser) {
                                throw 'No User!';
                            }
                            // update profile with new image
                            updateProfile(auth.currentUser, {
                                displayName: auth.currentUser.displayName,
                                photoURL: downloadURL
                            }).then(() => {

                                // set progress to 100%
                                set({
                                    progress: 100,
                                    state: 'success',
                                    error: null,
                                    downloadURL
                                });
                            });
                        })

                    });
            }
        );

    });

    return {
        files,
        status
    };
};

File Type#

With an input file element, you can keep track of the file being uploaded with a FileList type. Since we are only uploading one file, the file will be the first in the array.

	const new_file = $files[0];

We can limit the file size to 1MB for our client. This should be matched on the backend as well.

	new_file.size >= 1 * 1024 * 1024

Upload Part#

Let’s break down the upload part first.

	const uploadTask = uploadBytesResumable(
    ref(storage, `profiles/${uid}/${new_file.name}`),
    new_file
);

We upload to the profiles/{userId}/{filename} folder here. The file name can be anything as long as it is in the user’s folder.

Progress#

We can keep track of the upload in real-time using uploadTask.on.

	uploadTask.on('state_changed',
    (snapshot) => {

        // handle upload progress
        const progress = (
            snapshot.bytesTransferred / snapshot.totalBytes
        ) * 100;
        // handle progress

    },
    (error) => {

        // error handling
        
    },
    () => {

        // success, get download URL
        getDownloadURL(uploadTask.snapshot.ref)
            .then((downloadURL) => {

                // handle download URL
                // set progress to 100%

            });
    }
);

We can translate this to an observable, a signal, or a store (in the case of Svelte).

Delete Image#

In some cases, we may want to allow the user to have only one profile image at a time. Here, we remove the old profile image.

	deleteObject(
    ref(storage, photoURL)
);

Update the User Profile#

We also need to update the user’s profile once we receive an image, upload a new image, or delete an image. The linked article explains this more fully.

Storage Rules#

Of course, we should secure our app on the backend as well.

	rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
      match /profiles/{userId}/{image} {
        allow read;
      allow create: if isLoggedIn() 
          && isOwner()
        && isImageType()
        && oneMBLimit();
      allow delete: if isLoggedIn()
        && isOwner();
    }
  }
  
  function isOwner() {
      return request.path[4] == request.auth.uid;
  }
  
  function isLoggedIn() {
      return request.auth != null;
    }
  
  function isImageType() {
      return request.resource.contentType.matches('image/.*');
    }
  
  function oneMBLimit() {
    return request.resource.size < 1 * 1024 * 1024;
  }
  
}

Our path will be the storage file path instead of the collection path. For a more in-depth discussion of rules, see the Firestore Security Rules Example Guide. You may need some advanced rules. See Security Rules for Cloud Storage Reference.

Image Best Practices#

  • If your images could be used in multiple places, you may not want to ever delete them. Images are cheap to store and are hosted on a CDN.
  • However, if an image could be an orphan (with no links to it), it is best practice to delete it automatically to save yourself in the long run.
  • Your image should have good keywords in the file name for best SEO practices. I don’t do that here, but it is good to know.
  • ⚠️ Always use a new name when replacing an image. Images are cached by default, and it is extremely hard to reset the browser, server, and CDN caches when you update an image.
    • Images belonging to a user should go in that user’s folder. Ex: profiles/{userId}
    • You could generate a random UUID for every image or use a date timestamp.
    • You could keep the keywords with the random string. Ex: profiles/{userId}/firebase-functions-image-x2030s0eksl.jpg.

Firebase Function Storage Triggers#

There are Cloud Storage Trigger Functions that allow you to handle different events. I will only cover Generation 2, which you should use for speed and security.

  • onObjectArchived - Used when versioning is enabled to archive old versions of files.
  • onObjectDeleted - Used when a file is deleted.
  • onObjectFinalized - Used when a file has finished uploading, even when replacing old files.
  • onMetadataUpdated - Used when metadata changes.

Usage#

Relating to this app, you could use a storage trigger function to guarantee that the old user profile image is deleted or that the photoURL is updated.

  • ⚠️ CAVEAT: When the user profile image is updated on the server (in a server function or Cloud Function), it will not get updated until the active user logs out and back in. You could redundantly update it, but in a real app you would probably use Cloud Firestore to store the profile image. See Update a User Profile in Firebase.

In a production app, the best bet is to update Cloud Firestore inside the trigger function with the new download URL.

Demo: Vercel Serverless

Repo: GitHub

J


Related Posts

© 2024 Code.Build