diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index a65c0cb..5cd2089 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -22,6 +22,7 @@ version = "1.0.0"
 "general:action.report" = "Report"
 "general:action.manage" = "Manage"
 "general:action.open" = "Open"
+"general:action.view" = "View"
 "general:action.copy_link" = "Copy link"
 "general:label.safety" = "Safety"
 "general:label.share" = "Share"
@@ -147,6 +148,7 @@ version = "1.0.0"
 "settings:tab.security" = "Security"
 "settings:tab.blocks" = "Blocks"
 "settings:tab.billing" = "Billing"
+"settings:tab.uploads" = "Uploads"
 
 "mod_panel:label.open_reported_content" = "Open reported content"
 "mod_panel:label.manage_profile" = "Manage profile"
diff --git a/crates/app/src/public/html/communities/create_post.html b/crates/app/src/public/html/communities/create_post.html
index 93cb7d9..c19f3c3 100644
--- a/crates/app/src/public/html/communities/create_post.html
+++ b/crates/app/src/public/html/communities/create_post.html
@@ -97,8 +97,10 @@
                     // create body
                     const body = new FormData();
 
-                    for (const file of e.target.file_picker.files) {
-                        body.append(file.name, file);
+                    if (e.target.file_picker) {
+                        for (const file of e.target.file_picker.files) {
+                            body.append(file.name, file);
+                        }
                     }
 
                     body.append(
diff --git a/crates/app/src/public/html/profile/settings.html b/crates/app/src/public/html/profile/settings.html
index 62e9c48..e01cc3e 100644
--- a/crates/app/src/public/html/profile/settings.html
+++ b/crates/app/src/public/html/profile/settings.html
@@ -49,6 +49,11 @@
                     <span>{{ text "settings:tab.blocks" }}</span>
                 </a>
 
+                <a data-tab-button="account/uploads" href="#/account/uploads">
+                    {{ icon "image-up" }}
+                    <span>{{ text "settings:tab.uploads" }}</span>
+                </a>
+
                 {% if config.stripe %}
                 <a data-tab-button="account/billing" href="#/account/billing">
                     {{ icon "credit-card" }}
@@ -391,6 +396,86 @@
         </div>
     </div>
 
+    <div class="w-full flex flex-col gap-2 hidden" data-tab="account/uploads">
+        <div class="card tertiary flex flex-col gap-2">
+            <a href="#/account" class="button secondary">
+                {{ icon "arrow-left" }}
+                <span>{{ text "general:action.back" }}</span>
+            </a>
+
+            <div class="card-nest">
+                <div class="card flex items-center gap-2 small">
+                    {{ icon "image-up" }}
+                    <span>{{ text "settings:tab.uploads" }}</span>
+                </div>
+
+                <div class="card flex flex-col gap-2 secondary">
+                    {{ components::supporter_ad(body="Become a supporter to
+                    upload images directly to posts!") }} {% for upload in
+                    uploads %}
+                    <div
+                        class="card flex flex-wrap gap-2 items-center justify-between"
+                    >
+                        <div
+                            class="flex gap-2 items-center"
+                            onclick="trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])"
+                            style="cursor: pointer"
+                        >
+                            {{ icon "file-image" }}
+                            <b
+                                ><span class="date">{{ upload.created }}</span>
+                                ({{ upload.what }})</b
+                            >
+                        </div>
+
+                        <div class="flex gap-2">
+                            <button
+                                class="quaternary small"
+                                onclick="trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])"
+                            >
+                                {{ icon "view" }}
+                                <span>{{ text "general:action.view" }}</span>
+                            </button>
+
+                            <button
+                                class="quaternary small red"
+                                onclick="remove_upload('{{ upload.id }}')"
+                            >
+                                {{ icon "x" }}
+                                <span>{{ text "stacks:label.remove" }}</span>
+                            </button>
+                        </div>
+                    </div>
+                    {% endfor %} {{ components::pagination(page=page,
+                    items=uploads|length, key="#/account/uploads") }}
+
+                    <script>
+                        globalThis.remove_upload = async (id) => {
+                            if (
+                                !(await trigger("atto::confirm", [
+                                    "Are you sure you would like to do this? This action is permanent.",
+                                ]))
+                            ) {
+                                return;
+                            }
+
+                            fetch(`/api/v1/uploads/${id}`, {
+                                method: "DELETE",
+                            })
+                                .then((res) => res.json())
+                                .then((res) => {
+                                    trigger("atto::toast", [
+                                        res.ok ? "success" : "error",
+                                        res.message,
+                                    ]);
+                                });
+                        };
+                    </script>
+                </div>
+            </div>
+        </div>
+    </div>
+
     <div class="w-full flex flex-col gap-2 hidden" data-tab="account/billing">
         <div class="card tertiary flex flex-col gap-2">
             <a href="#/account" class="button secondary">
diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs
index dc5fdbb..67a5e5a 100644
--- a/crates/app/src/routes/api/v1/auth/images.rs
+++ b/crates/app/src/routes/api/v1/auth/images.rs
@@ -164,8 +164,8 @@ pub async fn banner_request(
     ))
 }
 
-pub static MAXIUMUM_FILE_SIZE: usize = 8388608;
-pub static MAXIUMUM_GIF_FILE_SIZE: usize = 2097152;
+pub static MAXIMUM_FILE_SIZE: usize = 8388608;
+pub static MAXIMUM_GIF_FILE_SIZE: usize = 2097152;
 
 /// Upload avatar
 pub async fn upload_avatar_request(
@@ -223,7 +223,7 @@ pub async fn upload_avatar_request(
     // upload image (gif)
     if mime == "image/gif" {
         // gif image, don't encode
-        if img.0.len() > MAXIUMUM_GIF_FILE_SIZE {
+        if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
             return Json(Error::DataTooLong("gif".to_string()).into());
         }
 
@@ -236,7 +236,7 @@ pub async fn upload_avatar_request(
     }
 
     // check file size
-    if img.0.len() > MAXIUMUM_FILE_SIZE {
+    if img.0.len() > MAXIMUM_FILE_SIZE {
         return Json(Error::DataTooLong("image".to_string()).into());
     }
 
@@ -321,7 +321,7 @@ pub async fn upload_banner_request(
     // upload image (gif)
     if mime == "image/gif" {
         // gif image, don't encode
-        if img.0.len() > MAXIUMUM_GIF_FILE_SIZE {
+        if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
             return Json(Error::DataTooLong("gif".to_string()).into());
         }
 
@@ -334,7 +334,7 @@ pub async fn upload_banner_request(
     }
 
     // check file size
-    if img.0.len() > MAXIUMUM_FILE_SIZE {
+    if img.0.len() > MAXIMUM_FILE_SIZE {
         return Json(Error::DataTooLong("image".to_string()).into());
     }
 
diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs
index 6e31d86..2676d6c 100644
--- a/crates/app/src/routes/api/v1/communities/emojis.rs
+++ b/crates/app/src/routes/api/v1/communities/emojis.rs
@@ -81,7 +81,7 @@ pub async fn get_request(
 }
 
 // maximum file dimensions: 512x512px (256KiB)
-pub const MAXIUMUM_FILE_SIZE: usize = 262144;
+pub const MAXIMUM_FILE_SIZE: usize = 262144;
 
 pub async fn create_request(
     jar: CookieJar,
@@ -96,7 +96,7 @@ pub async fn create_request(
     };
 
     // check file size
-    if img.0.len() > MAXIUMUM_FILE_SIZE {
+    if img.0.len() > MAXIMUM_FILE_SIZE {
         return Json(Error::DataTooLong("image".to_string()).into());
     }
 
diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs
index 4a7a2c1..a49712f 100644
--- a/crates/app/src/routes/api/v1/communities/images.rs
+++ b/crates/app/src/routes/api/v1/communities/images.rs
@@ -8,7 +8,7 @@ use crate::{
     State,
     image::{Image, save_buffer},
     get_user_from_token,
-    routes::api::v1::auth::images::{MAXIUMUM_FILE_SIZE, read_image},
+    routes::api::v1::auth::images::{MAXIMUM_FILE_SIZE, read_image},
 };
 
 /// Get a community's avatar image
@@ -135,7 +135,7 @@ pub async fn upload_avatar_request(
     );
 
     // check file size
-    if img.0.len() > MAXIUMUM_FILE_SIZE {
+    if img.0.len() > MAXIMUM_FILE_SIZE {
         return Json(Error::DataTooLong("image".to_string()).into());
     }
 
@@ -190,7 +190,7 @@ pub async fn upload_banner_request(
     );
 
     // check file size
-    if img.0.len() > MAXIUMUM_FILE_SIZE {
+    if img.0.len() > MAXIMUM_FILE_SIZE {
         return Json(Error::DataTooLong("image".to_string()).into());
     }
 
diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs
index bae8b89..8f565bc 100644
--- a/crates/app/src/routes/api/v1/communities/posts.rs
+++ b/crates/app/src/routes/api/v1/communities/posts.rs
@@ -1,8 +1,6 @@
-use std::fs::exists;
-use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
+use axum::{extract::Path, response::IntoResponse, Extension, Json};
 use axum_extra::extract::CookieJar;
 use image::ImageFormat;
-use pathbufd::PathBufD;
 use tetratto_core::model::{
     communities::Post,
     permissions::FinePermission,
@@ -12,14 +10,12 @@ use tetratto_core::model::{
 use crate::{
     get_user_from_token,
     image::{save_buffer, JsonMultipart},
-    routes::api::v1::{
-        auth::images::read_image, CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext,
-    },
+    routes::api::v1::{CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext},
     State,
 };
 
 // maximum file dimensions: 2048x2048px (4 MiB)
-pub const MAXIUMUM_FILE_SIZE: usize = 4194304;
+pub const MAXIMUM_FILE_SIZE: usize = 4194304;
 
 pub async fn create_request(
     jar: CookieJar,
@@ -74,7 +70,7 @@ pub async fn create_request(
 
     // check sizes
     for img in &images {
-        if img.len() > MAXIUMUM_FILE_SIZE {
+        if img.len() > MAXIMUM_FILE_SIZE {
             return Json(Error::DataTooLong("image".to_string()).into());
         }
     }
@@ -166,32 +162,6 @@ pub async fn create_repost_request(
     }
 }
 
-pub async fn get_upload_request(
-    Path(id): Path<usize>,
-    Extension(data): Extension<State>,
-) -> impl IntoResponse {
-    let data = &(data.read().await).0;
-
-    let upload = data.get_upload_by_id(id).await.unwrap();
-    let path = upload.path(&data.0);
-
-    if !exists(&path).unwrap() {
-        return Err((
-            [("Content-Type", "image/svg+xml")],
-            Body::from(read_image(PathBufD::current().extend(&[
-                data.0.dirs.media.as_str(),
-                "images",
-                "default-banner.svg",
-            ]))),
-        ));
-    }
-
-    Ok((
-        [("Content-Type", upload.what.mime())],
-        Body::from(read_image(path)),
-    ))
-}
-
 pub async fn delete_request(
     jar: CookieJar,
     Extension(data): Extension<State>,
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index e08423f..1cea099 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -5,6 +5,7 @@ pub mod reactions;
 pub mod reports;
 pub mod requests;
 pub mod stacks;
+pub mod uploads;
 pub mod util;
 
 #[cfg(feature = "redis")]
@@ -349,7 +350,8 @@ pub fn routes() -> Router {
         .route("/stacks/{id}/users", delete(stacks::remove_user_request))
         .route("/stacks/{id}", delete(stacks::delete_request))
         // uploads
-        .route("/uploads/{id}", get(communities::posts::get_upload_request))
+        .route("/uploads/{id}", get(uploads::get_request))
+        .route("/uploads/{id}", delete(uploads::delete_request))
 }
 
 #[derive(Deserialize)]
diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs
new file mode 100644
index 0000000..77aec0c
--- /dev/null
+++ b/crates/app/src/routes/api/v1/uploads.rs
@@ -0,0 +1,54 @@
+use std::fs::exists;
+use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
+use axum_extra::extract::CookieJar;
+use pathbufd::PathBufD;
+use crate::{get_user_from_token, State};
+use super::auth::images::read_image;
+use tetratto_core::model::{ApiReturn, Error};
+
+pub async fn get_request(
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+
+    let upload = data.get_upload_by_id(id).await.unwrap();
+    let path = upload.path(&data.0);
+
+    if !exists(&path).unwrap() {
+        return Err((
+            [("Content-Type", "image/svg+xml")],
+            Body::from(read_image(PathBufD::current().extend(&[
+                data.0.dirs.media.as_str(),
+                "images",
+                "default-avatar.svg",
+            ]))),
+        ));
+    }
+
+    Ok((
+        [("Content-Type", upload.what.mime())],
+        Body::from(read_image(path)),
+    ))
+}
+
+pub async fn delete_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Path(id): Path<usize>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+    let user = match get_user_from_token!(jar, data) {
+        Some(ua) => ua,
+        None => return Json(Error::NotAllowed.into()),
+    };
+
+    match data.delete_upload_checked(id, &user).await {
+        Ok(_) => Json(ApiReturn {
+            ok: true,
+            message: "Upload deleted".to_string(),
+            payload: (),
+        }),
+        Err(e) => Json(e.into()),
+    }
+}
diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs
index f2f9e48..ed3af57 100644
--- a/crates/app/src/routes/pages/profile.rs
+++ b/crates/app/src/routes/pages/profile.rs
@@ -19,6 +19,8 @@ use contrasted::{Color, MINIMUM_CONTRAST_THRESHOLD};
 pub struct SettingsProps {
     #[serde(default)]
     pub username: String,
+    #[serde(default)]
+    pub page: usize,
 }
 
 /// `/settings`
@@ -65,12 +67,21 @@ pub async fn settings_request(
         Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
     };
 
+    let uploads = match data.0.get_uploads_by_owner(profile.id, 12, req.page).await {
+        Ok(ua) => ua,
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &None).await));
+        }
+    };
+
     let tokens = profile.tokens.clone();
 
     let lang = get_lang!(jar, data.0);
     let mut context = initial_context(&data.0.0, lang, &Some(user)).await;
 
     context.insert("profile", &profile);
+    context.insert("page", &req.page);
+    context.insert("uploads", &uploads);
     context.insert("stacks", &stacks);
     context.insert("blocks", &blocks);
     context.insert("user_settings_serde", &clean_settings(&profile.settings));
diff --git a/crates/core/src/database/uploads.rs b/crates/core/src/database/uploads.rs
index 0189148..2a00823 100644
--- a/crates/core/src/database/uploads.rs
+++ b/crates/core/src/database/uploads.rs
@@ -1,5 +1,7 @@
 use super::*;
 use crate::cache::Cache;
+use crate::model::auth::User;
+use crate::model::permissions::FinePermission;
 use crate::model::{Error, Result, uploads::MediaUpload};
 use crate::{auto_method, execute, get, query_row, query_rows, params};
 
@@ -50,6 +52,37 @@ impl DataManager {
         Ok(res.unwrap())
     }
 
+    /// Get all uploads by their owner (paginated).
+    ///
+    /// # Arguments
+    /// * `owner` - the ID of the owner of the upload
+    /// * `batch` - the limit of items in each page
+    /// * `page` - the page number
+    pub async fn get_uploads_by_owner(
+        &self,
+        owner: usize,
+        batch: usize,
+        page: usize,
+    ) -> Result<Vec<MediaUpload>> {
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_rows!(
+            &conn,
+            "SELECT * FROM uploads WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
+            &[&(owner as i64), &(batch as i64), &((page * batch) as i64)],
+            |x| { Self::get_upload_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("upload".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
     /// Create a new upload in the database.
     ///
     /// # Arguments
@@ -109,4 +142,31 @@ impl DataManager {
         // return
         Ok(())
     }
+
+    pub async fn delete_upload_checked(&self, id: usize, user: &User) -> Result<()> {
+        let upload = self.get_upload_by_id(id).await?;
+
+        // check user permission
+        if user.id != upload.owner && !user.permissions.check(FinePermission::MANAGE_UPLOADS) {
+            return Err(Error::NotAllowed);
+        }
+
+        // delete file
+        upload.remove(&self.0)?;
+
+        // ...
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(&conn, "DELETE FROM uploads WHERE id = $1", &[&(id as i64)]);
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        self.2.remove(format!("atto.upload:{}", id)).await;
+        Ok(())
+    }
 }