Skip to content

Commit

Permalink
feat(website): profile pictures (#186)
Browse files Browse the repository at this point in the history
This pr implements website support for profile pictures. Including displaying and subscribing to changes and the ability to upload and remove profile pictures in the settings. It shows profile pictures in a responsive and accessible way.
  • Loading branch information
lennartkloock authored Jan 14, 2024
1 parent cf18151 commit 2fa70b4
Show file tree
Hide file tree
Showing 44 changed files with 1,084 additions and 215 deletions.
2 changes: 1 addition & 1 deletion ffmpeg/src/scalar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ impl Scalar {
width,
height,
pixel_format,
32,
1,
);
}

Expand Down
24 changes: 21 additions & 3 deletions platform/api/src/api/v1/gql/models/image_upload.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
use async_graphql::{Enum, SimpleObject};
use async_graphql::{Enum, SimpleObject, ComplexObject, Context};
use pb::scuffle::platform::internal::types::uploaded_file_metadata::{self, Image as PbImage};
use ulid::Ulid;

use super::ulid::GqlUlid;
use crate::api::v1::gql::error::{GqlError, Result};
use crate::config::ImageUploaderConfig;
use crate::database::UploadedFile;
use crate::global::ApiGlobal;
use crate::api::v1::gql::ext::ContextExt;

#[derive(SimpleObject, Clone)]
pub struct ImageUpload {
#[graphql(complex)]
pub struct ImageUpload<G: ApiGlobal> {
pub id: GqlUlid,
pub variants: Vec<ImageUploadVariant>,

#[graphql(skip)]
_phantom: std::marker::PhantomData<G>,
}

#[derive(SimpleObject, Clone)]
Expand All @@ -32,7 +39,17 @@ pub enum ImageUploadFormat {
Avif,
}

impl ImageUpload {
#[ComplexObject]
impl<G: ApiGlobal> ImageUpload<G> {
async fn endpoint<'a>(&self, ctx: &'a Context<'_>) -> &'a str {
let global = ctx.get_global::<G>();

let config: &ImageUploaderConfig = global.provide_config();
&config.public_endpoint
}
}

impl<G: ApiGlobal> ImageUpload<G> {
pub fn from_uploaded_file(uploaded_file: UploadedFile) -> Result<Option<Self>> {
if uploaded_file.pending {
return Ok(None);
Expand All @@ -49,6 +66,7 @@ impl ImageUpload {
Self {
id: GqlUlid(id),
variants: image.versions.into_iter().map(Into::into).collect(),
_phantom: std::marker::PhantomData,
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion platform/api/src/api/v1/gql/models/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub struct User<G: ApiGlobal> {
pub display_color: DisplayColor,
pub username: String,
pub channel: Channel<G>,
pub pending_profile_picture_id: Option<GqlUlid>,

// Private fields
#[graphql(skip)]
Expand Down Expand Up @@ -53,7 +54,7 @@ impl<G: ApiGlobal> User<G> {
auth_guard::<_, G>(ctx, "totpEnabled", self.totp_enabled_, self.id.into()).await
}

async fn profile_picture(&self, ctx: &Context<'_>) -> Result<Option<ImageUpload>> {
async fn profile_picture(&self, ctx: &Context<'_>) -> Result<Option<ImageUpload<G>>> {
let Some(profile_picture_id) = self.profile_picture_ else {
return Ok(None);
};
Expand All @@ -79,6 +80,7 @@ impl<G: ApiGlobal> From<database::User> for User<G> {
display_name: value.display_name,
display_color: value.display_color.into(),
channel: value.channel.into(),
pending_profile_picture_id: value.pending_profile_picture_id.map(|u| u.0.into()),
email_: value.email,
email_verified_: value.email_verified,
last_login_at_: value.last_login_at.into(),
Expand Down
45 changes: 38 additions & 7 deletions platform/api/src/api/v1/gql/mutations/user.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use async_graphql::{Context, Object};
use async_graphql::{ComplexObject, Context, SimpleObject};
use bytes::Bytes;
use common::database::Ulid;
use pb::scuffle::platform::internal::two_fa::two_fa_request_action::{Action, ChangePassword};
Expand All @@ -21,6 +21,8 @@ use crate::subscription::SubscriptionTopic;

mod two_fa;

#[derive(SimpleObject)]
#[graphql(complex)]
pub struct UserMutation<G: ApiGlobal> {
two_fa: two_fa::TwoFaMutation<G>,
}
Expand All @@ -33,7 +35,7 @@ impl<G: ApiGlobal> Default for UserMutation<G> {
}
}

#[Object]
#[ComplexObject]
impl<G: ApiGlobal> UserMutation<G> {
/// Change the email address of the currently logged in user.
async fn email<'ctx>(
Expand Down Expand Up @@ -182,6 +184,40 @@ impl<G: ApiGlobal> UserMutation<G> {
Ok(user.into())
}

/// Remove the profile picture of the currently logged in user.
async fn remove_profile_picture(&self, ctx: &Context<'_>) -> Result<User<G>> {
let global = ctx.get_global::<G>();
let request_context = ctx.get_req_context();

let auth = request_context
.auth(global)
.await?
.ok_or(GqlError::Auth(AuthError::NotLoggedIn))?;

let user: database::User = sqlx::query_as(
"UPDATE users SET profile_picture_id = NULL, pending_profile_picture_id = NULL WHERE id = $1 RETURNING *",
)
.bind(auth.session.user_id)
.fetch_one(global.db().as_ref())
.await?;

global
.nats()
.publish(
SubscriptionTopic::UserProfilePicture(user.id.0),
pb::scuffle::platform::internal::events::UserProfilePicture {
user_id: Some(user.id.0.into()),
profile_picture_id: None,
}
.encode_to_vec()
.into(),
)
.await
.map_err_gql("failed to publish message")?;

Ok(user.into())
}

async fn password<'ctx>(
&self,
ctx: &Context<'_>,
Expand Down Expand Up @@ -317,9 +353,4 @@ impl<G: ApiGlobal> UserMutation<G> {

Ok(follow)
}

#[inline(always)]
async fn two_fa(&self) -> &two_fa::TwoFaMutation<G> {
&self.two_fa
}
}
2 changes: 0 additions & 2 deletions platform/api/src/api/v1/gql/subscription/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,6 @@ impl<G: ApiGlobal> ChatSubscription<G> {
.await
.map_err_gql("failed to fetch chat messages")?;

dbg!(&messages);

Ok(stream!({
for message in messages {
yield Ok(message.into());
Expand Down
108 changes: 108 additions & 0 deletions platform/api/src/api/v1/gql/subscription/file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use async_graphql::{Context, Enum, SimpleObject, Subscription};
use futures_util::Stream;
use pb::ext::*;
use prost::Message;

use crate::api::v1::gql::error::ext::{OptionExt, ResultExt};
use crate::api::v1::gql::error::{GqlError, Result};
use crate::api::v1::gql::ext::ContextExt;
use crate::subscription::SubscriptionTopic;
use crate::{api::v1::gql::models::ulid::GqlUlid, global::ApiGlobal};

pub struct FileSubscription<G: ApiGlobal>(std::marker::PhantomData<G>);

impl<G: ApiGlobal> Default for FileSubscription<G> {
fn default() -> Self {
Self(std::marker::PhantomData)
}
}

#[derive(SimpleObject)]
struct FileStatusStream {
/// The ID of the file.
pub file_id: GqlUlid,
/// The status of the file.
pub status: FileStatus,
/// Only set if status is `Failure`.
pub reason: Option<String>,
/// Only set if status is `Failure`.
pub friendly_message: Option<String>,
}

#[derive(Enum, Copy, Clone, Eq, PartialEq)]
enum FileStatus {
Success,
Failure,
}

#[Subscription]
impl<G: ApiGlobal> FileSubscription<G> {
async fn file_status<'ctx>(
&self,
ctx: &'ctx Context<'ctx>,
file_id: GqlUlid,
) -> Result<impl Stream<Item = Result<FileStatusStream>> + 'ctx> {
let global = ctx.get_global::<G>();

// TODO: get initial status
let file = global
.uploaded_file_by_id_loader()
.load(file_id.to_ulid())
.await
.map_err_ignored_gql("failed to load file")?
.map_err_gql(GqlError::InvalidInput {
fields: vec!["fileId"],
message: "file not found",
})?;

let mut subscription = global
.subscription_manager()
.subscribe(SubscriptionTopic::UploadedFileStatus(file_id.to_ulid()))
.await
.map_err_gql("failed to subscribe to file status")?;

Ok(async_stream::stream!({
if !file.pending {
// When file isn't pending anymore, just yield once with the status from the db
let status = if file.failed.is_some() {
FileStatus::Failure
} else {
FileStatus::Success
};
yield Ok(FileStatusStream {
file_id: file.id.0.into(),
status,
reason: file.failed,
// TODO: we don't have access to the friendly message here because it isn't in the db
friendly_message: None,
});
} else {
// Only receive one message
if let Ok(message) = subscription.recv().await {
let event = pb::scuffle::platform::internal::events::UploadedFileStatus::decode(message.payload)
.map_err_ignored_gql("failed to decode uploaded file status event")?;

let file_id = event.file_id.into_ulid();
let (status, reason, friendly_message) = match event.status.unwrap() {
pb::scuffle::platform::internal::events::uploaded_file_status::Status::Success(_) => {
(FileStatus::Success, None, None)
}
pb::scuffle::platform::internal::events::uploaded_file_status::Status::Failure(
pb::scuffle::platform::internal::events::uploaded_file_status::Failure {
reason,
friendly_message,
},
) => (FileStatus::Failure, Some(reason), Some(friendly_message)),
};

yield Ok(FileStatusStream {
file_id: file_id.into(),
status,
reason,
friendly_message,
});
}
}
}))
}
}
7 changes: 5 additions & 2 deletions platform/api/src/api/v1/gql/subscription/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ use futures_util::Stream;

use self::channel::ChannelSubscription;
use self::chat::ChatSubscription;
use self::file::FileSubscription;
use self::user::UserSubscription;
use super::models::ulid::GqlUlid;
use crate::global::ApiGlobal;

mod channel;
mod chat;
mod file;
mod user;

#[derive(SimpleObject)]
Expand All @@ -20,15 +22,16 @@ struct FollowStream {

#[derive(MergedSubscription)]
pub struct Subscription<G: ApiGlobal>(
UserSubscription<G>,
ChannelSubscription<G>,
ChatSubscription<G>,
FileSubscription<G>,
UserSubscription<G>,
NoopSubscription,
);

impl<G: ApiGlobal> Default for Subscription<G> {
fn default() -> Self {
Self(Default::default(), Default::default(), Default::default(), Default::default())
Self(Default::default(), Default::default(), Default::default(), Default::default(), Default::default())
}
}

Expand Down
6 changes: 3 additions & 3 deletions platform/api/src/api/v1/gql/subscription/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ struct UserDisplayColorStream {
}

#[derive(SimpleObject)]
struct UserProfilePictureStream {
struct UserProfilePictureStream<G: ApiGlobal> {
pub user_id: GqlUlid,
pub profile_picture: Option<ImageUpload>,
pub profile_picture: Option<ImageUpload<G>>,
}

#[Subscription]
Expand Down Expand Up @@ -138,7 +138,7 @@ impl<G: ApiGlobal> UserSubscription<G> {
&self,
ctx: &'ctx Context<'ctx>,
user_id: GqlUlid,
) -> Result<impl Stream<Item = Result<UserProfilePictureStream>> + 'ctx> {
) -> Result<impl Stream<Item = Result<UserProfilePictureStream<G>>> + 'ctx> {
let global = ctx.get_global::<G>();

let Some(profile_picture_id) = global
Expand Down
4 changes: 2 additions & 2 deletions platform/api/src/api/v1/upload/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ trait UploadType: serde::de::DeserializeOwned + Default {
}

pub fn routes<G: ApiGlobal>(_: &Arc<G>) -> RouterBuilder<Incoming, Body, RouteError<ApiError>> {
Router::builder().patch("/profile-picture", handler::<G, ProfilePicture>)
Router::builder().post("/profile-picture", handler::<G, ProfilePicture>)
}

async fn handler<G: ApiGlobal, U: UploadType>(req: Request<Incoming>) -> Result<Response<Body>, RouteError<ApiError>> {
Expand Down Expand Up @@ -68,7 +68,7 @@ async fn handler<G: ApiGlobal, U: UploadType>(req: Request<Incoming>) -> Result<
.size_limit(
SizeLimit::new()
.for_field("metadata", 30 * 1024)
.for_field("captcha", 512)
.for_field("captcha", 2048) // https://developers.cloudflare.com/turnstile/frequently-asked-questions/#what-is-the-length-of-a-turnstile-token
.for_field("file", U::get_max_size(&global) as u64),
);

Expand Down
Loading

0 comments on commit 2fa70b4

Please sign in to comment.