From 2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328 Mon Sep 17 00:00:00 2001 From: pennae Date: Wed, 13 Jul 2022 10:33:30 +0200 Subject: initial import --- src/api/profile/mod.rs | 324 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 src/api/profile/mod.rs (limited to 'src/api/profile/mod.rs') diff --git a/src/api/profile/mod.rs b/src/api/profile/mod.rs new file mode 100644 index 0000000..28d1e03 --- /dev/null +++ b/src/api/profile/mod.rs @@ -0,0 +1,324 @@ +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, WithBearer, AuthenticatedRequest}, + 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 = "")] +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: id.clone(), 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) +} -- cgit v1.2.3