yggdrasil/api/assets/
list.rs1use 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 #[cfg(feature = "server")]
30 {
31 use crate::api::auth::get_current_admin_user;
32 use crate::api::error::AppError;
33 use crate::db::pool::get_conn;
34 use crate::models::asset::{Asset, AssetDto, AssetRef};
35
36 let _admin = get_current_admin_user().await?;
37
38 let client = get_conn().await.map_err(AppError::db_conn)?;
39
40 let page = page.max(1);
41 let offset: i64 = (page as i64 - 1) * PER_PAGE;
42 let query = query.trim().to_string();
43
44 let mut conditions: Vec<String> = Vec::new();
47 let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
48 match filter {
49 AssetFilter::Used => {
50 conditions.push(
51 "EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)".to_string(),
52 );
53 }
54 AssetFilter::Orphan => {
55 conditions.push(
56 "NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)".to_string(),
57 );
58 }
59 AssetFilter::All => {}
60 }
61 if !query.is_empty() {
62 params.push(&query);
63 conditions.push(format!(
64 "(a.filename ILIKE '%' || ${} || '%' OR a.alt ILIKE '%' || ${} || '%')",
65 params.len(),
66 params.len()
67 ));
68 }
69 let where_clause = if conditions.is_empty() {
70 String::new()
71 } else {
72 format!("WHERE {}", conditions.join(" AND "))
73 };
74
75 let order_clause = match sort {
76 AssetSort::CreatedDesc => "a.created_at DESC, a.id",
77 AssetSort::SizeDesc => "a.size_bytes DESC, a.id",
78 };
79
80 let per_page = PER_PAGE;
83 params.push(&per_page);
84 let limit_idx = params.len();
85 params.push(&offset);
86 let offset_idx = params.len();
87 let rows = client
88 .query(
89 &format!(
90 "SELECT a.id AS id, a.path, a.filename, a.mime, a.size_bytes, \
91 a.width, a.height, a.alt, a.created_at, \
92 (SELECT COUNT(*) FROM asset_refs r WHERE r.asset_id = a.id) AS ref_count \
93 FROM assets a {where_clause} \
94 ORDER BY {order_clause} LIMIT ${limit_idx} OFFSET ${offset_idx}"
95 ),
96 ¶ms,
97 )
98 .await
99 .map_err(AppError::query)?;
100
101 let total: i64 = client
102 .query_one(
103 &format!("SELECT COUNT(*) FROM assets a {where_clause}"),
104 ¶ms[..params.len() - 2],
105 )
106 .await
107 .map_err(AppError::query)?
108 .get(0);
109
110 let summary = client
112 .query_one(
113 "SELECT \
114 COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)), \
115 COUNT(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)), \
116 COUNT(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
117 AND a.created_at < NOW() - make_interval(days => $1)), \
118 COALESCE(SUM(a.size_bytes) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
119 AND a.created_at < NOW() - make_interval(days => $1)), 0)::bigint \
120 FROM assets a",
121 &[&PURGE_GRACE_DAYS],
122 )
123 .await
124 .map_err(AppError::query)?;
125
126 let ids: Vec<uuid::Uuid> = rows.iter().map(|r| r.get::<_, uuid::Uuid>("id")).collect();
128 let mut refs_map: std::collections::HashMap<String, Vec<AssetRef>> =
129 std::collections::HashMap::new();
130 if !ids.is_empty() {
131 let ref_rows = client
132 .query(
133 "SELECT r.asset_id AS asset_id, p.id AS post_id, p.title \
134 FROM asset_refs r JOIN posts p ON p.id = r.post_id \
135 WHERE r.asset_id = ANY($1) \
136 ORDER BY p.id",
137 &[&ids],
138 )
139 .await
140 .map_err(AppError::query)?;
141 for rr in ref_rows {
142 let asset_key: String = rr.get::<_, uuid::Uuid>("asset_id").to_string();
143 refs_map.entry(asset_key).or_default().push(AssetRef {
144 post_id: rr.get("post_id"),
145 title: rr.get("title"),
146 });
147 }
148 }
149
150 let assets = rows
151 .into_iter()
152 .map(|row| {
153 let id: String = row.get::<_, uuid::Uuid>("id").to_string();
154 let refs = refs_map.remove(&id).unwrap_or_default();
155 AssetDto {
156 asset: Asset {
157 id,
158 path: row.get("path"),
159 filename: row.get("filename"),
160 mime: row.get("mime"),
161 size_bytes: row.get("size_bytes"),
162 width: row.get("width"),
163 height: row.get("height"),
164 alt: row.get("alt"),
165 created_at: row.get("created_at"),
166 },
167 ref_count: row.get("ref_count"),
168 refs,
169 }
170 })
171 .collect();
172
173 Ok(AssetListResponse {
174 assets,
175 total,
176 used_count: summary.get(0),
177 orphan_count: summary.get(1),
178 purgeable_count: summary.get(2),
179 purgeable_bytes: summary.get(3),
180 })
181 }
182 #[cfg(not(feature = "server"))]
183 unreachable!()
184}