1244 lines
39 KiB
Rust
1244 lines
39 KiB
Rust
use crate::notify::{ClientToken, TokenStateReceiver};
|
|
use anyhow::Context;
|
|
use brotli::CompressorReader;
|
|
use gotrue_entity::dto::AuthProvider;
|
|
use shared_entity::dto::workspace_dto::CreateWorkspaceParam;
|
|
use std::io::Read;
|
|
|
|
use app_error::AppError;
|
|
use bytes::Bytes;
|
|
use database_entity::dto::{
|
|
AFCollabMember, AFCollabMembers, AFSnapshotMeta, AFSnapshotMetas, AFUserProfile,
|
|
AFUserWorkspaceInfo, AFWorkspace, AFWorkspaceMember, AFWorkspaces, BatchQueryCollabParams,
|
|
BatchQueryCollabResult, CollabMemberIdentify, CreateCollabParams, DeleteCollabParams,
|
|
InsertCollabMemberParams, QueryCollab, QueryCollabMembers, QueryCollabParams,
|
|
QuerySnapshotParams, SnapshotData, UpdateCollabMemberParams,
|
|
};
|
|
use futures_util::StreamExt;
|
|
use gotrue::grant::Grant;
|
|
use gotrue::grant::PasswordGrant;
|
|
|
|
use gotrue::params::MagicLinkParams;
|
|
use gotrue::params::{AdminUserParams, GenerateLinkParams};
|
|
use mime::Mime;
|
|
use parking_lot::RwLock;
|
|
use realtime_entity::EncodedCollab;
|
|
use reqwest::{header, StatusCode};
|
|
|
|
use collab_entity::CollabType;
|
|
use reqwest::header::HeaderValue;
|
|
use reqwest::Method;
|
|
use reqwest::RequestBuilder;
|
|
use shared_entity::dto::auth_dto::SignInTokenResponse;
|
|
use shared_entity::dto::auth_dto::UpdateUserParams;
|
|
use shared_entity::dto::workspace_dto::{
|
|
BlobMetadata, CreateWorkspaceMembers, RepeatedBlobMetaData, WorkspaceMemberChangeset,
|
|
WorkspaceMembers, WorkspaceSpaceUsage,
|
|
};
|
|
use shared_entity::response::{AppResponse, AppResponseError};
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tracing::{event, instrument, trace, warn};
|
|
use url::Url;
|
|
|
|
use gotrue_entity::dto::SignUpResponse::{Authenticated, NotAuthenticated};
|
|
use gotrue_entity::dto::{GotrueTokenResponse, UpdateGotrueUserParams, User};
|
|
|
|
pub const CLIENT_API_VERSION: &str = "0.0.3";
|
|
pub const X_COMPRESSION_TYPE: &str = "X-Compression-Type";
|
|
pub const X_COMPRESSION_BUFFER_SIZE: &str = "X-Compression-Buffer-Size";
|
|
pub const X_COMPRESSION_TYPE_BROTLI: &str = "brotli";
|
|
|
|
#[derive(Clone)]
|
|
pub struct ClientConfiguration {
|
|
/// Lower Levels (0-4): Faster compression and decompression speeds but lower compression ratios. Suitable for scenarios where speed is more critical than reducing data size.
|
|
/// Medium Levels (5-9): A balance between compression ratio and speed. These levels are generally good for a mix of performance and efficiency.
|
|
/// Higher Levels (10-11): The highest compression ratios, but significantly slower and more resource-intensive. These are typically used in scenarios where reducing data size is paramount and resource usage is a secondary concern, such as for static content compression in web servers.
|
|
pub(crate) compression_quality: u32,
|
|
/// A larger buffer size means more data is compressed in a single operation, which can lead to better compression ratios
|
|
/// since Brotli has more data to analyze for patterns and repetitions.
|
|
pub(crate) compression_buffer_size: usize,
|
|
}
|
|
|
|
impl ClientConfiguration {
|
|
pub fn with_compression_buffer_size(mut self, compression_buffer_size: usize) -> Self {
|
|
self.compression_buffer_size = compression_buffer_size;
|
|
self
|
|
}
|
|
|
|
pub fn with_compression_quality(mut self, compression_quality: u32) -> Self {
|
|
self.compression_quality = if compression_quality > 11 {
|
|
warn!("compression_quality is larger than 11, set it to 11");
|
|
11
|
|
} else {
|
|
compression_quality
|
|
};
|
|
self
|
|
}
|
|
}
|
|
|
|
impl Default for ClientConfiguration {
|
|
fn default() -> Self {
|
|
Self {
|
|
compression_quality: 8,
|
|
compression_buffer_size: 10240,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `Client` is responsible for managing communication with the GoTrue API and cloud storage.
|
|
///
|
|
/// It provides methods to perform actions like signing in, signing out, refreshing tokens,
|
|
/// and interacting with file storage and collaboration objects.
|
|
///
|
|
/// # Fields
|
|
/// - `cloud_client`: A `reqwest::Client` used for HTTP requests to the cloud.
|
|
/// - `gotrue_client`: A `gotrue::api::Client` used for interacting with the GoTrue API.
|
|
/// - `base_url`: The base URL for API requests.
|
|
/// - `ws_addr`: The WebSocket address for real-time communication.
|
|
/// - `token`: An `Arc<RwLock<ClientToken>>` managing the client's authentication token.
|
|
///
|
|
#[derive(Clone)]
|
|
pub struct Client {
|
|
pub(crate) cloud_client: reqwest::Client,
|
|
pub(crate) gotrue_client: gotrue::api::Client,
|
|
pub base_url: String,
|
|
ws_addr: String,
|
|
pub(crate) token: Arc<RwLock<ClientToken>>,
|
|
pub(crate) is_refreshing_token: Arc<AtomicBool>,
|
|
pub(crate) refresh_ret_txs: Arc<RwLock<Vec<RefreshTokenSender>>>,
|
|
pub(crate) config: ClientConfiguration,
|
|
}
|
|
|
|
pub(crate) type RefreshTokenRet = tokio::sync::oneshot::Receiver<Result<(), AppResponseError>>;
|
|
pub(crate) type RefreshTokenSender = tokio::sync::oneshot::Sender<Result<(), AppResponseError>>;
|
|
|
|
/// Hardcoded schema in the frontend application. Do not change this value.
|
|
const DESKTOP_CALLBACK_URL: &str = "appflowy-flutter://login-callback";
|
|
|
|
impl Client {
|
|
/// Constructs a new `Client` instance.
|
|
///
|
|
/// # Parameters
|
|
/// - `base_url`: The base URL for API requests.
|
|
/// - `ws_addr`: The WebSocket address for real-time communication.
|
|
/// - `gotrue_url`: The URL for the GoTrue API.
|
|
pub fn new(base_url: &str, ws_addr: &str, gotrue_url: &str, config: ClientConfiguration) -> Self {
|
|
let reqwest_client = reqwest::Client::new();
|
|
Self {
|
|
base_url: base_url.to_string(),
|
|
ws_addr: ws_addr.to_string(),
|
|
cloud_client: reqwest_client.clone(),
|
|
gotrue_client: gotrue::api::Client::new(reqwest_client, gotrue_url),
|
|
token: Arc::new(RwLock::new(ClientToken::new())),
|
|
is_refreshing_token: Default::default(),
|
|
refresh_ret_txs: Default::default(),
|
|
config,
|
|
}
|
|
}
|
|
|
|
pub fn base_url(&self) -> &str {
|
|
&self.base_url
|
|
}
|
|
|
|
pub fn ws_addr(&self) -> &str {
|
|
&self.ws_addr
|
|
}
|
|
|
|
pub fn gotrue_url(&self) -> &str {
|
|
&self.gotrue_client.base_url
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub fn restore_token(&self, token: &str) -> Result<(), AppResponseError> {
|
|
if token.is_empty() {
|
|
return Err(AppError::OAuthError("Empty token".to_string()).into());
|
|
}
|
|
let token = serde_json::from_str::<GotrueTokenResponse>(token)?;
|
|
self.token.write().set(token);
|
|
Ok(())
|
|
}
|
|
|
|
/// Retrieves the string representation of the [GotrueTokenResponse]. The returned value can be
|
|
/// saved to the client application's local storage and used to restore the client's authentication
|
|
///
|
|
/// This function attempts to acquire a read lock on `self.token` and retrieves the
|
|
/// string representation of the access token. If the lock cannot be acquired or
|
|
/// the token is not present, an error is returned.
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub fn get_token(&self) -> Result<String, AppResponseError> {
|
|
let token_str = self
|
|
.token
|
|
.read()
|
|
.try_get()
|
|
.map_err(|err| AppResponseError::from(AppError::OAuthError(err.to_string())))?;
|
|
Ok(token_str)
|
|
}
|
|
|
|
pub fn subscribe_token_state(&self) -> TokenStateReceiver {
|
|
self.token.read().subscribe()
|
|
}
|
|
|
|
/// Attempts to sign in using a URL, extracting and validating the token parameters from the URL fragment.
|
|
/// It looks like, e.g., `appflowy-flutter://#access_token=...&expires_in=3600&provider_token=...&refresh_token=...&token_type=bearer`.
|
|
///
|
|
/// return a bool indicating if the user is new
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn sign_in_with_url(&self, url: &str) -> Result<bool, AppResponseError> {
|
|
let mut access_token: Option<String> = None;
|
|
let mut token_type: Option<String> = None;
|
|
let mut expires_in: Option<i64> = None;
|
|
let mut expires_at: Option<i64> = None;
|
|
let mut refresh_token: Option<String> = None;
|
|
let mut provider_access_token: Option<String> = None;
|
|
let mut provider_refresh_token: Option<String> = None;
|
|
|
|
Url::parse(url)?
|
|
.fragment()
|
|
.ok_or(url_missing_param("fragment"))?
|
|
.split('&')
|
|
.try_for_each(|f| -> Result<(), AppResponseError> {
|
|
let (k, v) = f.split_once('=').ok_or(url_missing_param("key=value"))?;
|
|
match k {
|
|
"access_token" => access_token = Some(v.to_string()),
|
|
"token_type" => token_type = Some(v.to_string()),
|
|
"expires_in" => expires_in = Some(v.parse::<i64>().context("parser expires_in failed")?),
|
|
"expires_at" => expires_at = Some(v.parse::<i64>().context("parser expires_at failed")?),
|
|
"refresh_token" => refresh_token = Some(v.to_string()),
|
|
"provider_access_token" => provider_access_token = Some(v.to_string()),
|
|
"provider_refresh_token" => provider_refresh_token = Some(v.to_string()),
|
|
x => tracing::warn!("unhandled param in url: {}", x),
|
|
};
|
|
Ok(())
|
|
})?;
|
|
|
|
let access_token = access_token.ok_or(url_missing_param("access_token"))?;
|
|
let (user, new) = self.verify_token(&access_token).await?;
|
|
|
|
self.token.write().set(GotrueTokenResponse {
|
|
access_token,
|
|
token_type: token_type.ok_or(url_missing_param("token_type"))?,
|
|
expires_in: expires_in.ok_or(url_missing_param("expires_in"))?,
|
|
expires_at: expires_at.ok_or(url_missing_param("expires_at"))?,
|
|
refresh_token: refresh_token.ok_or(url_missing_param("refresh_token"))?,
|
|
user,
|
|
provider_access_token,
|
|
provider_refresh_token,
|
|
});
|
|
|
|
Ok(new)
|
|
}
|
|
|
|
/// Returns an OAuth URL by constructing the authorization URL for the specified provider.
|
|
///
|
|
/// This asynchronous function communicates with the GoTrue client to retrieve settings and
|
|
/// validate the availability of the specified OAuth provider. If the provider is available,
|
|
/// it constructs and returns the OAuth URL. When the user opens the OAuth URL, it redirects to
|
|
/// the corresponding provider's OAuth web page. After the user is authenticated, the browser will open
|
|
/// a deep link to the AppFlowy app (iOS, macOS, etc.), which will call [Client::sign_in_with_url] to sign in.
|
|
///
|
|
/// For example, the OAuth URL on Google looks like `https://appflowy.io/authorize?provider=google`.
|
|
/// The deep link looks like `appflowy-flutter://#access_token=...&expires_in=3600&provider_token=...&refresh_token=...&token_type=bearer`.
|
|
///
|
|
/// The appflowy-flutter:// is a hardcoded schema in the frontend application
|
|
///
|
|
/// # Parameters
|
|
/// - `provider`: A reference to an `OAuthProvider` indicating which OAuth provider to use for login.
|
|
///
|
|
/// # Returns
|
|
/// - `Ok(String)`: A `String` containing the constructed authorization URL if the specified provider is available.
|
|
/// - `Err(AppResponseError)`: An `AppResponseError` indicating either the OAuth provider is invalid or other issues occurred while fetching settings.
|
|
///
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn generate_oauth_url_with_provider(
|
|
&self,
|
|
provider: &AuthProvider,
|
|
) -> Result<String, AppResponseError> {
|
|
let settings = self.gotrue_client.settings().await?;
|
|
if !settings.external.has_provider(provider) {
|
|
return Err(AppError::InvalidOAuthProvider(provider.as_str().to_owned()).into());
|
|
}
|
|
|
|
let url = format!("{}/authorize", self.gotrue_client.base_url,);
|
|
|
|
let mut url = Url::parse(&url)?;
|
|
url
|
|
.query_pairs_mut()
|
|
.append_pair("provider", provider.as_str())
|
|
.append_pair("redirect_to", DESKTOP_CALLBACK_URL);
|
|
|
|
if let AuthProvider::Google = provider {
|
|
url
|
|
.query_pairs_mut()
|
|
// In many cases, especially for server-side applications or mobile apps that might need to
|
|
// interact with Google services on behalf of the user without the user being actively
|
|
// engaged, access_type=offline is preferred to ensure long-term access.
|
|
.append_pair("access_type", "offline")
|
|
// In Google OAuth2.0, the prompt parameter is used to control the OAuth2.0 flow's behavior.
|
|
// It determines if the user is re-prompted for authentication and/or consent.
|
|
// 1. none: The authorization server does not display any authentication or consent user interface pages.
|
|
// 2. consent: The authorization server prompts the user for consent before returning information to the client
|
|
// 3. select_account: The authorization server prompts the user to select a user account.
|
|
.append_pair("prompt", "consent");
|
|
}
|
|
|
|
Ok(url.to_string())
|
|
}
|
|
|
|
/// Generates a sign action link for the specified email address.
|
|
/// This is only applicable if user token is with admin privilege.
|
|
/// This action link is used on web browser to sign in. When user then click the action link in the browser,
|
|
/// which calls gotrue authentication server, which then redirects to the appflowy-flutter:// with the authentication token.
|
|
///
|
|
/// [Self::extract_sign_in_url] simulates the browser behavior to extract the sign in url.
|
|
///
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn generate_sign_in_action_link(
|
|
&self,
|
|
email: &str,
|
|
) -> Result<String, AppResponseError> {
|
|
let admin_user_params: GenerateLinkParams = GenerateLinkParams {
|
|
email: email.to_string(),
|
|
..Default::default()
|
|
};
|
|
|
|
let link_resp = self
|
|
.gotrue_client
|
|
.admin_generate_link(&self.access_token()?, &admin_user_params)
|
|
.await?;
|
|
assert_eq!(link_resp.email, email);
|
|
|
|
Ok(link_resp.action_link)
|
|
}
|
|
|
|
#[cfg(feature = "test_util")]
|
|
/// Used to extract the sign in url from the action link
|
|
/// Only expose this method for testing
|
|
pub async fn extract_sign_in_url(&self, action_link: &str) -> Result<String, AppResponseError> {
|
|
let resp = reqwest::Client::new().get(action_link).send().await?;
|
|
let html = resp.text().await.unwrap();
|
|
|
|
trace!("action_link:{}, html: {}", action_link, html);
|
|
let fragment = scraper::Html::parse_fragment(&html);
|
|
let selector = scraper::Selector::parse("a").unwrap();
|
|
let sign_in_url = fragment
|
|
.select(&selector)
|
|
.next()
|
|
.unwrap()
|
|
.value()
|
|
.attr("href")
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
Ok(sign_in_url)
|
|
}
|
|
|
|
#[inline]
|
|
async fn verify_token(&self, access_token: &str) -> Result<(User, bool), AppResponseError> {
|
|
let user = self.gotrue_client.user_info(access_token).await?;
|
|
let is_new = self.verify_token_cloud(access_token).await?;
|
|
Ok((user, is_new))
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
#[inline]
|
|
async fn verify_token_cloud(&self, access_token: &str) -> Result<bool, AppResponseError> {
|
|
let url = format!("{}/api/user/verify/{}", self.base_url, access_token);
|
|
let resp = self.cloud_client.get(&url).send().await?;
|
|
let sign_in_resp: SignInTokenResponse = AppResponse::from_response(resp).await?.into_data()?;
|
|
Ok(sign_in_resp.is_new)
|
|
}
|
|
|
|
// Invites another user by sending a magic link to the user's email address.
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn invite(&self, email: &str) -> Result<(), AppResponseError> {
|
|
self
|
|
.gotrue_client
|
|
.magic_link(
|
|
&self.access_token()?,
|
|
&MagicLinkParams {
|
|
email: email.to_owned(),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn create_magic_link(
|
|
&self,
|
|
email: &str,
|
|
password: &str,
|
|
) -> Result<User, AppResponseError> {
|
|
Ok(
|
|
self
|
|
.gotrue_client
|
|
.admin_add_user(
|
|
&self.access_token()?,
|
|
&AdminUserParams {
|
|
email: email.to_owned(),
|
|
password: Some(password.to_owned()),
|
|
email_confirm: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?,
|
|
)
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn create_email_verified_user(
|
|
&self,
|
|
email: &str,
|
|
password: &str,
|
|
) -> Result<User, AppResponseError> {
|
|
Ok(
|
|
self
|
|
.gotrue_client
|
|
.admin_add_user(
|
|
&self.access_token()?,
|
|
&AdminUserParams {
|
|
email: email.to_owned(),
|
|
password: Some(password.to_owned()),
|
|
email_confirm: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?,
|
|
)
|
|
}
|
|
|
|
/// Only expose this method for testing
|
|
#[cfg(debug_assertions)]
|
|
pub fn token(&self) -> Arc<RwLock<ClientToken>> {
|
|
self.token.clone()
|
|
}
|
|
|
|
/// Retrieves the expiration timestamp of the current token.
|
|
///
|
|
/// This function attempts to read the current token and, if successful, returns the expiration timestamp.
|
|
///
|
|
/// # Returns
|
|
/// - `Ok(i64)`: An `i64` representing the expiration timestamp of the token in seconds.
|
|
/// - `Err(AppError)`: An `AppError` indicating either an inability to read the token or that the user is not logged in.
|
|
///
|
|
#[inline]
|
|
pub fn token_expires_at(&self) -> Result<i64, AppResponseError> {
|
|
match &self.token.try_read() {
|
|
None => Err(AppError::Unhandled("Failed to read token".to_string()).into()),
|
|
Some(token) => Ok(
|
|
token
|
|
.as_ref()
|
|
.ok_or(AppResponseError::from(AppError::NotLoggedIn(
|
|
"token is empty".to_string(),
|
|
)))?
|
|
.expires_at,
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Retrieves the access token string.
|
|
///
|
|
/// This function attempts to read the current token and, if successful, returns the access token string.
|
|
///
|
|
/// # Returns
|
|
/// - `Ok(String)`: A `String` containing the access token.
|
|
/// - `Err(AppResponseError)`: An `AppResponseError` indicating either an inability to read the token or that the user is not logged in.
|
|
///
|
|
pub fn access_token(&self) -> Result<String, AppResponseError> {
|
|
match &self.token.try_read_for(Duration::from_secs(2)) {
|
|
None => Err(AppError::Unhandled("Failed to read token".to_string()).into()),
|
|
Some(token) => Ok(
|
|
token
|
|
.as_ref()
|
|
.ok_or(AppResponseError::from(AppError::NotLoggedIn(
|
|
"fail to get access token. Token is empty".to_string(),
|
|
)))?
|
|
.access_token
|
|
.clone(),
|
|
),
|
|
}
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn get_profile(&self) -> Result<AFUserProfile, AppResponseError> {
|
|
let url = format!("{}/api/user/profile", self.base_url);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<AFUserProfile>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn get_user_workspace_info(&self) -> Result<AFUserWorkspaceInfo, AppResponseError> {
|
|
let url = format!("{}/api/user/workspace", self.base_url);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<AFUserWorkspaceInfo>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn delete_workspace(&self, workspace_id: &str) -> Result<(), AppResponseError> {
|
|
let url = format!("{}/api/workspace/{}", self.base_url, workspace_id);
|
|
let resp = self
|
|
.http_client_with_auth(Method::DELETE, &url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn create_workspace(
|
|
&self,
|
|
params: CreateWorkspaceParam,
|
|
) -> Result<AFWorkspace, AppResponseError> {
|
|
let url = format!("{}/api/workspace", self.base_url);
|
|
let resp = self
|
|
.http_client_with_auth(Method::POST, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<AFWorkspace>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn get_workspaces(&self) -> Result<AFWorkspaces, AppResponseError> {
|
|
let url = format!("{}/api/workspace", self.base_url);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<AFWorkspaces>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn open_workspace(&self, workspace_id: &str) -> Result<AFWorkspace, AppResponseError> {
|
|
let url = format!("{}/api/workspace/{}/open", self.base_url, workspace_id);
|
|
let resp = self
|
|
.http_client_with_auth(Method::PUT, &url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<AFWorkspace>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn get_workspace_members<W: AsRef<str>>(
|
|
&self,
|
|
workspace_id: W,
|
|
) -> Result<Vec<AFWorkspaceMember>, AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/member",
|
|
self.base_url,
|
|
workspace_id.as_ref()
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<Vec<AFWorkspaceMember>>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn add_workspace_members<T: Into<CreateWorkspaceMembers>, W: AsRef<str>>(
|
|
&self,
|
|
workspace_id: W,
|
|
members: T,
|
|
) -> Result<(), AppResponseError> {
|
|
let members = members.into();
|
|
let url = format!(
|
|
"{}/api/workspace/{}/member",
|
|
self.base_url,
|
|
workspace_id.as_ref()
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::POST, &url)
|
|
.await?
|
|
.json(&members)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn update_workspace_member<T: AsRef<str>>(
|
|
&self,
|
|
workspace_id: T,
|
|
changeset: WorkspaceMemberChangeset,
|
|
) -> Result<(), AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/member",
|
|
self.base_url,
|
|
workspace_id.as_ref()
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::PUT, &url)
|
|
.await?
|
|
.json(&changeset)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn remove_workspace_members<T: AsRef<str>>(
|
|
&self,
|
|
workspace_id: T,
|
|
member_emails: Vec<String>,
|
|
) -> Result<(), AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/member",
|
|
self.base_url,
|
|
workspace_id.as_ref()
|
|
);
|
|
let payload = WorkspaceMembers::from(member_emails);
|
|
let resp = self
|
|
.http_client_with_auth(Method::DELETE, &url)
|
|
.await?
|
|
.json(&payload)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[instrument(skip_all, err)]
|
|
pub async fn sign_in_password(
|
|
&self,
|
|
email: &str,
|
|
password: &str,
|
|
) -> Result<bool, AppResponseError> {
|
|
let access_token_resp = self
|
|
.gotrue_client
|
|
.token(&Grant::Password(PasswordGrant {
|
|
email: email.to_owned(),
|
|
password: password.to_owned(),
|
|
}))
|
|
.await?;
|
|
let is_new = self
|
|
.verify_token_cloud(&access_token_resp.access_token)
|
|
.await?;
|
|
self.token.write().set(access_token_resp);
|
|
Ok(is_new)
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn sign_up(&self, email: &str, password: &str) -> Result<(), AppResponseError> {
|
|
match self.gotrue_client.sign_up(email, password).await? {
|
|
Authenticated(access_token_resp) => {
|
|
self.token.write().set(access_token_resp);
|
|
Ok(())
|
|
},
|
|
NotAuthenticated(user) => {
|
|
tracing::info!("sign_up but not authenticated: {}", user.email);
|
|
Ok(())
|
|
},
|
|
}
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all)]
|
|
pub async fn sign_out(&self) -> Result<(), AppResponseError> {
|
|
self.gotrue_client.logout(&self.access_token()?).await?;
|
|
self.token.write().unset();
|
|
Ok(())
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn update_user(&self, params: UpdateUserParams) -> Result<(), AppResponseError> {
|
|
let gotrue_params = UpdateGotrueUserParams::new()
|
|
.with_opt_email(params.email.clone())
|
|
.with_opt_password(params.password.clone());
|
|
|
|
let updated_user = self
|
|
.gotrue_client
|
|
.update_user(&self.access_token()?, &gotrue_params)
|
|
.await?;
|
|
|
|
if let Some(token) = self.token.write().as_mut() {
|
|
token.user = updated_user;
|
|
}
|
|
|
|
let url = format!("{}/api/user/update", self.base_url);
|
|
let resp = self
|
|
.http_client_with_auth(Method::POST, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn create_collab(&self, params: CreateCollabParams) -> Result<(), AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/collab/{}",
|
|
self.base_url, params.workspace_id, ¶ms.object_id
|
|
);
|
|
let bytes = params
|
|
.to_bytes()
|
|
.map_err(|err| AppError::Internal(err.into()))?;
|
|
|
|
let compress_bytes = spawn_blocking_brotli_compress(
|
|
bytes,
|
|
self.config.compression_quality,
|
|
self.config.compression_buffer_size,
|
|
)
|
|
.await?;
|
|
|
|
#[allow(unused_mut)]
|
|
let mut builder = self
|
|
.http_client_with_auth_compress(Method::POST, &url)
|
|
.await?;
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
builder = builder.timeout(Duration::from_secs(60));
|
|
}
|
|
|
|
let resp = builder.body(compress_bytes).send().await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
|
}
|
|
|
|
pub async fn get_snapshot_list(
|
|
&self,
|
|
workspace_id: &str,
|
|
object_id: &str,
|
|
) -> Result<AFSnapshotMetas, AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/{}/snapshot/list",
|
|
self.base_url, workspace_id, object_id
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<AFSnapshotMetas>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
pub async fn get_snapshot(
|
|
&self,
|
|
workspace_id: &str,
|
|
object_id: &str,
|
|
params: QuerySnapshotParams,
|
|
) -> Result<SnapshotData, AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/{}/snapshot",
|
|
self.base_url, workspace_id, object_id,
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<SnapshotData>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
pub async fn create_snapshot(
|
|
&self,
|
|
workspace_id: &str,
|
|
object_id: &str,
|
|
collab_type: CollabType,
|
|
) -> Result<AFSnapshotMeta, AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/{}/snapshot",
|
|
self.base_url, workspace_id, object_id,
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::POST, &url)
|
|
.await?
|
|
.json(&collab_type)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<AFSnapshotMeta>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn update_collab(&self, params: CreateCollabParams) -> Result<(), AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/collab/{}",
|
|
self.base_url, ¶ms.workspace_id, ¶ms.object_id
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::PUT, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all)]
|
|
pub async fn get_collab(
|
|
&self,
|
|
params: QueryCollabParams,
|
|
) -> Result<EncodedCollab, AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/collab/{}",
|
|
self.base_url, ¶ms.workspace_id, ¶ms.object_id
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<EncodedCollab>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn batch_get_collab(
|
|
&self,
|
|
workspace_id: &str,
|
|
params: Vec<QueryCollab>,
|
|
) -> Result<BatchQueryCollabResult, AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/collab_list",
|
|
self.base_url, workspace_id
|
|
);
|
|
let params = BatchQueryCollabParams(params);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<BatchQueryCollabResult>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn delete_collab(&self, params: DeleteCollabParams) -> Result<(), AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/collab/{}",
|
|
self.base_url, ¶ms.workspace_id, ¶ms.object_id
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::DELETE, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn add_collab_member(
|
|
&self,
|
|
params: InsertCollabMemberParams,
|
|
) -> Result<(), AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/collab/{}/member",
|
|
self.base_url, params.workspace_id, ¶ms.object_id
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::POST, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn get_collab_member(
|
|
&self,
|
|
params: CollabMemberIdentify,
|
|
) -> Result<AFCollabMember, AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/collab/{}/member",
|
|
self.base_url, params.workspace_id, ¶ms.object_id
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<AFCollabMember>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn update_collab_member(
|
|
&self,
|
|
params: UpdateCollabMemberParams,
|
|
) -> Result<(), AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/collab/{}/member",
|
|
self.base_url, params.workspace_id, ¶ms.object_id
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::PUT, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn remove_collab_member(
|
|
&self,
|
|
params: CollabMemberIdentify,
|
|
) -> Result<(), AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/collab/{}/member",
|
|
self.base_url, params.workspace_id, ¶ms.object_id
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::DELETE, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn get_collab_members(
|
|
&self,
|
|
params: QueryCollabMembers,
|
|
) -> Result<AFCollabMembers, AppResponseError> {
|
|
let url = format!(
|
|
"{}/api/workspace/{}/collab/{}/member/list",
|
|
self.base_url, params.workspace_id, ¶ms.object_id
|
|
);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.json(¶ms)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<AFCollabMembers>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
pub async fn ws_url(&self, device_id: &str) -> Result<String, AppResponseError> {
|
|
self.refresh_if_required().await?;
|
|
|
|
let access_token = self.access_token()?;
|
|
Ok(format!("{}/{}/{}", self.ws_addr, access_token, device_id))
|
|
}
|
|
|
|
pub fn get_blob_url(&self, workspace_id: &str, file_id: &str) -> String {
|
|
format!(
|
|
"{}/api/file_storage/{}/blob/{}",
|
|
self.base_url, workspace_id, file_id
|
|
)
|
|
}
|
|
|
|
pub async fn put_blob<T: Into<Bytes>>(
|
|
&self,
|
|
url: &str,
|
|
data: T,
|
|
mime: &Mime,
|
|
) -> Result<(), AppResponseError> {
|
|
let data = data.into();
|
|
let resp = self
|
|
.http_client_with_auth(Method::PUT, url)
|
|
.await?
|
|
.header(header::CONTENT_TYPE, mime.to_string())
|
|
.body(data)
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
if resp.status() == StatusCode::PAYLOAD_TOO_LARGE {
|
|
return Err(AppResponseError::from(AppError::PayloadTooLarge(
|
|
StatusCode::PAYLOAD_TOO_LARGE.to_string(),
|
|
)));
|
|
}
|
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
|
}
|
|
|
|
/// Only expose this method for testing
|
|
#[cfg(debug_assertions)]
|
|
pub async fn put_blob_with_content_length<T: Into<Bytes>>(
|
|
&self,
|
|
url: &str,
|
|
data: T,
|
|
mime: &Mime,
|
|
content_length: usize,
|
|
) -> Result<crate::entity::AFBlobRecord, AppResponseError> {
|
|
let resp = self
|
|
.http_client_with_auth(Method::PUT, url)
|
|
.await?
|
|
.header(header::CONTENT_TYPE, mime.to_string())
|
|
.header(header::CONTENT_LENGTH, content_length)
|
|
.body(data.into())
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<crate::entity::AFBlobRecord>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
/// Get the file with the given url. The url should be in the format of
|
|
/// `https://appflowy.io/api/file_storage/<workspace_id>/<file_id>`.
|
|
pub async fn get_blob(&self, url: &str) -> Result<(Mime, Vec<u8>), AppResponseError> {
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
|
|
match resp.status() {
|
|
reqwest::StatusCode::OK => {
|
|
// get mime from resp header
|
|
let mime = {
|
|
match resp.headers().get(header::CONTENT_TYPE) {
|
|
Some(v) => match v.to_str() {
|
|
Ok(v) => match v.parse::<Mime>() {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::error!("failed to parse mime from header: {:?}", e);
|
|
mime::TEXT_PLAIN
|
|
},
|
|
},
|
|
Err(e) => {
|
|
tracing::error!("failed to get mime from header: {:?}", e);
|
|
mime::TEXT_PLAIN
|
|
},
|
|
},
|
|
None => mime::TEXT_PLAIN,
|
|
}
|
|
};
|
|
|
|
let mut stream = resp.bytes_stream();
|
|
let mut acc: Vec<u8> = Vec::new();
|
|
while let Some(raw_bytes) = stream.next().await {
|
|
acc.extend_from_slice(&raw_bytes?);
|
|
}
|
|
Ok((mime, acc))
|
|
},
|
|
reqwest::StatusCode::NOT_FOUND => Err(AppResponseError::from(AppError::RecordNotFound(
|
|
url.to_owned(),
|
|
))),
|
|
c => Err(AppResponseError::from(AppError::Unhandled(format!(
|
|
"status code: {}, message: {}",
|
|
c,
|
|
resp.text().await?
|
|
)))),
|
|
}
|
|
}
|
|
|
|
pub async fn get_blob_metadata(&self, url: &str) -> Result<BlobMetadata, AppResponseError> {
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
|
|
log_request_id(&resp);
|
|
AppResponse::<BlobMetadata>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
pub async fn delete_blob(&self, url: &str) -> Result<(), AppResponseError> {
|
|
let resp = self
|
|
.http_client_with_auth(Method::DELETE, url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<()>::from_response(resp).await?.into_error()
|
|
}
|
|
|
|
pub async fn get_workspace_usage(
|
|
&self,
|
|
workspace_id: &str,
|
|
) -> Result<WorkspaceSpaceUsage, AppResponseError> {
|
|
let url = format!("{}/api/file_storage/{}/usage", self.base_url, workspace_id);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<WorkspaceSpaceUsage>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
pub async fn get_workspace_all_blob_metadata(
|
|
&self,
|
|
workspace_id: &str,
|
|
) -> Result<RepeatedBlobMetaData, AppResponseError> {
|
|
let url = format!("{}/api/file_storage/{}/blobs", self.base_url, workspace_id);
|
|
let resp = self
|
|
.http_client_with_auth(Method::GET, &url)
|
|
.await?
|
|
.send()
|
|
.await?;
|
|
log_request_id(&resp);
|
|
AppResponse::<RepeatedBlobMetaData>::from_response(resp)
|
|
.await?
|
|
.into_data()
|
|
}
|
|
|
|
pub async fn refresh_if_required(&self) -> Result<(), AppResponseError> {
|
|
let expires_at = self.token_expires_at()?;
|
|
|
|
// Refresh token if it's about to expire
|
|
let time_now_sec = chrono::Local::now().timestamp();
|
|
if time_now_sec + 10 > expires_at {
|
|
// Add 10 seconds buffer
|
|
self.refresh_token().await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub async fn http_client_with_auth(
|
|
&self,
|
|
method: Method,
|
|
url: &str,
|
|
) -> Result<RequestBuilder, AppResponseError> {
|
|
self.refresh_if_required().await?;
|
|
|
|
let access_token = self.access_token()?;
|
|
trace!("start request: {}, method: {}", url, method);
|
|
let request_builder = self
|
|
.cloud_client
|
|
.request(method, url)
|
|
.header("client-version", CLIENT_API_VERSION)
|
|
.bearer_auth(access_token);
|
|
Ok(request_builder)
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all, err)]
|
|
pub(crate) async fn http_client_with_auth_compress(
|
|
&self,
|
|
method: Method,
|
|
url: &str,
|
|
) -> Result<RequestBuilder, AppResponseError> {
|
|
self
|
|
.http_client_with_auth(method, url)
|
|
.await
|
|
.map(|builder| {
|
|
builder
|
|
.header(
|
|
X_COMPRESSION_TYPE,
|
|
HeaderValue::from_static(X_COMPRESSION_TYPE_BROTLI),
|
|
)
|
|
.header(
|
|
X_COMPRESSION_BUFFER_SIZE,
|
|
HeaderValue::from(self.config.compression_buffer_size),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn batch_create_collab_url(&self, workspace_id: &str) -> String {
|
|
format!(
|
|
"{}/api/workspace/{}/batch/collab",
|
|
self.base_url, workspace_id
|
|
)
|
|
}
|
|
}
|
|
|
|
fn url_missing_param(param: &str) -> AppResponseError {
|
|
AppError::InvalidRequest(format!("Url Missing Parameter:{}", param)).into()
|
|
}
|
|
|
|
pub(crate) fn log_request_id(resp: &reqwest::Response) {
|
|
if let Some(request_id) = resp.headers().get("x-request-id") {
|
|
event!(tracing::Level::DEBUG, "request_id: {:?}", request_id);
|
|
} else {
|
|
event!(tracing::Level::DEBUG, "request_id: not found");
|
|
}
|
|
}
|
|
|
|
pub async fn spawn_blocking_brotli_compress(
|
|
data: Vec<u8>,
|
|
quality: u32,
|
|
buffer_size: usize,
|
|
) -> Result<Vec<u8>, AppError> {
|
|
tokio::task::spawn_blocking(move || {
|
|
event!(
|
|
tracing::Level::DEBUG,
|
|
"start compressing collab with len:{}",
|
|
data.len(),
|
|
);
|
|
let mut compressor = CompressorReader::new(&*data, buffer_size, quality, 22);
|
|
let mut compressed_data = Vec::new();
|
|
compressor
|
|
.read_to_end(&mut compressed_data)
|
|
.map_err(|err| AppError::InvalidRequest(format!("Failed to compress data: {}", err)))?;
|
|
|
|
event!(
|
|
tracing::Level::DEBUG,
|
|
"compress collab success: before:{}, after:{}",
|
|
data.len(),
|
|
compressed_data.len()
|
|
);
|
|
Ok(compressed_data)
|
|
})
|
|
.await
|
|
.map_err(AppError::from)?
|
|
}
|