Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(website): profile pictures #186

Merged
merged 24 commits into from
Jan 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cb7fdbd
feat(website): implement profile pictures
lennartkloock Jan 8, 2024
9aadc10
fix(website): profile picture source tag
lennartkloock Jan 9, 2024
6d42482
fix(image_processor): publish event
lennartkloock Jan 11, 2024
870598a
feat(platform): make profile pictures work
lennartkloock Jan 11, 2024
0db1cd4
fix(image_processor): ffmpeg decoder duration calculation
lennartkloock Jan 11, 2024
ec92091
fix(website): profile picture variants
lennartkloock Jan 12, 2024
0b35eb4
feat(website): loading animation and style fix
lennartkloock Jan 12, 2024
c704d08
fix(website): minor fixes
lennartkloock Jan 12, 2024
2550d77
feat(platform): show pending profile picture
lennartkloock Jan 12, 2024
394e51a
fix(website): update supported file types
lennartkloock Jan 12, 2024
b356dac
chore(website): fmt
lennartkloock Jan 12, 2024
2e6a98f
feat(platform): implement remove profile picture
lennartkloock Jan 12, 2024
71dc020
fix(platform/api): didn't support all file formats
lennartkloock Jan 12, 2024
ef7ff0e
fix(website): improve padding on tab selector
lennartkloock Jan 12, 2024
300a99d
Revert "fix(website): improve padding on tab selector"
lennartkloock Jan 12, 2024
183bef1
feat(platform/api): add new file subscription endpoint
lennartkloock Jan 12, 2024
f2bfc94
feat(platform/website): add error handling for profile pictures
lennartkloock Jan 12, 2024
a311cb5
fix(platform/api): address comment
lennartkloock Jan 12, 2024
844ce8d
fix: publish to nats instead of jetstream
lennartkloock Jan 12, 2024
f7bb92a
feat(platform/website): improve error styling
lennartkloock Jan 13, 2024
6763a0c
fix(platform/website): accessability
lennartkloock Jan 13, 2024
cf35b5a
fix(ffmpeg): buffer align
lennartkloock Jan 13, 2024
092cc67
refactor(platform/website): prepare variants function
lennartkloock Jan 13, 2024
420707a
fix(platform/website): reset status in profile settings
lennartkloock Jan 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading