use std::sync::Arc; use either::Either; use rocket::{ data::ToByteUnit, http::{uri::Absolute, ContentType, Status}, response::{self, Responder}, serde::json::Json, Request, Response, State, }; use serde::{Deserialize, Serialize}; use serde_json::json; use sha2::{Digest, Sha256}; use Either::{Left, Right}; use crate::{ api::Empty, auth::{Authenticated, AuthenticatedRequest, WithBearer}, cache::Immutable, db::Db, types::{oauth::Scope, UserID}, utils::DeferAction, }; use crate::{db::DbConn, types::AvatarID, Config}; use crate::{push::PushClient, types::Avatar}; use super::EMPTY; // we don't provide any additional fields. some we can't provide anyway (eg // invalid parameter `validation`), others are implied by the request body (eg // account exists `email`), and *our* client doesn't care about them anyway #[derive(Debug)] pub(crate) enum Error { Unauthorized, InvalidParameter, PayloadTooLarge, NotFound, // this is actually a response from the auth api (not the profile api), // but firefox needs the *exact response* of this auth error to refresh // profile fetch oauth tokens for its ui. :( InvalidAuthToken, Other(anyhow::Error), UnexpectedStatus(Status), } #[rustfmt::skip] impl<'r> Responder<'r, 'static> for Error { fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { let (code, errno, msg) = match self { Error::Unauthorized => (Status::Forbidden, 100, "unauthorized"), Error::InvalidParameter => (Status::BadRequest, 101, "invalid parameter in request body"), Error::PayloadTooLarge => (Status::PayloadTooLarge, 999, "payload too large"), Error::NotFound => (Status::NotFound, 999, "not found"), Error::InvalidAuthToken => (Status::Unauthorized, 110, "invalid authentication token"), Error::Other(e) => { error!("non-api error during request: {:?}", e); (Status::InternalServerError, 999, "internal error") }, Error::UnexpectedStatus(s) => (s, 999, ""), }; let body = json!({ "code": code.code, "errno": errno, "error": code.reason_lossy(), "message": msg }); Response::build_from(Json(body).respond_to(request)?).status(code).ok() } } impl From for Error { fn from(e: sqlx::Error) -> Self { Error::Other(anyhow!(e)) } } impl From for Error { fn from(e: anyhow::Error) -> Self { Error::Other(e) } } pub(crate) type Result = std::result::Result, Error>; #[catch(default)] pub(crate) fn catch_all(status: Status, r: &Request<'_>) -> Error { match status.code { // these three are caused by Json errors 400 | 422 => Error::InvalidParameter, 413 => Error::PayloadTooLarge, // translate forbidden-because-token to the auth api error for firefox 401 if r.invalid_token_used() => Error::InvalidAuthToken, // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints 401 | 404 => Error::Unauthorized, _ => { error!("caught unexpected error {status}"); Error::UnexpectedStatus(status) }, } } // MISSING GET /v1/email // MISSING GET /v1/subscriptions // MISSING GET /v1/uid // MISSING GET /v1/display_name // MISSING DELETE /v1/cache/:uid // MISSING send profile:change webchannel event an avatar/name changes #[derive(Debug, Serialize)] #[allow(non_snake_case)] pub(crate) struct ProfileResp { uid: Option, email: Option, locale: Option, amrValues: Option>, twoFactorAuthentication: bool, displayName: Option, // NOTE spec does not exist, fxa-profile-server schema says this field is optional, // but fenix exceptions if it's null. // NOTE it also *must* be a valid url, or fenix crashes entirely. avatar: Absolute<'static>, avatarDefault: bool, subscriptions: Option>, } #[get("/profile")] pub(crate) async fn profile( db: &DbConn, cfg: &State, auth: Authenticated<(), WithBearer>, ) -> Result { let has_scope = |s| auth.context.implies(&Scope::borrowed(s)); let user = db.get_user_by_id(&auth.session).await?; let (avatar, avatar_default) = if has_scope("profile:avatar") { match db.get_user_avatar_id(&auth.session).await? { Some(id) => (uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string())), false), None => ( uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000")), true, ), } } else { (uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000")), true) }; Ok(Json(ProfileResp { uid: if has_scope("profile:uid") { Some(auth.session) } else { None }, email: if has_scope("profile:email") { Some(user.email) } else { None }, locale: None, amrValues: None, twoFactorAuthentication: false, displayName: if has_scope("profile:display_name") { user.display_name } else { None }, avatar, avatarDefault: avatar_default, subscriptions: None, })) } #[derive(Debug, Deserialize)] #[allow(non_snake_case)] pub(crate) struct DisplayNameReq { displayName: String, } #[post("/display_name", data = "")] pub(crate) async fn display_name_post( db: &DbConn, db_pool: &Db, pc: &State>, defer: &DeferAction, req: Authenticated, ) -> Result { if !req.context.implies(&Scope::borrowed("profile:display_name:write")) { return Err(Error::Unauthorized); } db.set_user_name(&req.session, &req.body.displayName).await?; match db.get_devices(&req.session).await { Ok(devs) => defer.spawn_after_success("api::profile/display_name(post)", { let (pc, db) = (Arc::clone(pc), db_pool.clone()); async move { let db = db.begin().await?; pc.profile_updated(&db, &devs).await; db.commit().await?; Ok(()) } }), Err(e) => warn!("profile_updated push failed: {e}"), } Ok(EMPTY) } #[derive(Serialize)] #[allow(non_snake_case)] pub(crate) struct AvatarResp { id: AvatarID, avatarDefault: bool, avatar: Absolute<'static>, } #[get("/avatar")] pub(crate) async fn avatar_get( db: &DbConn, cfg: &State, req: Authenticated<(), WithBearer>, ) -> Result { if !req.context.implies(&Scope::borrowed("profile:avatar")) { return Err(Error::Unauthorized); } let resp = match db.get_user_avatar_id(&req.session).await? { Some(id) => { let url = uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string())); AvatarResp { id, avatarDefault: false, avatar: url } }, None => { let url = uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000")); AvatarResp { id: AvatarID([0; 16]), avatarDefault: true, avatar: url } }, }; Ok(Json(resp)) } #[get("/")] pub(crate) async fn avatar_get_img( db: &DbConn, id: &str, ) -> std::result::Result<(ContentType, Immutable, &'static [u8]>>), Error> { let id = id.parse().map_err(|_| Error::NotFound)?; if id == AvatarID([0; 16]) { return Ok(( ContentType::SVG, Immutable(Right(include_bytes!("../../../Raven-Silhouette.svg"))), )); } match db.get_user_avatar(&id).await? { Some(avatar) => { let ct = avatar.content_type.parse().expect("invalid content type in db"); Ok((ct, Immutable(Left(avatar.data)))) }, None => Err(Error::NotFound), } } #[derive(Serialize)] #[allow(non_snake_case)] pub(crate) struct AvatarUploadResp { url: Absolute<'static>, } #[post("/avatar/upload", data = "")] #[allow(clippy::too_many_arguments)] pub(crate) async fn avatar_upload( db: &DbConn, db_pool: &Db, pc: &State>, defer: &DeferAction, cfg: &State, ct: &ContentType, req: Authenticated<(), WithBearer>, data: Vec, ) -> Result { if !req.context.implies(&Scope::borrowed("profile:avatar:write")) { return Err(Error::Unauthorized); } if data.len() >= 128.kibibytes() { return Err(Error::PayloadTooLarge); } if !ct.is_png() && !ct.is_gif() && !ct.is_bmp() && !ct.is_jpeg() && !ct.is_webp() && !ct.is_avif() && !ct.is_svg() { return Err(Error::InvalidParameter); } let mut sha = Sha256::new(); sha.update(&req.session.0); sha.update(&data); let id = AvatarID(sha.finalize()[0..16].try_into().unwrap()); db.set_user_avatar(&req.session, Avatar { id, data, content_type: ct.to_string() }).await?; match db.get_devices(&req.session).await { Ok(devs) => defer.spawn_after_success("api::profile/avatar/upload(post)", { let (pc, db) = (Arc::clone(pc), db_pool.clone()); async move { let db = db.begin().await?; pc.profile_updated(&db, &devs).await; db.commit().await?; Ok(()) } }), Err(e) => warn!("profile_updated push failed: {e}"), } let url = uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string())); Ok(Json(AvatarUploadResp { url })) } #[delete("/avatar/")] pub(crate) async fn avatar_delete( db: &DbConn, id: &str, req: Authenticated<(), WithBearer>, ) -> Result { if !req.context.implies(&Scope::borrowed("profile:avatar:write")) { return Err(Error::Unauthorized); } let id = id.parse().map_err(|_| Error::NotFound)?; db.delete_user_avatar(&req.session, &id).await?; Ok(EMPTY) }