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.
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
.
- Images belonging to a user should go in that userās folder. Ex:
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