Skip to content

Commit 4fbcfd6

Browse files
committed
extension images
1 parent 904a5cd commit 4fbcfd6

File tree

19 files changed

+743
-33
lines changed

19 files changed

+743
-33
lines changed

.sqlx/query-04cbefa58cfe21af2b11b58e0c74258e0b3e7b9378719a79871210a3b15c67df.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.sqlx/query-3aead5457cbc88a05bb4a443c87ec61d18444fc62f8f7567e5e4446d477e5d1d.json

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/backend/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ regex = "1.11.1"
4343
urlencoding = "2.1.3"
4444
rust-s3 = { version = "0.37.0", default-features = false, features = ["tokio-rustls-tls"] }
4545
image = "0.25.5"
46+
futures-util = "0.3.31"

apps/backend/src/models/extension.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,42 @@ impl Extension {
442442
Ok(row.map(|data| Self::map(None, &data)))
443443
}
444444

445+
pub async fn delete(
446+
&self,
447+
database: &crate::database::Database,
448+
s3: &crate::s3::S3,
449+
) -> Result<(), anyhow::Error> {
450+
if self.banner != "_default.jpeg" {
451+
tokio::try_join!(
452+
s3.bucket
453+
.delete_object(format!("extensions/lowres/{}", self.banner)),
454+
s3.bucket
455+
.delete_object(format!("extensions/{}", self.banner))
456+
)?;
457+
}
458+
459+
let images =
460+
super::extension_image::ExtensionImage::all_by_extension_id(database, self.id).await?;
461+
let mut futures = Vec::new();
462+
futures.reserve_exact(images.len());
463+
464+
for image in images {
465+
futures.push(s3.bucket.delete_object(image.location));
466+
}
467+
468+
futures_util::future::try_join_all(futures).await?;
469+
470+
sqlx::query!(
471+
"DELETE FROM extensions
472+
WHERE extensions.id = $1",
473+
self.id
474+
)
475+
.execute(database.write())
476+
.await?;
477+
478+
Ok(())
479+
}
480+
445481
#[inline]
446482
pub fn versions(&self) -> Vec<&String> {
447483
let mut versions: Vec<&String> = Vec::new();
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
use super::BaseModel;
2+
use image::{DynamicImage, codecs::webp::WebPEncoder, imageops::FilterType};
3+
use rand::distr::SampleString;
4+
use serde::{Deserialize, Serialize};
5+
use sqlx::{Row, postgres::PgRow, types::chrono::NaiveDateTime};
6+
use std::collections::BTreeMap;
7+
use utoipa::ToSchema;
8+
9+
#[derive(Serialize, Deserialize, Clone)]
10+
pub struct ExtensionImage {
11+
pub id: i32,
12+
13+
pub width: i32,
14+
pub height: i32,
15+
pub size: i32,
16+
pub location: String,
17+
18+
pub created: NaiveDateTime,
19+
}
20+
21+
impl BaseModel for ExtensionImage {
22+
#[inline]
23+
fn columns(prefix: Option<&str>, table: Option<&str>) -> BTreeMap<String, String> {
24+
let prefix = prefix.unwrap_or_default();
25+
let table = table.unwrap_or("extension_images");
26+
27+
BTreeMap::from([
28+
(format!("{table}.id"), format!("{}id", prefix)),
29+
(format!("{table}.width"), format!("{}width", prefix)),
30+
(format!("{table}.height"), format!("{}height", prefix)),
31+
(format!("{table}.size"), format!("{}size", prefix)),
32+
(format!("{table}.location"), format!("{}location", prefix)),
33+
(format!("{table}.created"), format!("{}created", prefix)),
34+
])
35+
}
36+
37+
#[inline]
38+
fn map(prefix: Option<&str>, row: &PgRow) -> Self {
39+
let prefix = prefix.unwrap_or_default();
40+
41+
Self {
42+
id: row.get(format!("{prefix}id").as_str()),
43+
width: row.get(format!("{prefix}width").as_str()),
44+
height: row.get(format!("{prefix}height").as_str()),
45+
size: row.get(format!("{prefix}size").as_str()),
46+
location: row.get(format!("{prefix}location").as_str()),
47+
created: row.get(format!("{prefix}created").as_str()),
48+
}
49+
}
50+
}
51+
52+
impl ExtensionImage {
53+
pub async fn create(
54+
database: &crate::database::Database,
55+
s3: &crate::s3::S3,
56+
extension: &super::extension::Extension,
57+
image: DynamicImage,
58+
) -> Result<Self, anyhow::Error> {
59+
let identifier_random = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 8);
60+
let location = format!(
61+
"extensions/images/{}/{}.webp",
62+
extension.identifier, identifier_random
63+
);
64+
65+
let image = tokio::task::spawn_blocking(move || {
66+
image.resize_exact(
67+
image.width().min(1280),
68+
image.height().min(10000),
69+
FilterType::Triangle,
70+
)
71+
})
72+
.await?;
73+
74+
let width = image.width() as i32;
75+
let height = image.height() as i32;
76+
77+
let data = tokio::task::spawn_blocking(move || {
78+
let mut data: Vec<u8> = Vec::new();
79+
let encoder = WebPEncoder::new_lossless(&mut data);
80+
encoder.encode(image.as_bytes(), 1280, 720, image.color().into())?;
81+
82+
Ok::<_, anyhow::Error>(data)
83+
})
84+
.await??;
85+
86+
let mut transaction = database.write().begin().await?;
87+
88+
let row = sqlx::query(&format!(
89+
r#"
90+
INSERT INTO extension_images (extension_id, width, height, size, location, created)
91+
VALUES ($1, $2, $3, $4, $5, NOW())
92+
RETURNING {}
93+
"#,
94+
Self::columns_sql(None, None)
95+
))
96+
.bind(extension.id)
97+
.bind(width)
98+
.bind(height)
99+
.bind(data.len() as i32)
100+
.bind(&location)
101+
.fetch_one(&mut *transaction)
102+
.await?;
103+
104+
let count_and_total = sqlx::query!(
105+
r#"
106+
SELECT
107+
COUNT(*) AS count,
108+
COALESCE(SUM(size), 0) AS total
109+
FROM extension_images
110+
WHERE extension_images.extension_id = $1
111+
"#,
112+
extension.id
113+
)
114+
.fetch_one(&mut *transaction)
115+
.await?;
116+
117+
let image_count: i64 = count_and_total.count.unwrap_or(0);
118+
let image_total: i64 = count_and_total.total.unwrap_or(0);
119+
120+
if image_count > 25 {
121+
transaction.rollback().await?;
122+
123+
return Err(anyhow::anyhow!(
124+
"unable to upload image: extension image limit reached"
125+
));
126+
}
127+
128+
if image_total + (data.len() as i64) > 30 * 1024 * 1024 {
129+
transaction.rollback().await?;
130+
131+
return Err(anyhow::anyhow!(
132+
"unable to upload image: extension image storage limit reached"
133+
));
134+
}
135+
136+
s3.upload(location, &data, Some("image/webp")).await?;
137+
transaction.commit().await?;
138+
139+
Ok(Self::map(None, &row))
140+
}
141+
142+
pub async fn all_by_extension_id(
143+
database: &crate::database::Database,
144+
extension_id: i32,
145+
) -> Result<Vec<Self>, sqlx::Error> {
146+
let rows = sqlx::query(&format!(
147+
r#"
148+
SELECT {}
149+
FROM extension_images
150+
WHERE extension_id = $1
151+
ORDER BY extension_images.id
152+
LIMIT $2 OFFSET $3
153+
"#,
154+
Self::columns_sql(None, None)
155+
))
156+
.bind(extension_id)
157+
.fetch_all(database.read())
158+
.await?;
159+
160+
Ok(rows.into_iter().map(|row| Self::map(None, &row)).collect())
161+
}
162+
163+
pub async fn by_extension_id_id(
164+
database: &crate::database::Database,
165+
extension_id: i32,
166+
id: i32,
167+
) -> Result<Option<Self>, sqlx::Error> {
168+
let row = sqlx::query(&format!(
169+
r#"
170+
SELECT {}
171+
FROM extension_images
172+
WHERE extension_id = $1 AND extension_images.id = $2
173+
"#,
174+
Self::columns_sql(None, None)
175+
))
176+
.bind(extension_id)
177+
.bind(id)
178+
.fetch_optional(database.read())
179+
.await?;
180+
181+
Ok(row.map(|data| Self::map(None, &data)))
182+
}
183+
184+
pub async fn delete(
185+
&self,
186+
database: &crate::database::Database,
187+
s3: &crate::s3::S3,
188+
) -> Result<(), anyhow::Error> {
189+
s3.bucket.delete_object(&self.location).await?;
190+
191+
sqlx::query!(
192+
"DELETE FROM extension_images
193+
WHERE extension_images.id = $1",
194+
self.id
195+
)
196+
.execute(database.write())
197+
.await?;
198+
199+
Ok(())
200+
}
201+
202+
#[inline]
203+
pub fn into_api_object(self, env: &crate::env::Env) -> ApiExtensionImage {
204+
ApiExtensionImage {
205+
id: self.id,
206+
width: self.width,
207+
height: self.height,
208+
size: self.size,
209+
url: format!("{}/{}", env.s3_url, self.location),
210+
created: self.created.and_utc(),
211+
}
212+
}
213+
}
214+
215+
#[derive(ToSchema, Serialize)]
216+
#[schema(title = "ExtensionImage")]
217+
pub struct ApiExtensionImage {
218+
pub id: i32,
219+
220+
pub width: i32,
221+
pub height: i32,
222+
pub size: i32,
223+
pub url: String,
224+
225+
pub created: chrono::DateTime<chrono::Utc>,
226+
}

apps/backend/src/models/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use utoipa::ToSchema;
55
use validator::Validate;
66

77
pub mod extension;
8+
pub mod extension_image;
89
pub mod user;
910
pub mod user_password_reset;
1011
pub mod user_recovery_code;

0 commit comments

Comments
 (0)