1use crate::{
2 models::{InsertQueryBuilder, UpdateQueryBuilder},
3 prelude::*,
4};
5use garde::Validate;
6use rand::distr::SampleString;
7use serde::{Deserialize, Serialize};
8use sqlx::{Row, postgres::PgRow};
9use std::{
10 collections::BTreeMap,
11 sync::{Arc, LazyLock},
12};
13use utoipa::ToSchema;
14
15#[derive(Clone, Serialize, Deserialize)]
16pub struct ServerSubuser {
17 pub user: super::user::User,
18 pub server: Fetchable<super::server::Server>,
19
20 pub permissions: Vec<compact_str::CompactString>,
21 pub ignored_files: Vec<compact_str::CompactString>,
22
23 pub created: chrono::NaiveDateTime,
24
25 extension_data: super::ModelExtensionData,
26}
27
28impl BaseModel for ServerSubuser {
29 const NAME: &'static str = "server_subuser";
30
31 fn get_extension_list() -> &'static super::ModelExtensionList {
32 static EXTENSIONS: LazyLock<super::ModelExtensionList> =
33 LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
34
35 &EXTENSIONS
36 }
37
38 fn get_extension_data(&self) -> &super::ModelExtensionData {
39 &self.extension_data
40 }
41
42 #[inline]
43 fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
44 let prefix = prefix.unwrap_or_default();
45
46 let mut columns = BTreeMap::from([
47 (
48 "server_subusers.server_uuid",
49 compact_str::format_compact!("{prefix}server_uuid"),
50 ),
51 (
52 "server_subusers.permissions",
53 compact_str::format_compact!("{prefix}permissions"),
54 ),
55 (
56 "server_subusers.ignored_files",
57 compact_str::format_compact!("{prefix}ignored_files"),
58 ),
59 (
60 "server_subusers.created",
61 compact_str::format_compact!("{prefix}created"),
62 ),
63 ]);
64
65 columns.extend(super::user::User::base_columns(Some("user_")));
66
67 columns
68 }
69
70 #[inline]
71 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
72 let prefix = prefix.unwrap_or_default();
73
74 Ok(Self {
75 user: super::user::User::map(Some("user_"), row)?,
76 server: super::server::Server::get_fetchable(
77 row.try_get(compact_str::format_compact!("{prefix}server_uuid").as_str())?,
78 ),
79 permissions: row
80 .try_get(compact_str::format_compact!("{prefix}permissions").as_str())?,
81 ignored_files: row
82 .try_get(compact_str::format_compact!("{prefix}ignored_files").as_str())?,
83 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
84 extension_data: Self::map_extensions(prefix, row)?,
85 })
86 }
87}
88
89impl ServerSubuser {
90 pub async fn permissions_by_server_uuid_user_uuid(
91 database: &crate::database::Database,
92 server_uuid: uuid::Uuid,
93 user_uuid: uuid::Uuid,
94 ) -> Result<
95 Option<(
96 Vec<compact_str::CompactString>,
97 Vec<compact_str::CompactString>,
98 )>,
99 sqlx::Error,
100 > {
101 let row = sqlx::query(
102 r#"
103 SELECT server_subusers.permissions, server_subusers.ignored_files
104 FROM server_subusers
105 WHERE server_subusers.server_uuid = $1 AND server_subusers.user_uuid = $2
106 "#,
107 )
108 .bind(server_uuid)
109 .bind(user_uuid)
110 .fetch_optional(database.read())
111 .await?;
112
113 row.map(|row| Ok((row.try_get("permissions")?, row.try_get("ignored_files")?)))
114 .transpose()
115 }
116
117 pub async fn by_server_uuid_user_uuid(
118 database: &crate::database::Database,
119 server_uuid: uuid::Uuid,
120 user_uuid: uuid::Uuid,
121 ) -> Result<Option<Self>, crate::database::DatabaseError> {
122 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
123 r#"
124 SELECT {}
125 FROM server_subusers
126 JOIN users ON users.uuid = server_subusers.user_uuid
127 LEFT JOIN roles ON roles.uuid = users.role_uuid
128 WHERE server_subusers.server_uuid = $1 AND server_subusers.user_uuid = $2
129 "#,
130 Self::columns_sql(None)
131 )))
132 .bind(server_uuid)
133 .bind(user_uuid)
134 .fetch_optional(database.read())
135 .await?;
136
137 row.try_map(|row| Self::map(None, &row))
138 }
139
140 pub async fn by_server_uuid_with_pagination(
141 database: &crate::database::Database,
142 server_uuid: uuid::Uuid,
143 page: i64,
144 per_page: i64,
145 search: Option<&str>,
146 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
147 let offset = (page - 1) * per_page;
148
149 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
150 r#"
151 SELECT {}, COUNT(*) OVER() AS total_count
152 FROM server_subusers
153 JOIN users ON users.uuid = server_subusers.user_uuid
154 LEFT JOIN roles ON roles.uuid = users.role_uuid
155 WHERE server_subusers.server_uuid = $1 AND ($2 IS NULL OR users.username ILIKE '%' || $2 || '%')
156 ORDER BY server_subusers.created
157 LIMIT $3 OFFSET $4
158 "#,
159 Self::columns_sql(None)
160 )))
161 .bind(server_uuid)
162 .bind(search)
163 .bind(per_page)
164 .bind(offset)
165 .fetch_all(database.read())
166 .await?;
167
168 Ok(super::Pagination {
169 total: rows
170 .first()
171 .map_or(Ok(0), |row| row.try_get("total_count"))?,
172 per_page,
173 page,
174 data: rows
175 .into_iter()
176 .map(|row| Self::map(None, &row))
177 .try_collect_vec()?,
178 })
179 }
180
181 pub async fn count_by_server_uuid(
182 database: &crate::database::Database,
183 server_uuid: uuid::Uuid,
184 ) -> Result<i64, sqlx::Error> {
185 sqlx::query_scalar(
186 r#"
187 SELECT COUNT(*)
188 FROM server_subusers
189 WHERE server_subusers.server_uuid = $1
190 "#,
191 )
192 .bind(server_uuid)
193 .fetch_one(database.read())
194 .await
195 }
196}
197
198#[async_trait::async_trait]
199impl IntoApiObject for ServerSubuser {
200 type ApiObject = ApiServerSubuser;
201 type ExtraArgs<'a> = &'a crate::storage::StorageUrlRetriever<'a>;
202
203 async fn into_api_object<'a>(
204 self,
205 state: &crate::State,
206 storage_url_retriever: Self::ExtraArgs<'a>,
207 ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
208 let api_object = ApiServerSubuser::init_hooks(&self, state).await?;
209
210 let api_object = finish_extendible!(
211 ApiServerSubuser {
212 user: self
213 .user
214 .into_api_object(state, storage_url_retriever)
215 .await?,
216 permissions: self.permissions,
217 ignored_files: self.ignored_files,
218 created: self.created.and_utc(),
219 },
220 api_object,
221 state
222 )?;
223
224 Ok(api_object)
225 }
226}
227
228#[derive(Validate)]
229pub struct CreateServerSubuserOptions<'a> {
230 #[garde(skip)]
231 pub server: &'a super::server::Server,
232
233 #[garde(email)]
234 pub email: compact_str::CompactString,
235 #[garde(custom(crate::permissions::validate_server_permissions))]
236 pub permissions: Vec<compact_str::CompactString>,
237 #[garde(
238 length(max = 1024),
239 inner(length(chars, min = 1, max = 255)),
240 custom(crate::utils::validate_ignored_files)
241 )]
242 pub ignored_files: Vec<compact_str::CompactString>,
243}
244
245#[async_trait::async_trait]
246impl CreatableModel for ServerSubuser {
247 type CreateOptions<'a> = CreateServerSubuserOptions<'a>;
248 type CreateResult = Self;
249
250 fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
251 static CREATE_LISTENERS: LazyLock<CreateListenerList<ServerSubuser>> =
252 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
253
254 &CREATE_LISTENERS
255 }
256
257 async fn create_with_transaction(
258 state: &crate::State,
259 mut options: Self::CreateOptions<'_>,
260 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
261 ) -> Result<Self, crate::database::DatabaseError> {
262 options.validate()?;
263
264 let user = match super::user::User::by_email(&state.database, &options.email).await? {
265 Some(user) => user,
266 None => {
267 let username = options
268 .email
269 .split('@')
270 .next()
271 .unwrap_or("unknown")
272 .chars()
273 .filter(|c| c.is_alphanumeric() || *c == '_')
274 .take(10)
275 .collect::<compact_str::CompactString>();
276 let username = compact_str::format_compact!(
277 "{username}_{}",
278 rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 4)
279 );
280
281 let app_settings = state.settings.get().await?;
282
283 let create_options = super::user::CreateUserOptions {
284 role_uuid: None,
285 external_id: None,
286 username: username.clone(),
287 email: options.email.clone(),
288 name_first: None,
289 name_last: None,
290 password: None,
291 admin: false,
292 frozen: false,
293 suspended: false,
294 verify_email: false,
295 send_email: true,
296 language: app_settings.app.language.clone(),
297 };
298 drop(app_settings);
299 match super::user::User::create(state, create_options).await {
300 Ok(user) => user,
301 Err(err) => {
302 tracing::error!(username = %username, email = %options.email, "failed to create subuser user: {:?}", err);
303 return Err(err);
304 }
305 }
306 }
307 };
308
309 if options.server.owner.uuid == user.uuid {
310 return Err(sqlx::Error::InvalidArgument(
311 "cannot create subuser for server owner".into(),
312 )
313 .into());
314 }
315
316 let server_uuid = options.server.uuid;
317 let username = user.username.clone();
318
319 let mut query_builder = InsertQueryBuilder::new("server_subusers");
320
321 Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
322
323 query_builder
324 .set("server_uuid", options.server.uuid)
325 .set("user_uuid", user.uuid)
326 .set("permissions", &options.permissions)
327 .set("ignored_files", &options.ignored_files);
328
329 query_builder.execute(&mut **transaction).await?;
330
331 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
332 r#"
333 SELECT {}
334 FROM server_subusers
335 JOIN users ON users.uuid = server_subusers.user_uuid
336 LEFT JOIN roles ON roles.uuid = users.role_uuid
337 WHERE server_subusers.server_uuid = $1 AND users.username = $2
338 "#,
339 Self::columns_sql(None)
340 )))
341 .bind(server_uuid)
342 .bind(username.as_str())
343 .fetch_one(&mut **transaction)
344 .await?;
345
346 let mut result = Self::map(None, &row)?;
347
348 Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
349
350 let settings = state.settings.get().await?;
351 state
352 .mail
353 .send_template(
354 state,
355 "added_to_server",
356 &user,
357 minijinja::context! {
358 user => user,
359 server => options.server,
360 server_link => format!(
361 "{}/server/{:08x}",
362 settings.app.url,
363 options.server.uuid_short,
364 )
365 },
366 )
367 .await;
368
369 Ok(result)
370 }
371}
372
373#[derive(ToSchema, Serialize, Deserialize, Validate, Default)]
374pub struct UpdateServerSubuserOptions {
375 #[garde(inner(custom(crate::permissions::validate_server_permissions)))]
376 pub permissions: Option<Vec<compact_str::CompactString>>,
377 #[garde(inner(
378 length(max = 1024),
379 inner(length(chars, min = 1, max = 255)),
380 custom(crate::utils::validate_ignored_files)
381 ))]
382 pub ignored_files: Option<Vec<compact_str::CompactString>>,
383}
384
385#[async_trait::async_trait]
386impl UpdatableModel for ServerSubuser {
387 type UpdateOptions = UpdateServerSubuserOptions;
388
389 fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
390 static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<ServerSubuser>> =
391 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
392
393 &UPDATE_LISTENERS
394 }
395
396 async fn update_with_transaction(
397 &mut self,
398 state: &crate::State,
399 mut options: Self::UpdateOptions,
400 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
401 ) -> Result<(), crate::database::DatabaseError> {
402 options.validate()?;
403
404 let mut query_builder = UpdateQueryBuilder::new("server_subusers");
405
406 self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
407 .await?;
408
409 query_builder
410 .set("permissions", options.permissions.as_ref())
411 .set("ignored_files", options.ignored_files.as_ref())
412 .where_eq("server_uuid", self.server.uuid)
413 .where_eq("user_uuid", self.user.uuid);
414
415 query_builder.execute(&mut **transaction).await?;
416
417 if let Some(permissions) = options.permissions {
418 self.permissions = permissions;
419 }
420 if let Some(ignored_files) = options.ignored_files {
421 self.ignored_files = ignored_files;
422 }
423
424 self.run_after_update_handlers(state, transaction).await?;
425
426 Ok(())
427 }
428}
429
430#[async_trait::async_trait]
431impl DeletableModel for ServerSubuser {
432 type DeleteOptions = ();
433
434 fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
435 static DELETE_LISTENERS: LazyLock<DeleteHandlerList<ServerSubuser>> =
436 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
437
438 &DELETE_LISTENERS
439 }
440
441 async fn delete_with_transaction(
442 &self,
443 state: &crate::State,
444 options: Self::DeleteOptions,
445 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
446 ) -> Result<(), anyhow::Error> {
447 self.run_delete_handlers(&options, state, transaction)
448 .await?;
449
450 sqlx::query(
451 r#"
452 DELETE FROM server_subusers
453 WHERE server_subusers.server_uuid = $1 AND server_subusers.user_uuid = $2
454 "#,
455 )
456 .bind(self.server.uuid)
457 .bind(self.user.uuid)
458 .execute(&mut **transaction)
459 .await?;
460
461 self.run_after_delete_handlers(&options, state, transaction)
462 .await?;
463
464 state
465 .mail
466 .send_template(
467 state,
468 "removed_from_server",
469 &self.user,
470 minijinja::context! {
471 user => self.user,
472 },
473 )
474 .await;
475
476 Ok(())
477 }
478}
479
480#[schema_extension_derive::extendible]
481#[init_args(ServerSubuser, crate::State)]
482#[hook_args(crate::State)]
483#[derive(ToSchema, Serialize)]
484#[schema(title = "ServerSubuser")]
485pub struct ApiServerSubuser {
486 pub user: super::user::ApiUser,
487
488 pub permissions: Vec<compact_str::CompactString>,
489 pub ignored_files: Vec<compact_str::CompactString>,
490
491 pub created: chrono::DateTime<chrono::Utc>,
492}