summaryrefslogtreecommitdiff
path: root/src/api/profile/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/profile/mod.rs')
-rw-r--r--src/api/profile/mod.rs324
1 files changed, 324 insertions, 0 deletions
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<sqlx::Error> for Error {
+ fn from(e: sqlx::Error) -> Self {
+ Error::Other(anyhow!(e))
+ }
+}
+
+impl From<anyhow::Error> for Error {
+ fn from(e: anyhow::Error) -> Self {
+ Error::Other(e)
+ }
+}
+
+pub(crate) type Result<T> = std::result::Result<Json<T>, Error>;
+
+#[catch(default)]
+pub(crate) fn catch_all(status: Status, r: &Request<'_>) -> Error {
+ match status.code {
+ // these three are caused by Json<T> 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<UserID>,
+ email: Option<String>,
+ locale: Option<String>,
+ amrValues: Option<Vec<String>>,
+ twoFactorAuthentication: bool,
+ displayName: Option<String>,
+ // 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<Vec<String>>,
+}
+
+#[get("/profile")]
+pub(crate) async fn profile(
+ db: &DbConn,
+ cfg: &State<Config>,
+ auth: Authenticated<(), WithBearer>,
+) -> Result<ProfileResp> {
+ 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 = "<req>")]
+pub(crate) async fn display_name_post(
+ db: &DbConn,
+ db_pool: &Db,
+ pc: &State<Arc<PushClient>>,
+ defer: &DeferAction,
+ req: Authenticated<DisplayNameReq, WithBearer>,
+) -> Result<Empty> {
+ 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<Config>,
+ req: Authenticated<(), WithBearer>,
+) -> Result<AvatarResp> {
+ 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("/<id>")]
+pub(crate) async fn avatar_get_img(
+ db: &DbConn,
+ id: &str,
+) -> std::result::Result<(ContentType, Immutable<Either<Vec<u8>, &'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 = "<data>")]
+pub(crate) async fn avatar_upload(
+ db: &DbConn,
+ db_pool: &Db,
+ pc: &State<Arc<PushClient>>,
+ defer: &DeferAction,
+ cfg: &State<Config>,
+ ct: &ContentType,
+ req: Authenticated<(), WithBearer>,
+ data: Vec<u8>,
+) -> Result<AvatarUploadResp> {
+ 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/<id>")]
+pub(crate) async fn avatar_delete(
+ db: &DbConn,
+ id: &str,
+ req: Authenticated<(), WithBearer>,
+) -> Result<Empty> {
+ 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)
+}