1use dioxus::prelude::*;
8
9use super::types::AssetListResponse;
10use crate::models::asset::{AssetFilter, AssetSort};
11
12#[cfg(feature = "server")]
14const PER_PAGE: i64 = 60;
15
16#[cfg(feature = "server")]
19pub(crate) const PURGE_GRACE_DAYS: i32 = 7;
20
21#[server(ListAssets, "/api")]
23pub async fn list_assets(
24 filter: AssetFilter,
25 query: String,
26 sort: AssetSort,
27 page: i32,
28) -> Result<AssetListResponse, ServerFnError> {
29 crate::api::auth::get_current_admin_user().await?;
30 list_assets_impl(filter, query, sort, page).await
31}
32
33#[cfg(feature = "server")]
35pub(crate) async fn list_assets_impl(
36 filter: AssetFilter,
37 query: String,
38 sort: AssetSort,
39 page: i32,
40) -> Result<AssetListResponse, ServerFnError> {
41 use crate::api::error::AppError;
42 use crate::db::pool::get_conn;
43 use crate::models::asset::{Asset, AssetDto, AssetRef, AssetRefPostStatus};
44
45 let client = get_conn().await.map_err(AppError::db_conn)?;
46
47 let page = page.max(1);
48 let offset: i64 = (page as i64 - 1) * PER_PAGE;
49 let query = query.trim().to_string();
50
51 let mut conditions: Vec<String> = Vec::new();
54 let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
55 match filter {
56 AssetFilter::Used => {
57 conditions.push(super::ASSET_REF_CLAUSE.to_string());
58 }
59 AssetFilter::Orphan => {
60 conditions.push(format!("NOT {}", super::ASSET_REF_CLAUSE));
61 }
62 AssetFilter::All => {}
63 }
64 if !query.is_empty() {
65 params.push(&query);
66 conditions.push(format!(
67 "(a.filename ILIKE '%' || ${} || '%' OR a.alt ILIKE '%' || ${} || '%')",
68 params.len(),
69 params.len()
70 ));
71 }
72 let where_clause = if conditions.is_empty() {
73 String::new()
74 } else {
75 format!("WHERE {}", conditions.join(" AND "))
76 };
77
78 let order_clause = match sort {
79 AssetSort::CreatedDesc => "a.created_at DESC, a.id",
80 AssetSort::SizeDesc => "a.size_bytes DESC, a.id",
81 };
82
83 let per_page = PER_PAGE;
87 params.push(&per_page);
88 let limit_idx = params.len();
89 params.push(&offset);
90 let offset_idx = params.len();
91 let rows = client
92 .query(
93 &format!(
94 "SELECT a.id AS id, a.path, a.filename, a.mime, a.size_bytes, \
95 a.width, a.height, a.alt, a.created_at, \
96 {ref_count} AS ref_count \
97 FROM assets a {where_clause} \
98 ORDER BY {order_clause} LIMIT ${limit_idx} OFFSET ${offset_idx}",
99 ref_count = super::ASSET_REF_COUNT_EXPR,
100 ),
101 ¶ms,
102 )
103 .await
104 .map_err(AppError::query)?;
105
106 let total: i64 = client
107 .query_one(
108 &format!("SELECT COUNT(*) FROM assets a {where_clause}"),
109 ¶ms[..params.len() - 2],
110 )
111 .await
112 .map_err(AppError::query)?
113 .get(0);
114
115 let summary = client
118 .query_one(
119 &format!(
120 "SELECT \
121 COUNT(*) FILTER (WHERE {asset_ref}), \
122 COUNT(*) FILTER (WHERE NOT {asset_ref}), \
123 COUNT(*) FILTER (WHERE NOT {asset_ref} \
124 AND a.created_at < NOW() - make_interval(days => $1)), \
125 COALESCE(SUM(a.size_bytes) FILTER (WHERE NOT {asset_ref} \
126 AND a.created_at < NOW() - make_interval(days => $1)), 0)::bigint \
127 FROM assets a",
128 asset_ref = super::ASSET_REF_CLAUSE
129 ),
130 &[&PURGE_GRACE_DAYS],
131 )
132 .await
133 .map_err(AppError::query)?;
134
135 let ids: Vec<uuid::Uuid> = rows.iter().map(|r| r.get::<_, uuid::Uuid>("id")).collect();
139 let mut refs_map: std::collections::HashMap<String, Vec<AssetRef>> =
140 std::collections::HashMap::new();
141 if !ids.is_empty() {
142 let note_rows = client.query(
143 "SELECT DISTINCT r.asset_id, n.id, CASE WHEN v.title='' THEN left(v.summary,48) ELSE v.title END AS title FROM note_asset_refs r JOIN notes n ON n.id=r.note_id JOIN note_revisions v ON v.note_id=n.id AND v.version=n.version WHERE r.asset_id=ANY($1) ORDER BY n.id",
144 &[&ids],
145 ).await.map_err(AppError::query)?;
146 for row in note_rows {
147 refs_map
148 .entry(row.get::<_, uuid::Uuid>("asset_id").to_string())
149 .or_default()
150 .push(AssetRef::Note {
151 note_id: row.get("id"),
152 title: row.get("title"),
153 });
154 }
155 let ref_rows = client
158 .query(
159 "SELECT r.asset_id AS asset_id, p.id AS post_id, p.title, p.slug, \
160 p.status, p.deleted_at \
161 FROM asset_refs r JOIN posts p ON p.id = r.post_id \
162 WHERE r.asset_id = ANY($1) \
163 ORDER BY p.id",
164 &[&ids],
165 )
166 .await
167 .map_err(AppError::query)?;
168 for rr in ref_rows {
169 let asset_key: String = rr.get::<_, uuid::Uuid>("asset_id").to_string();
170 let status = AssetRefPostStatus::resolve(
171 rr.get::<_, &str>("status"),
172 rr.get::<_, Option<chrono::DateTime<chrono::Utc>>>("deleted_at"),
173 );
174 refs_map.entry(asset_key).or_default().push(AssetRef::Post {
175 post_id: rr.get("post_id"),
176 title: rr.get("title"),
177 slug: rr.get("slug"),
178 status,
179 });
180 }
181
182 let comment_rows = client
184 .query(
185 "SELECT a.id AS asset_id, c.id AS comment_id, c.author_name, \
186 p.id AS post_id, p.title AS post_title, p.slug AS post_slug, \
187 p.status AS post_status, p.deleted_at AS post_deleted_at \
188 FROM assets a \
189 JOIN comments c ON c.deleted_at IS NULL \
190 AND c.content_html LIKE '%' || a.path || '%' \
191 JOIN posts p ON p.id = c.post_id \
192 WHERE a.id = ANY($1) \
193 ORDER BY c.id",
194 &[&ids],
195 )
196 .await
197 .map_err(AppError::query)?;
198 for rr in comment_rows {
199 let asset_key: String = rr.get::<_, uuid::Uuid>("asset_id").to_string();
200 let post_status = AssetRefPostStatus::resolve(
201 rr.get::<_, &str>("post_status"),
202 rr.get::<_, Option<chrono::DateTime<chrono::Utc>>>("post_deleted_at"),
203 );
204 refs_map
205 .entry(asset_key)
206 .or_default()
207 .push(AssetRef::Comment {
208 comment_id: rr.get("comment_id"),
209 author_name: rr.get("author_name"),
210 post_id: rr.get("post_id"),
211 post_title: rr.get("post_title"),
212 post_slug: rr.get("post_slug"),
213 post_status,
214 });
215 }
216
217 let user_rows = client
219 .query(
220 "SELECT a.id AS asset_id, u.id AS user_id, u.username, u.display_name \
221 FROM assets a \
222 JOIN users u ON u.avatar_url = '/uploads/' || a.path \
223 WHERE a.id = ANY($1) \
224 ORDER BY u.id",
225 &[&ids],
226 )
227 .await
228 .map_err(AppError::query)?;
229 for rr in user_rows {
230 let asset_key: String = rr.get::<_, uuid::Uuid>("asset_id").to_string();
231 let username: String = rr.get("username");
232 let display_name: Option<String> = rr.get("display_name");
233 let label = display_name
234 .filter(|s| !s.trim().is_empty())
235 .unwrap_or(username);
236 refs_map
237 .entry(asset_key)
238 .or_default()
239 .push(AssetRef::UserAvatar {
240 user_id: rr.get("user_id"),
241 label,
242 });
243 }
244
245 let friend_rows = client
247 .query(
248 "SELECT a.id AS asset_id, f.id AS friend_id, f.name \
249 FROM assets a \
250 JOIN friend_links f ON f.avatar_url = '/uploads/' || a.path \
251 WHERE a.id = ANY($1) \
252 ORDER BY f.id",
253 &[&ids],
254 )
255 .await
256 .map_err(AppError::query)?;
257 for rr in friend_rows {
258 let asset_key: String = rr.get::<_, uuid::Uuid>("asset_id").to_string();
259 refs_map
260 .entry(asset_key)
261 .or_default()
262 .push(AssetRef::FriendAvatar {
263 friend_id: rr.get("friend_id"),
264 name: rr.get("name"),
265 });
266 }
267 }
268
269 let assets = rows
270 .into_iter()
271 .map(|row| {
272 let id: String = row.get::<_, uuid::Uuid>("id").to_string();
273 let refs = refs_map.remove(&id).unwrap_or_default();
274 AssetDto {
275 asset: Asset {
276 id,
277 path: row.get("path"),
278 filename: row.get("filename"),
279 mime: row.get("mime"),
280 size_bytes: row.get("size_bytes"),
281 width: row.get("width"),
282 height: row.get("height"),
283 alt: row.get("alt"),
284 created_at: row.get("created_at"),
285 },
286 ref_count: row.get("ref_count"),
287 refs,
288 }
289 })
290 .collect();
291
292 Ok(AssetListResponse {
293 assets,
294 total,
295 used_count: summary.get(0),
296 orphan_count: summary.get(1),
297 purgeable_count: summary.get(2),
298 purgeable_bytes: summary.get(3),
299 })
300}