authbeam/api/
profile.rs

1use crate::database::Database;
2use crate::model::{
3    DatabaseError, FinePermission, NotificationCreate, RenderLayout, SetProfileBadges,
4    SetProfileCoins, SetProfileGroup, SetProfileLabels, SetProfileLayout, SetProfileLinks,
5    SetProfileMetadata, SetProfilePassword, SetProfileTier, SetProfileUsername, TOTPDisable,
6    TokenContext, TokenPermission,
7};
8use crate::simplify;
9use databeam::prelude::DefaultReturn;
10use pathbufd::pathd;
11
12use axum::body::Body;
13use axum::http::{HeaderMap, HeaderValue};
14use axum::response::IntoResponse;
15use axum::{
16    extract::{Path, State},
17    Json,
18};
19use axum_extra::extract::cookie::CookieJar;
20use serde::Serialize;
21
22use std::{fs::File, io::Read};
23
24pub fn read_image(static_dir: String, image: String) -> Vec<u8> {
25    let mut bytes = Vec::new();
26
27    for byte in match File::open(format!("{static_dir}/{image}")) {
28        Ok(f) => f,
29        Err(_) => return bytes,
30    }
31    .bytes()
32    {
33        bytes.push(byte.unwrap())
34    }
35
36    bytes
37}
38
39/// Get a profile's avatar image
40pub async fn avatar_request(
41    Path(id): Path<String>,
42    State(database): State<Database>,
43) -> impl IntoResponse {
44    // get user
45    let auth_user = match database.get_profile(&id).await {
46        Ok(ua) => ua,
47        Err(_) => {
48            return (
49                [("Content-Type", "image/svg+xml")],
50                Body::from(read_image(
51                    pathd!("{}/images", database.config.static_dir),
52                    "default-avatar.svg".to_string(),
53                )),
54            );
55        }
56    };
57
58    // ...
59    let avatar_url = match auth_user.metadata.kv.get("sparkler:avatar_url") {
60        Some(r) => r,
61        None => "",
62    };
63
64    if (avatar_url == "rb://") && !database.config.media_dir.to_string().is_empty() {
65        return (
66            [("Content-Type", "image/avif")],
67            Body::from(read_image(
68                pathd!("{}/avatars", database.config.media_dir),
69                format!("{}.avif", &auth_user.id),
70            )),
71        );
72    }
73
74    if avatar_url.starts_with(&database.config.host) {
75        return (
76            [("Content-Type", "image/svg+xml")],
77            Body::from(read_image(
78                pathd!("{}/images", database.config.static_dir),
79                "default-avatar.svg".to_string(),
80            )),
81        );
82    }
83
84    for host in database.config.blocked_hosts {
85        if avatar_url.starts_with(&host) {
86            return (
87                [("Content-Type", "image/svg+xml")],
88                Body::from(read_image(
89                    pathd!("{}/images", database.config.static_dir),
90                    "default-avatar.svg".to_string(),
91                )),
92            );
93        }
94    }
95
96    // get profile image
97    if avatar_url.is_empty() {
98        return (
99            [("Content-Type", "image/svg+xml")],
100            Body::from(read_image(
101                pathd!("{}/images", database.config.static_dir),
102                "default-avatar.svg".to_string(),
103            )),
104        );
105    }
106
107    let guessed_mime = mime_guess::from_path(avatar_url)
108        .first_raw()
109        .unwrap_or("application/octet-stream");
110
111    match database.http.get(avatar_url).send().await {
112        Ok(stream) => {
113            let size = stream.content_length();
114            if size.unwrap_or_default() > 10485760 {
115                // return defualt image (content too big)
116                return (
117                    [("Content-Type", "image/svg+xml")],
118                    Body::from(read_image(
119                        pathd!("{}/images", database.config.static_dir),
120                        "default-banner.svg".to_string(),
121                    )),
122                );
123            }
124
125            if let Some(ct) = stream.headers().get("Content-Type") {
126                let ct = ct.to_str().unwrap();
127                let bad_ct = vec!["text/html", "text/plain"];
128                if (!ct.starts_with("image/") && !ct.starts_with("font/")) | bad_ct.contains(&ct) {
129                    // if we got html, return default banner (likely an error page)
130                    return (
131                        [("Content-Type", "image/svg+xml")],
132                        Body::from(read_image(
133                            pathd!("{}/images", database.config.static_dir),
134                            "default-banner.svg".to_string(),
135                        )),
136                    );
137                }
138            }
139
140            (
141                [(
142                    "Content-Type",
143                    if guessed_mime == "text/html" {
144                        "text/plain"
145                    } else {
146                        guessed_mime
147                    },
148                )],
149                Body::from_stream(stream.bytes_stream()),
150            )
151        }
152        Err(_) => (
153            [("Content-Type", "image/svg+xml")],
154            Body::from(read_image(
155                pathd!("{}/images", database.config.static_dir),
156                "default-avatar.svg".to_string(),
157            )),
158        ),
159    }
160}
161
162/// Get a profile's banner image
163pub async fn banner_request(
164    Path(id): Path<String>,
165    State(database): State<Database>,
166) -> impl IntoResponse {
167    // get user
168    let auth_user = match database.get_profile(&id).await {
169        Ok(ua) => ua,
170        Err(_) => {
171            return (
172                [("Content-Type", "image/svg+xml")],
173                Body::from(read_image(
174                    pathd!("{}/images", database.config.static_dir),
175                    "default-banner.svg".to_string(),
176                )),
177            );
178        }
179    };
180
181    // ...
182    let banner_url = match auth_user.metadata.kv.get("sparkler:banner_url") {
183        Some(r) => r,
184        None => "",
185    };
186
187    if (banner_url == "rb://") && !database.config.media_dir.to_string().is_empty() {
188        return (
189            [("Content-Type", "image/avif")],
190            Body::from(read_image(
191                pathd!("{}/banners", database.config.media_dir),
192                format!("{}.avif", &auth_user.id),
193            )),
194        );
195    }
196
197    if banner_url.starts_with(&database.config.host) {
198        return (
199            [("Content-Type", "image/svg+xml")],
200            Body::from(read_image(
201                pathd!("{}/images", database.config.static_dir),
202                "default-banner.svg".to_string(),
203            )),
204        );
205    }
206
207    for host in database.config.blocked_hosts {
208        if banner_url.starts_with(&host) {
209            return (
210                [("Content-Type", "image/svg+xml")],
211                Body::from(read_image(
212                    pathd!("{}/images", database.config.static_dir),
213                    "default-banner.svg".to_string(),
214                )),
215            );
216        }
217    }
218
219    // get profile image
220    if banner_url.is_empty() {
221        return (
222            [("Content-Type", "image/svg+xml")],
223            Body::from(read_image(
224                pathd!("{}/images", database.config.static_dir),
225                "default-banner.svg".to_string(),
226            )),
227        );
228    }
229
230    let guessed_mime = mime_guess::from_path(banner_url)
231        .first_raw()
232        .unwrap_or("application/octet-stream");
233
234    match database.http.get(banner_url).send().await {
235        Ok(stream) => {
236            let size = stream.content_length();
237            if size.unwrap_or_default() > 10485760 {
238                // return defualt image (content too big)
239                return (
240                    [("Content-Type", "image/svg+xml")],
241                    Body::from(read_image(
242                        pathd!("{}/images", database.config.static_dir),
243                        "default-banner.svg".to_string(),
244                    )),
245                );
246            }
247
248            if let Some(ct) = stream.headers().get("Content-Type") {
249                let ct = ct.to_str().unwrap();
250                let bad_ct = vec!["text/html", "text/plain"];
251                if (!ct.starts_with("image/") && !ct.starts_with("font/")) | bad_ct.contains(&ct) {
252                    // if we got html, return default banner (likely an error page)
253                    return (
254                        [("Content-Type", "image/svg+xml")],
255                        Body::from(read_image(
256                            pathd!("{}/images", database.config.static_dir),
257                            "default-banner.svg".to_string(),
258                        )),
259                    );
260                }
261            }
262
263            (
264                [(
265                    "Content-Type",
266                    if guessed_mime == "text/html" {
267                        "text/plain"
268                    } else {
269                        guessed_mime
270                    },
271                )],
272                Body::from_stream(stream.bytes_stream()),
273            )
274        }
275        Err(_) => (
276            [("Content-Type", "image/svg+xml")],
277            Body::from(read_image(
278                pathd!("{}/images", database.config.static_dir),
279                "default-banner.svg".to_string(),
280            )),
281        ),
282    }
283}
284
285/// View a profile's information
286pub async fn get_request(
287    Path(id): Path<String>,
288    State(database): State<Database>,
289) -> impl IntoResponse {
290    // get user
291    let mut auth_user = match database.get_profile(&id).await {
292        Ok(ua) => ua,
293        Err(e) => return Json(e.to_json()),
294    };
295
296    // clean profile
297    auth_user.clean();
298
299    // return
300    Json(DefaultReturn {
301        success: true,
302        message: auth_user.id.to_string(),
303        payload: Some(auth_user),
304    })
305}
306
307/// View a profile's information from auth token (no cleaning)
308pub async fn get_from_token_request(
309    Path(token): Path<String>,
310    State(database): State<Database>,
311) -> impl IntoResponse {
312    // get user
313    let auth_user = match database.get_profile_by_unhashed(&token).await {
314        Ok(ua) => ua,
315        Err(e) => return Json(e.to_json()),
316    };
317
318    // return
319    Json(DefaultReturn {
320        success: true,
321        message: auth_user.username.to_string(),
322        payload: Some(auth_user),
323    })
324}
325
326/// Change a profile's tier
327pub async fn update_tier_request(
328    jar: CookieJar,
329    Path(id): Path<String>,
330    State(database): State<Database>,
331    Json(props): Json<SetProfileTier>,
332) -> impl IntoResponse {
333    // get user from token
334    let auth_user = match jar.get("__Secure-Token") {
335        Some(c) => {
336            let token = c.value_trimmed();
337
338            match database.get_profile_by_unhashed(token).await {
339                Ok(ua) => {
340                    // check token permission
341                    if !ua
342                        .token_context_from_token(&token)
343                        .can_do(TokenPermission::Moderator)
344                    {
345                        return Json(DatabaseError::NotAllowed.to_json());
346                    }
347
348                    // return
349                    ua
350                }
351                Err(e) => return Json(e.to_json()),
352            }
353        }
354        None => return Json(DatabaseError::NotAllowed.to_json()),
355    };
356
357    // check permission
358    let group = match database.get_group_by_id(auth_user.group).await {
359        Ok(g) => g,
360        Err(e) => return Json(e.to_json()),
361    };
362
363    if !group.permissions.check(FinePermission::MANAGE_PROFILE_TIER) {
364        // we must have the "Manager" permission to edit other users
365        return Json(DefaultReturn {
366            success: false,
367            message: DatabaseError::NotAllowed.to_string(),
368            payload: None,
369        });
370    }
371
372    // get other user
373    let other_user = match database.get_profile(&id).await {
374        Ok(ua) => ua,
375        Err(e) => return Json(e.to_json()),
376    };
377
378    // check permission
379    let group = match database.get_group_by_id(other_user.group).await {
380        Ok(g) => g,
381        Err(e) => return Json(e.to_json()),
382    };
383
384    if group.permissions.check(FinePermission::MANAGE_PROFILE_TIER) {
385        return Json(DefaultReturn {
386            success: false,
387            message: DatabaseError::NotAllowed.to_string(),
388            payload: None,
389        });
390    }
391
392    // push update
393    // TODO: try not to clone
394    if let Err(e) = database.update_profile_tier(&id, props.tier).await {
395        return Json(e.to_json());
396    }
397
398    // return
399    Json(DefaultReturn {
400        success: true,
401        message: "Acceptable".to_string(),
402        payload: Some(props.tier),
403    })
404}
405
406/// Change a profile's group
407pub async fn update_group_request(
408    jar: CookieJar,
409    Path(id): Path<String>,
410    State(database): State<Database>,
411    Json(props): Json<SetProfileGroup>,
412) -> impl IntoResponse {
413    // get user from token
414    let auth_user = match jar.get("__Secure-Token") {
415        Some(c) => match database.get_profile_by_unhashed(c.value_trimmed()).await {
416            Ok(ua) => ua,
417            Err(e) => return Json(e.to_json()),
418        },
419        None => return Json(DatabaseError::NotAllowed.to_json()),
420    };
421
422    // check permission
423    let our_group = match database.get_group_by_id(auth_user.group).await {
424        Ok(g) => g,
425        Err(e) => return Json(e.to_json()),
426    };
427
428    if !our_group
429        .permissions
430        .check(FinePermission::MANAGE_PROFILE_GROUP)
431    {
432        // we must have the "Manager" permission to edit other users
433        return Json(DatabaseError::NotAllowed.to_json());
434    }
435
436    // get other user
437    let other_user = match database.get_profile(&id).await {
438        Ok(ua) => ua,
439        Err(e) => return Json(e.to_json()),
440    };
441
442    // check permission
443    let other_group = match database.get_group_by_id(other_user.group).await {
444        Ok(g) => g,
445        Err(e) => return Json(e.to_json()),
446    };
447
448    if other_group
449        .permissions
450        .check(FinePermission::MANAGE_PROFILE_GROUP)
451    {
452        return Json(DatabaseError::NotAllowed.to_json());
453    }
454
455    // check group
456    if props.group != -1 {
457        if let Err(e) = database.get_group_by_id(props.group).await {
458            return Json(e.to_json());
459        }
460
461        if !our_group.permissions.check(FinePermission::PROMOTE_USERS) {
462            // non-managers **cannot** promote people to helper
463            return Json(DatabaseError::NotAllowed.to_json());
464        }
465    }
466
467    // push update
468    // TODO: try not to clone
469    if let Err(e) = database
470        .update_profile_group(&other_user.id, props.group)
471        .await
472    {
473        return Json(e.to_json());
474    }
475
476    // return
477    if let Err(e) = database
478        .audit(
479            &auth_user.id,
480            &format!(
481                "Changed user group: [{}](/+u/{})",
482                other_user.id, other_user.id
483            ),
484        )
485        .await
486    {
487        return Json(e.to_json());
488    };
489
490    Json(DefaultReturn {
491        success: true,
492        message: "Acceptable".to_string(),
493        payload: Some(props.group),
494    })
495}
496
497/// Change a profile's coins
498pub async fn update_coins_request(
499    jar: CookieJar,
500    Path(id): Path<String>,
501    State(database): State<Database>,
502    Json(props): Json<SetProfileCoins>,
503) -> impl IntoResponse {
504    // get user from token
505    let auth_user = match jar.get("__Secure-Token") {
506        Some(c) => match database.get_profile_by_unhashed(c.value_trimmed()).await {
507            Ok(ua) => ua,
508            Err(e) => return Json(e.to_json()),
509        },
510        None => return Json(DatabaseError::NotAllowed.to_json()),
511    };
512
513    // check permission
514    let group = match database.get_group_by_id(auth_user.group).await {
515        Ok(g) => g,
516        Err(e) => return Json(e.to_json()),
517    };
518
519    if !group.permissions.check(FinePermission::ECON_MASTER) {
520        // we must have the "Manager" permission to edit other users
521        return Json(DefaultReturn {
522            success: false,
523            message: DatabaseError::NotAllowed.to_string(),
524            payload: None,
525        });
526    }
527
528    // get other user
529    let other_user = match database.get_profile(&id).await {
530        Ok(ua) => ua,
531        Err(e) => return Json(e.to_json()),
532    };
533
534    // push update
535    // TODO: try not to clone
536    if let Err(e) = database
537        .update_profile_coins(&other_user.id, props.coins)
538        .await
539    {
540        return Json(e.to_json());
541    }
542
543    // return
544    if let Err(e) = database
545        .audit(
546            &auth_user.id,
547            &format!(
548                "Updated user coin balance: [{}](/+u/{})",
549                other_user.id, other_user.id
550            ),
551        )
552        .await
553    {
554        return Json(e.to_json());
555    };
556
557    Json(DefaultReturn {
558        success: true,
559        message: "Acceptable".to_string(),
560        payload: Some(props.coins),
561    })
562}
563
564/// Update the given user's session tokens
565pub async fn update_tokens_request(
566    jar: CookieJar,
567    Path(id): Path<String>,
568    State(database): State<Database>,
569    Json(req): Json<super::me::UpdateTokens>,
570) -> impl IntoResponse {
571    // get user from token
572    let auth_user = match jar.get("__Secure-Token") {
573        Some(c) => {
574            let token = c.value_trimmed();
575
576            match database.get_profile_by_unhashed(token).await {
577                Ok(ua) => {
578                    // check token permission
579                    if !ua
580                        .token_context_from_token(&token)
581                        .can_do(TokenPermission::Moderator)
582                    {
583                        return Json(DatabaseError::NotAllowed.to_json());
584                    }
585
586                    // return
587                    ua
588                }
589                Err(e) => return Json(e.to_json()),
590            }
591        }
592        None => return Json(DatabaseError::NotAllowed.to_json()),
593    };
594
595    let mut other = match database.get_profile(&id).await {
596        Ok(o) => o,
597        Err(e) => return Json(e.to_json()),
598    };
599
600    if auth_user.id == other.id {
601        return Json(DefaultReturn {
602            success: false,
603            message: DatabaseError::NotAllowed.to_string(),
604            payload: (),
605        });
606    }
607
608    let group = match database.get_group_by_id(auth_user.group).await {
609        Ok(g) => g,
610        Err(e) => return Json(e.to_json()),
611    };
612
613    if !group.permissions.check(FinePermission::EDIT_USER) {
614        // we must have the "Manager" permission to edit other users
615        return Json(DefaultReturn {
616            success: false,
617            message: DatabaseError::NotAllowed.to_string(),
618            payload: (),
619        });
620    }
621
622    // check permission
623    let group = match database.get_group_by_id(other.group).await {
624        Ok(g) => g,
625        Err(e) => return Json(e.to_json()),
626    };
627
628    if group.permissions.check(FinePermission::ADMINISTRATOR) {
629        // we cannot manager other managers
630        return Json(DefaultReturn {
631            success: false,
632            message: DatabaseError::NotAllowed.to_string(),
633            payload: (),
634        });
635    }
636
637    // for every token that doesn't have a context, insert the default context
638    for (i, _) in other.tokens.clone().iter().enumerate() {
639        if let None = other.token_context.get(i) {
640            other.token_context.insert(i, TokenContext::default())
641        }
642    }
643
644    // get diff
645    let mut removed_indexes = Vec::new();
646
647    for (i, token) in other.tokens.iter().enumerate() {
648        if !req.tokens.contains(token) {
649            removed_indexes.push(i);
650        }
651    }
652
653    // edit dependent vecs
654    for i in removed_indexes.clone() {
655        if (other.ips.len() < i) | (other.ips.len() == 0) {
656            break;
657        }
658
659        other.ips.remove(i);
660    }
661
662    for i in removed_indexes.clone() {
663        if (other.token_context.len() < i) | (other.token_context.len() == 0) {
664            break;
665        }
666
667        other.token_context.remove(i);
668    }
669
670    // return
671    if let Err(e) = database
672        .update_profile_tokens(&other.id, req.tokens, other.ips, other.token_context)
673        .await
674    {
675        return Json(e.to_json());
676    }
677
678    Json(DefaultReturn {
679        success: true,
680        message: "Tokens updated!".to_string(),
681        payload: (),
682    })
683}
684
685/// Generate a new token and session (like logging in while already logged in)
686pub async fn generate_token_request(
687    jar: CookieJar,
688    headers: HeaderMap,
689    Path(id): Path<String>,
690    State(database): State<Database>,
691    Json(props): Json<TokenContext>,
692) -> impl IntoResponse {
693    // get user from token
694    let auth_user = match jar.get("__Secure-Token") {
695        Some(c) => {
696            let token = c.value_trimmed();
697
698            match database.get_profile_by_unhashed(token).await {
699                Ok(ua) => {
700                    // check token permission
701                    if !ua
702                        .token_context_from_token(&token)
703                        .can_do(TokenPermission::Moderator)
704                    {
705                        return Json(DatabaseError::NotAllowed.to_json());
706                    }
707
708                    // return
709                    ua
710                }
711                Err(e) => return Json(e.to_json()),
712            }
713        }
714        None => return Json(DatabaseError::NotAllowed.to_json()),
715    };
716
717    let mut other = match database.get_profile(&id).await {
718        Ok(o) => o,
719        Err(e) => return Json(e.to_json()),
720    };
721
722    if auth_user.id == other.id {
723        return Json(DefaultReturn {
724            success: false,
725            message: DatabaseError::NotAllowed.to_string(),
726            payload: None,
727        });
728    }
729
730    let group = match database.get_group_by_id(auth_user.group).await {
731        Ok(g) => g,
732        Err(e) => return Json(e.to_json()),
733    };
734
735    if !group.permissions.check(FinePermission::EDIT_USER) {
736        // we must have the "Manager" permission to edit other users
737        return Json(DefaultReturn {
738            success: false,
739            message: DatabaseError::NotAllowed.to_string(),
740            payload: None,
741        });
742    }
743
744    // check permission
745    let group = match database.get_group_by_id(other.group).await {
746        Ok(g) => g,
747        Err(e) => return Json(e.to_json()),
748    };
749
750    if group.permissions.check(FinePermission::ADMINISTRATOR) {
751        // we cannot manager other managers
752        return Json(DefaultReturn {
753            success: false,
754            message: DatabaseError::NotAllowed.to_string(),
755            payload: None,
756        });
757    }
758
759    // for every token that doesn't have a context, insert the default context
760    for (i, _) in other.tokens.clone().iter().enumerate() {
761        if let None = other.token_context.get(i) {
762            other.token_context.insert(i, TokenContext::default())
763        }
764    }
765
766    // get real ip
767    let real_ip = if let Some(ref real_ip_header) = database.config.real_ip_header {
768        headers
769            .get(real_ip_header.to_owned())
770            .unwrap_or(&HeaderValue::from_static(""))
771            .to_str()
772            .unwrap_or("")
773            .to_string()
774    } else {
775        String::new()
776    };
777
778    // check ip
779    if database.get_ipban_by_ip(&real_ip).await.is_ok() {
780        return Json(DefaultReturn {
781            success: false,
782            message: DatabaseError::NotAllowed.to_string(),
783            payload: None,
784        });
785    }
786
787    // ...
788    let token = databeam::utility::uuid();
789    let token_hashed = databeam::utility::hash(token.clone());
790
791    other.tokens.push(token_hashed);
792    other.ips.push(String::new()); // don't actually store ip, this endpoint is used by external apps
793    other.token_context.push(props);
794
795    database
796        .update_profile_tokens(&other.id, other.tokens, other.ips, other.token_context)
797        .await
798        .unwrap();
799
800    // return
801    return Json(DefaultReturn {
802        success: true,
803        message: "Generated token!".to_string(),
804        payload: Some(token),
805    });
806}
807
808/// Change a profile's password
809pub async fn update_password_request(
810    jar: CookieJar,
811    Path(id): Path<String>,
812    State(database): State<Database>,
813    Json(props): Json<SetProfilePassword>,
814) -> impl IntoResponse {
815    // get user from token
816    let auth_user = match jar.get("__Secure-Token") {
817        Some(c) => {
818            let token = c.value_trimmed();
819
820            match database.get_profile_by_unhashed(token).await {
821                Ok(ua) => {
822                    // check token permission
823                    if !ua
824                        .token_context_from_token(&token)
825                        .can_do(TokenPermission::ManageAccount)
826                    {
827                        return Json(DatabaseError::NotAllowed.to_json());
828                    }
829
830                    // return
831                    ua
832                }
833                Err(e) => return Json(e.to_json()),
834            }
835        }
836        None => return Json(DatabaseError::NotAllowed.to_json()),
837    };
838
839    // check permission
840    let mut is_manager = false;
841    if auth_user.id != id && auth_user.username != id {
842        let group = match database.get_group_by_id(auth_user.group).await {
843            Ok(g) => g,
844            Err(e) => {
845                return Json(DefaultReturn {
846                    success: false,
847                    message: e.to_string(),
848                    payload: None,
849                })
850            }
851        };
852
853        if !group.permissions.check(FinePermission::EDIT_USER) {
854            // we must have the "Manager" permission to edit other users
855            return Json(DatabaseError::NotAllowed.to_json());
856        } else {
857            is_manager = true;
858        }
859    }
860
861    // check user permissions
862    // returning NotAllowed here will block them from editing their profile
863    // we don't want to waste resources on rule breakers
864    if auth_user.group == -1 {
865        // group -1 (even if it exists) is for marking users as banned
866        return Json(DefaultReturn {
867            success: false,
868            message: DatabaseError::NotAllowed.to_string(),
869            payload: None,
870        });
871    }
872
873    // push update
874    // TODO: try not to clone
875    if let Err(e) = database
876        .update_profile_password(&id, &props.password, &props.new_password, !is_manager)
877        .await
878    {
879        return Json(e.to_json());
880    }
881
882    // return
883    Json(DefaultReturn {
884        success: true,
885        message: "Acceptable".to_string(),
886        payload: Some(props.new_password),
887    })
888}
889
890/// Change a profile's username
891pub async fn update_username_request(
892    jar: CookieJar,
893    Path(id): Path<String>,
894    State(database): State<Database>,
895    Json(props): Json<SetProfileUsername>,
896) -> impl IntoResponse {
897    // get user from token
898    let auth_user = match jar.get("__Secure-Token") {
899        Some(c) => {
900            let token = c.value_trimmed();
901
902            match database.get_profile_by_unhashed(token).await {
903                Ok(ua) => {
904                    // check token permission
905                    if !ua
906                        .token_context_from_token(&token)
907                        .can_do(TokenPermission::ManageAccount)
908                    {
909                        return Json(DatabaseError::NotAllowed.to_json());
910                    }
911
912                    // return
913                    ua
914                }
915                Err(e) => return Json(e.to_json()),
916            }
917        }
918        None => return Json(DatabaseError::NotAllowed.to_json()),
919    };
920
921    // check permission
922    if auth_user.id != id && auth_user.username != id {
923        let group = match database.get_group_by_id(auth_user.group).await {
924            Ok(g) => g,
925            Err(e) => {
926                return Json(DefaultReturn {
927                    success: false,
928                    message: e.to_string(),
929                    payload: None,
930                })
931            }
932        };
933
934        if !group.permissions.check(FinePermission::EDIT_USER) {
935            // we must have the "Manager" permission to edit other users
936            return Json(DatabaseError::NotAllowed.to_json());
937        }
938    }
939
940    // check user permissions
941    // returning NotAllowed here will block them from editing their profile
942    // we don't want to waste resources on rule breakers
943    if auth_user.group == -1 {
944        // group -1 (even if it exists) is for marking users as banned
945        return Json(DefaultReturn {
946            success: false,
947            message: DatabaseError::NotAllowed.to_string(),
948            payload: None,
949        });
950    }
951
952    // push update
953    // TODO: try not to clone
954    if let Err(e) = database
955        .update_profile_username(&id, &props.password, &props.new_name)
956        .await
957    {
958        return Json(e.to_json());
959    }
960
961    // return
962    Json(DefaultReturn {
963        success: true,
964        message: "Acceptable".to_string(),
965        payload: Some(props.new_name),
966    })
967}
968
969/// Update a user's metadata
970pub async fn update_metdata_request(
971    jar: CookieJar,
972    Path(id): Path<String>,
973    State(database): State<Database>,
974    Json(props): Json<SetProfileMetadata>,
975) -> impl IntoResponse {
976    // get user from token
977    let auth_user = match jar.get("__Secure-Token") {
978        Some(c) => {
979            let token = c.value_trimmed();
980
981            match database.get_profile_by_unhashed(token).await {
982                Ok(ua) => {
983                    // check token permission
984                    if !ua
985                        .token_context_from_token(&token)
986                        .can_do(TokenPermission::ManageProfile)
987                    {
988                        return Json(DatabaseError::NotAllowed.to_json());
989                    }
990
991                    // return
992                    ua
993                }
994                Err(e) => return Json(e.to_json()),
995            }
996        }
997        None => return Json(DatabaseError::NotAllowed.to_json()),
998    };
999
1000    // check permission
1001    if auth_user.id != id && auth_user.username != id {
1002        let group = match database.get_group_by_id(auth_user.group).await {
1003            Ok(g) => g,
1004            Err(e) => {
1005                return Json(DefaultReturn {
1006                    success: false,
1007                    message: e.to_string(),
1008                    payload: (),
1009                })
1010            }
1011        };
1012
1013        if !group
1014            .permissions
1015            .check(FinePermission::MANAGE_PROFILE_SETTINGS)
1016        {
1017            // we cannot manager other managers
1018            return Json(DatabaseError::NotAllowed.to_json());
1019        }
1020    }
1021
1022    // check user permissions
1023    // returning NotAllowed here will block them from editing their profile
1024    // we don't want to waste resources on rule breakers
1025    if auth_user.group == -1 {
1026        // group -1 (even if it exists) is for marking users as banned
1027        return Json(DefaultReturn {
1028            success: false,
1029            message: DatabaseError::NotAllowed.to_string(),
1030            payload: (),
1031        });
1032    }
1033
1034    // return
1035    match database.update_profile_metadata(&id, props.metadata).await {
1036        Ok(_) => Json(DefaultReturn {
1037            success: true,
1038            message: "Acceptable".to_string(),
1039            payload: (),
1040        }),
1041        Err(e) => Json(e.to_json()),
1042    }
1043}
1044
1045/// Patch a user's metadata
1046pub async fn patch_metdata_request(
1047    jar: CookieJar,
1048    Path(id): Path<String>,
1049    State(database): State<Database>,
1050    Json(props): Json<SetProfileMetadata>,
1051) -> impl IntoResponse {
1052    // get user from token
1053    let auth_user = match jar.get("__Secure-Token") {
1054        Some(c) => {
1055            let token = c.value_trimmed();
1056
1057            match database.get_profile_by_unhashed(token).await {
1058                Ok(ua) => {
1059                    // check token permission
1060                    if !ua
1061                        .token_context_from_token(&token)
1062                        .can_do(TokenPermission::ManageProfile)
1063                    {
1064                        return Json(DatabaseError::NotAllowed.to_json());
1065                    }
1066
1067                    // return
1068                    ua
1069                }
1070                Err(e) => return Json(e.to_json()),
1071            }
1072        }
1073        None => return Json(DatabaseError::NotAllowed.to_json()),
1074    };
1075
1076    // get other user
1077    let other_user = match database.get_profile(&id).await {
1078        Ok(ua) => ua,
1079        Err(e) => return Json(e.to_json()),
1080    };
1081
1082    // check permission
1083    if auth_user.id != id && auth_user.username != id {
1084        let group = match database.get_group_by_id(auth_user.group).await {
1085            Ok(g) => g,
1086            Err(e) => {
1087                return Json(DefaultReturn {
1088                    success: false,
1089                    message: e.to_string(),
1090                    payload: (),
1091                })
1092            }
1093        };
1094
1095        if !group
1096            .permissions
1097            .check(FinePermission::MANAGE_PROFILE_SETTINGS)
1098        {
1099            // we must have the "Manager" permission to edit other users
1100            return Json(DatabaseError::NotAllowed.to_json());
1101        }
1102    }
1103
1104    // check user permissions
1105    // returning NotAllowed here will block them from editing their profile
1106    // we don't want to waste resources on rule breakers
1107    if auth_user.group == -1 {
1108        // group -1 (even if it exists) is for marking users as banned
1109        return Json(DefaultReturn {
1110            success: false,
1111            message: DatabaseError::NotAllowed.to_string(),
1112            payload: (),
1113        });
1114    }
1115
1116    // patch metadata
1117    let mut metadata = other_user.metadata.clone();
1118
1119    for kv in props.metadata.kv {
1120        metadata.kv.insert(kv.0, kv.1);
1121    }
1122
1123    if props.metadata.policy_consent != metadata.policy_consent {
1124        metadata.policy_consent = props.metadata.policy_consent;
1125    }
1126
1127    // return
1128    match database.update_profile_metadata(&id, metadata).await {
1129        Ok(_) => Json(DefaultReturn {
1130            success: true,
1131            message: "Acceptable".to_string(),
1132            payload: (),
1133        }),
1134        Err(e) => Json(e.to_json()),
1135    }
1136}
1137
1138/// Update a user's badges
1139pub async fn update_badges_request(
1140    jar: CookieJar,
1141    Path(id): Path<String>,
1142    State(database): State<Database>,
1143    Json(props): Json<SetProfileBadges>,
1144) -> impl IntoResponse {
1145    // get user from token
1146    let auth_user = match jar.get("__Secure-Token") {
1147        Some(c) => {
1148            let token = c.value_trimmed();
1149
1150            match database.get_profile_by_unhashed(token).await {
1151                Ok(ua) => {
1152                    // check token permission
1153                    if !ua
1154                        .token_context_from_token(&token)
1155                        .can_do(TokenPermission::Moderator)
1156                    {
1157                        return Json(DatabaseError::NotAllowed.to_json());
1158                    }
1159
1160                    // return
1161                    ua
1162                }
1163                Err(e) => return Json(e.to_json()),
1164            }
1165        }
1166        None => return Json(DatabaseError::NotAllowed.to_json()),
1167    };
1168
1169    // check permission
1170    let group = match database.get_group_by_id(auth_user.group).await {
1171        Ok(g) => g,
1172        Err(e) => return Json(e.to_json()),
1173    };
1174
1175    if !group
1176        .permissions
1177        .check(FinePermission::MANAGE_PROFILE_SETTINGS)
1178    {
1179        // we must have the "Helper" permission to edit other users' badges
1180        return Json(DefaultReturn {
1181            success: false,
1182            message: DatabaseError::NotAllowed.to_string(),
1183            payload: (),
1184        });
1185    }
1186
1187    // return
1188    match database.update_profile_badges(&id, props.badges).await {
1189        Ok(_) => Json(DefaultReturn {
1190            success: true,
1191            message: "Acceptable".to_string(),
1192            payload: (),
1193        }),
1194        Err(e) => Json(e.to_json()),
1195    }
1196}
1197
1198/// Update a user's labels
1199pub async fn update_labels_request(
1200    jar: CookieJar,
1201    Path(id): Path<String>,
1202    State(database): State<Database>,
1203    Json(props): Json<SetProfileLabels>,
1204) -> impl IntoResponse {
1205    // get user from token
1206    let auth_user = match jar.get("__Secure-Token") {
1207        Some(c) => {
1208            let token = c.value_trimmed();
1209
1210            match database.get_profile_by_unhashed(token).await {
1211                Ok(ua) => {
1212                    // check token permission
1213                    if !ua
1214                        .token_context_from_token(&token)
1215                        .can_do(TokenPermission::Moderator)
1216                    {
1217                        return Json(DatabaseError::NotAllowed.to_json());
1218                    }
1219
1220                    // return
1221                    ua
1222                }
1223                Err(e) => return Json(e.to_json()),
1224            }
1225        }
1226        None => return Json(DatabaseError::NotAllowed.to_json()),
1227    };
1228
1229    // check permission
1230    let group = match database.get_group_by_id(auth_user.group).await {
1231        Ok(g) => g,
1232        Err(e) => return Json(e.to_json()),
1233    };
1234
1235    if !group
1236        .permissions
1237        .check(FinePermission::MANAGE_PROFILE_SETTINGS)
1238    {
1239        // we must have the "Helper" permission to edit other users' badges
1240        return Json(DefaultReturn {
1241            success: false,
1242            message: DatabaseError::NotAllowed.to_string(),
1243            payload: (),
1244        });
1245    }
1246
1247    // return
1248    match database.update_profile_labels(&id, props.labels).await {
1249        Ok(_) => Json(DefaultReturn {
1250            success: true,
1251            message: "Acceptable".to_string(),
1252            payload: (),
1253        }),
1254        Err(e) => Json(e.to_json()),
1255    }
1256}
1257
1258/// Update a user's links
1259pub async fn update_links_request(
1260    jar: CookieJar,
1261    Path(id): Path<String>,
1262    State(database): State<Database>,
1263    Json(props): Json<SetProfileLinks>,
1264) -> impl IntoResponse {
1265    // get user from token
1266    let auth_user = match jar.get("__Secure-Token") {
1267        Some(c) => {
1268            let token = c.value_trimmed();
1269
1270            match database.get_profile_by_unhashed(token).await {
1271                Ok(ua) => {
1272                    // check token permission
1273                    if !ua
1274                        .token_context_from_token(&token)
1275                        .can_do(TokenPermission::Moderator)
1276                    {
1277                        return Json(DatabaseError::NotAllowed.to_json());
1278                    }
1279
1280                    // return
1281                    ua
1282                }
1283                Err(e) => return Json(e.to_json()),
1284            }
1285        }
1286        None => return Json(DatabaseError::NotAllowed.to_json()),
1287    };
1288
1289    // check permission
1290    let group = match database.get_group_by_id(auth_user.group).await {
1291        Ok(g) => g,
1292        Err(e) => return Json(e.to_json()),
1293    };
1294
1295    if (auth_user.id != id)
1296        && !group
1297            .permissions
1298            .check(FinePermission::MANAGE_PROFILE_SETTINGS)
1299    {
1300        return Json(DatabaseError::NotAllowed.to_json());
1301    }
1302
1303    // return
1304    match database.update_profile_links(&id, props.links).await {
1305        Ok(_) => Json(DefaultReturn {
1306            success: true,
1307            message: "Acceptable".to_string(),
1308            payload: (),
1309        }),
1310        Err(e) => Json(e.to_json()),
1311    }
1312}
1313
1314/// Update a user's layout
1315pub async fn update_layout_request(
1316    jar: CookieJar,
1317    Path(id): Path<String>,
1318    State(database): State<Database>,
1319    Json(props): Json<SetProfileLayout>,
1320) -> impl IntoResponse {
1321    // get user from token
1322    let auth_user = match jar.get("__Secure-Token") {
1323        Some(c) => {
1324            let token = c.value_trimmed();
1325
1326            match database.get_profile_by_unhashed(token).await {
1327                Ok(ua) => {
1328                    // check token permission
1329                    if !ua
1330                        .token_context_from_token(&token)
1331                        .can_do(TokenPermission::Moderator)
1332                    {
1333                        return Json(DatabaseError::NotAllowed.to_json());
1334                    }
1335
1336                    // return
1337                    ua
1338                }
1339                Err(e) => return Json(e.to_json()),
1340            }
1341        }
1342        None => return Json(DatabaseError::NotAllowed.to_json()),
1343    };
1344
1345    // check permission
1346    let group = match database.get_group_by_id(auth_user.group).await {
1347        Ok(g) => g,
1348        Err(e) => return Json(e.to_json()),
1349    };
1350
1351    if (auth_user.id != id)
1352        && !group
1353            .permissions
1354            .check(FinePermission::MANAGE_PROFILE_SETTINGS)
1355    {
1356        return Json(DatabaseError::NotAllowed.to_json());
1357    }
1358
1359    // return
1360    match database.update_profile_layout(&id, props.layout).await {
1361        Ok(_) => Json(DefaultReturn {
1362            success: true,
1363            message: "Acceptable".to_string(),
1364            payload: (),
1365        }),
1366        Err(e) => Json(e.to_json()),
1367    }
1368}
1369
1370#[derive(Serialize)]
1371struct LayoutRenderResult {
1372    pub block: String,
1373    pub tree: String,
1374}
1375
1376/// Render a layout (in block form).
1377pub async fn render_layout_request(Json(props): Json<RenderLayout>) -> impl IntoResponse {
1378    Json(LayoutRenderResult {
1379        block: props.layout.render_block(),
1380        tree: props.layout.render_tree(),
1381    })
1382}
1383
1384/// Delete another user
1385pub async fn delete_request(
1386    jar: CookieJar,
1387    Path(id): Path<String>,
1388    State(database): State<Database>,
1389) -> impl IntoResponse {
1390    // get user from token
1391    let auth_user = match jar.get("__Secure-Token") {
1392        Some(c) => match database.get_profile_by_unhashed(c.value_trimmed()).await {
1393            Ok(ua) => ua,
1394            Err(e) => return Json(e.to_json()),
1395        },
1396        None => return Json(DatabaseError::NotAllowed.to_json()),
1397    };
1398
1399    // check permission
1400    if auth_user.username != id {
1401        let group = match database.get_group_by_id(auth_user.group).await {
1402            Ok(g) => g,
1403            Err(e) => {
1404                return Json(DefaultReturn {
1405                    success: false,
1406                    message: e.to_string(),
1407                    payload: (),
1408                })
1409            }
1410        };
1411
1412        // get other user
1413        let other_user = match database.get_profile_by_id(&id).await {
1414            Ok(ua) => ua,
1415            Err(e) => return Json(e.to_json()),
1416        };
1417
1418        if !group.permissions.check(FinePermission::DELETE_USER) {
1419            // we must have the "Manager" permission to edit other users
1420            return Json(DatabaseError::NotAllowed.to_json());
1421        } else {
1422            let actor_id = auth_user.id;
1423            simplify!(
1424                database
1425                .create_notification(
1426                    NotificationCreate {
1427                        title: format!("[{actor_id}](/+u/{actor_id})"),
1428                        content: format!("Deleted a profile: @{}", other_user.username),
1429                        address: format!("/+u/{actor_id}"),
1430                        recipient: "*(audit)".to_string(), // all staff, audit
1431                    },
1432                    None,
1433                )
1434                .await; Err; Json(DatabaseError::Other.to_json())
1435            );
1436        }
1437
1438        // check permission
1439        let group = match database.get_group_by_id(other_user.group).await {
1440            Ok(g) => g,
1441            Err(e) => {
1442                return Json(DefaultReturn {
1443                    success: false,
1444                    message: e.to_string(),
1445                    payload: (),
1446                })
1447            }
1448        };
1449
1450        if group.permissions.check(FinePermission::DELETE_USER) {
1451            // we cannot manager other managers
1452            return Json(DatabaseError::NotAllowed.to_json());
1453        }
1454    }
1455
1456    // check user permissions
1457    // returning NotAllowed here will block them from editing their profile
1458    // we don't want to waste resources on rule breakers
1459    if auth_user.group == -1 {
1460        // group -1 (even if it exists) is for marking users as banned
1461        return Json(DefaultReturn {
1462            success: false,
1463            message: DatabaseError::NotAllowed.to_string(),
1464            payload: (),
1465        });
1466    }
1467
1468    // return
1469    match database.delete_profile_by_id(&id).await {
1470        Ok(_) => Json(DefaultReturn {
1471            success: true,
1472            message: "Acceptable".to_string(),
1473            payload: (),
1474        }),
1475        Err(e) => Json(e.to_json()),
1476    }
1477}
1478
1479/// Get a profile's css
1480pub async fn css_request(
1481    Path(id): Path<String>,
1482    State(database): State<Database>,
1483) -> impl IntoResponse {
1484    // get user
1485    let auth_user = match database.get_profile(&id).await {
1486        Ok(ua) => ua,
1487        Err(_) => {
1488            return String::new();
1489        }
1490    };
1491
1492    // ...
1493    let mut out: String = format!(
1494        "{}\n*, :root {{\n",
1495        auth_user
1496            .metadata
1497            .soft_get("rainbeam:market_theme_template")
1498    );
1499
1500    for style in auth_user.metadata.kv.clone() {
1501        if !style.0.starts_with("sparkler:color_") {
1502            continue;
1503        }
1504
1505        out.push_str(&format!(
1506            "    --{}: {};\n",
1507            style.0.replace("_", "-").replace("sparkler:", ""),
1508            style.1,
1509        ));
1510    }
1511
1512    out.push_str(&auth_user.metadata.soft_get("sparkler:custom_css"));
1513    format!("{out}\n}}")
1514}
1515
1516/// Enable TOTP for a user.
1517pub async fn enable_totp_request(
1518    jar: CookieJar,
1519    Path(id): Path<String>,
1520    State(database): State<Database>,
1521) -> impl IntoResponse {
1522    // get user from token
1523    let auth_user = match jar.get("__Secure-Token") {
1524        Some(c) => {
1525            let token = c.value_trimmed();
1526
1527            match database.get_profile_by_unhashed(token).await {
1528                Ok(ua) => {
1529                    // check token permission
1530                    if !ua
1531                        .token_context_from_token(&token)
1532                        .can_do(TokenPermission::ManageAccount)
1533                    {
1534                        return Json(DatabaseError::NotAllowed.to_json());
1535                    }
1536
1537                    // return
1538                    ua
1539                }
1540                Err(e) => return Json(e.to_json()),
1541            }
1542        }
1543        None => return Json(DatabaseError::NotAllowed.to_json()),
1544    };
1545
1546    // update
1547    match database.enable_totp(auth_user, &id).await {
1548        Ok(t) => {
1549            return Json(DefaultReturn {
1550                success: true,
1551                message: "TOTP enabled".to_string(),
1552                payload: Some(t),
1553            })
1554        }
1555        Err(e) => return Json(e.to_json()),
1556    }
1557}
1558
1559/// Disable TOTP for a user.
1560pub async fn disable_totp_request(
1561    jar: CookieJar,
1562    Path(id): Path<String>,
1563    State(database): State<Database>,
1564    Json(props): Json<TOTPDisable>,
1565) -> impl IntoResponse {
1566    // get user from token
1567    match jar.get("__Secure-Token") {
1568        Some(c) => {
1569            let token = c.value_trimmed();
1570
1571            match database.get_profile_by_unhashed(token).await {
1572                Ok(ua) => {
1573                    // check token permission
1574                    if !ua
1575                        .token_context_from_token(&token)
1576                        .can_do(TokenPermission::ManageAccount)
1577                    {
1578                        return Json(DatabaseError::NotAllowed.to_json());
1579                    }
1580
1581                    // return
1582                    ua
1583                }
1584                Err(e) => return Json(e.to_json()),
1585            }
1586        }
1587        None => return Json(DatabaseError::NotAllowed.to_json()),
1588    };
1589
1590    // get profile
1591    let profile = match database.get_profile(&id).await {
1592        Ok(p) => p,
1593        Err(e) => return Json(e.to_json()),
1594    };
1595
1596    // check totp
1597    if !database.check_totp(&profile, &props.totp) {
1598        return Json(DatabaseError::NotAllowed.to_json());
1599    }
1600
1601    // disable
1602    if let Err(e) = database
1603        .update_profile_totp_secret(&profile.id, "", &Vec::new())
1604        .await
1605    {
1606        return Json(e.to_json());
1607    }
1608
1609    // return
1610    Json(DefaultReturn {
1611        success: true,
1612        message: "TOTP disabled".to_string(),
1613        payload: (),
1614    })
1615}
1616
1617/// Refresh TOTP recovery codes for a user.
1618pub async fn refresh_totp_recovery_codes_request(
1619    jar: CookieJar,
1620    Path(id): Path<String>,
1621    State(database): State<Database>,
1622    Json(props): Json<TOTPDisable>,
1623) -> impl IntoResponse {
1624    // get user from token
1625    match jar.get("__Secure-Token") {
1626        Some(c) => {
1627            let token = c.value_trimmed();
1628
1629            match database.get_profile_by_unhashed(token).await {
1630                Ok(ua) => {
1631                    // check token permission
1632                    if !ua
1633                        .token_context_from_token(&token)
1634                        .can_do(TokenPermission::ManageAccount)
1635                    {
1636                        return Json(DatabaseError::NotAllowed.to_json());
1637                    }
1638
1639                    // return
1640                    ua
1641                }
1642                Err(e) => return Json(e.to_json()),
1643            }
1644        }
1645        None => return Json(DatabaseError::NotAllowed.to_json()),
1646    };
1647
1648    // get profile
1649    let profile = match database.get_profile(&id).await {
1650        Ok(p) => p,
1651        Err(e) => return Json(e.to_json()),
1652    };
1653
1654    // check totp
1655    if !database.check_totp(&profile, &props.totp) {
1656        return Json(DatabaseError::NotAllowed.to_json());
1657    }
1658
1659    // update
1660    let recovery = Database::generate_totp_recovery_codes();
1661
1662    if let Err(e) = database
1663        .update_profile_totp_secret(&profile.id, &profile.totp, &recovery)
1664        .await
1665    {
1666        return Json(e.to_json());
1667    }
1668
1669    // return
1670    Json(DefaultReturn {
1671        success: true,
1672        message: "TOTP disabled".to_string(),
1673        payload: Some(recovery),
1674    })
1675}