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