parent
d14e5457f3
commit
8e9387b95d
|
@ -22,5 +22,5 @@ jobs:
|
||||||
uses: dokku/github-action@master
|
uses: dokku/github-action@master
|
||||||
with:
|
with:
|
||||||
branch: 'main'
|
branch: 'main'
|
||||||
git_remote_url: 'ssh://dokku@v2.discours.io:22/presence'
|
git_remote_url: 'ssh://dokku@v2.discours.io:22/quoter'
|
||||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
1145
Cargo.lock
generated
1145
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
14
Cargo.toml
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "discoursio-presence"
|
name = "discoursio-quoter"
|
||||||
version = "0.2.20"
|
version = "0.0.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
@ -12,11 +12,15 @@ actix-web = "4.5.1"
|
||||||
reqwest = { version = "0.12.3", features = ["json"] }
|
reqwest = { version = "0.12.3", features = ["json"] }
|
||||||
sentry = { version = "0.34.0", features = ["tokio"] }
|
sentry = { version = "0.34.0", features = ["tokio"] }
|
||||||
uuid = { version = "1.8.0", features = ["v4"] }
|
uuid = { version = "1.8.0", features = ["v4"] }
|
||||||
redis = { version = "0.25.3", features = ["tokio-comp"] }
|
redis = { version = "0.26.1", features = ["tokio-comp"] }
|
||||||
tokio = { version = "1.37.0", features = ["full"] }
|
tokio = { version = "1.37.0", features = ["full"] }
|
||||||
serde = { version = "1.0.197", features = ["derive"] }
|
serde = { version = "1.0.209", features = ["derive"] }
|
||||||
sentry-actix = "0.34.0"
|
sentry-actix = "0.34.0"
|
||||||
|
aws-sdk-s3 = "1.47.0" # AWS SDK для работы с S3
|
||||||
|
image = "0.24.7" # Библиотека для работы с изображениями (генерация миниатюр)
|
||||||
|
mime_guess = "2.0.5"
|
||||||
|
aws-config = "1.5.5"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "presence"
|
name = "quoter"
|
||||||
path = "./src/main.rs"
|
path = "./src/main.rs"
|
||||||
|
|
10
Dockerfile
10
Dockerfile
|
@ -5,8 +5,8 @@ RUN apt-get update -y && \
|
||||||
apt-get install -y git pkg-config make g++ libssl-dev wget && \
|
apt-get install -y git pkg-config make g++ libssl-dev wget && \
|
||||||
rustup target add x86_64-unknown-linux-gnu
|
rustup target add x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
RUN USER=root cargo new --bin presence
|
RUN USER=root cargo new --bin quoter
|
||||||
WORKDIR /presence
|
WORKDIR /quoter
|
||||||
|
|
||||||
COPY ./Cargo.lock ./Cargo.lock
|
COPY ./Cargo.lock ./Cargo.lock
|
||||||
COPY ./Cargo.toml ./Cargo.toml
|
COPY ./Cargo.toml ./Cargo.toml
|
||||||
|
@ -20,7 +20,7 @@ RUN rm src/*.rs
|
||||||
COPY ./src ./src
|
COPY ./src ./src
|
||||||
|
|
||||||
# build for release
|
# build for release
|
||||||
RUN rm ./target/release/deps/presence*
|
RUN rm ./target/release/deps/quoter*
|
||||||
RUN cargo build --release
|
RUN cargo build --release
|
||||||
|
|
||||||
FROM rust
|
FROM rust
|
||||||
|
@ -28,8 +28,8 @@ FROM rust
|
||||||
ENV RUST_BACKTRACE=full
|
ENV RUST_BACKTRACE=full
|
||||||
RUN apt-get update && apt install -y openssl libssl-dev
|
RUN apt-get update && apt install -y openssl libssl-dev
|
||||||
|
|
||||||
COPY --from=build /presence/target/release/presence .
|
COPY --from=build /quoter/target/release/quoter .
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["./presence"]
|
CMD ["./quoter"]
|
||||||
|
|
43
README.md
43
README.md
|
@ -1,34 +1,39 @@
|
||||||
## Presence
|
## Квотер
|
||||||
|
|
||||||
"Присутствие" - это сервер для пересылки сообщений в реальном времени. Текущая версия использует SSE-транспорт.
|
|
||||||
|
|
||||||
|
Управляет квотами на загрузку файлов и их размещением в S3-хранилище. Поддерживает создание миниатюр для изображений и управляет квотами на использование дискового пространства для каждого пользователя с использованием Redis.
|
||||||
|
|
||||||
### ENV
|
### ENV
|
||||||
|
|
||||||
- API_BASE
|
- `REDIS_URL`: URL для подключения к Redis. Используется для управления квотами и хранения информации о загружаемых файлах.
|
||||||
- AUTH_URL
|
- `S3_BUCKET`: Имя S3 bucket, используемого для хранения загруженных файлов.
|
||||||
- REDIS_URL
|
- `CDN_DOMAIN`: Домен CDN для генерации публичных URL-адресов загруженных файлов.
|
||||||
|
|
||||||
|
|
||||||
### Как это работает
|
### Как это работает
|
||||||
|
|
||||||
При каждом обращении к `/connect` создаётся отдельная асинхронная задача с подписками на Redus PubSub каналы, позволяя пользователям получать только те уведомления, которые предназначены непосредственно для них.
|
1. **Аутентификация**:
|
||||||
|
- Клиент отправляет файл на сервер с заголовком `Authorization`, содержащим токен. Сервер проверяет наличие и валидность токена, определяя пользователя.
|
||||||
|
|
||||||
Каналы Redis:
|
2. **Загрузка файлов**:
|
||||||
|
- Сервер обрабатывает все загружаемые файлы. Если файл является изображением, создается его миниатюра. И миниатюра, и оригинальное изображение загружаются в S3. Для остальных файлов выполняется простая загрузка в S3 без создания миниатюр.
|
||||||
|
|
||||||
- `reaction`
|
3. **Создание миниатюр**:
|
||||||
- `shout`
|
- Для всех загружаемых изображений сервер автоматически создает миниатюры размером 320x320 пикселей. Миниатюры сохраняются как отдельные файлы в том же S3 bucket, что и оригинальные изображения.
|
||||||
- `follower:<author_id>`
|
|
||||||
- `chat:<chat_id>`
|
|
||||||
|
|
||||||
Сервис пересылает сообщения из этих каналов, которые предназначены пользователю, подписавшемуся на Server-Sent Events (SSE) по адресу `/connect`. Для авторизации подписки используется токен, который передается клиентом в заголовке `Authorization`, или в пути `/connect/{token}`, или в переменной запроса `/connect/?token={token}`.
|
4. **Определение MIME-типа и расширения файла**:
|
||||||
|
- MIME-тип и расширение файла определяются автоматически на основе имени файла и его содержимого с использованием библиотеки `mime_guess`.
|
||||||
|
|
||||||
При завершении подключения, все подписки автоматически отменяются, так как они связаны с конкретным подключением. Если пользователь снова подключается, процесс подписки повторяется.
|
5. **Загрузка файлов в S3**:
|
||||||
|
- Все файлы, включая миниатюры и оригиналы изображений, загружаются в указанный S3 bucket. Сформированные URL-адреса файлов возвращаются клиенту.
|
||||||
|
|
||||||
|
6. **Управление квотами**:
|
||||||
|
- Для каждого пользователя устанавливается квота на загрузку данных, которая составляет 1 ГБ в неделю. Перед загрузкой каждого нового файла проверяется, не превысит ли его размер текущую квоту пользователя. Если квота будет превышена, загрузка файла будет отклонена. После успешной загрузки файл и его размер регистрируются в Redis, и квота пользователя обновляется.
|
||||||
|
|
||||||
### Формат сообщений межсервисной коммуникации
|
7. **Сохранение информации о загруженных файлах в Redis**:
|
||||||
|
- Имя каждого загруженного файла сохраняется в Redis для отслеживания загруженных пользователем файлов. Это позволяет учитывать квоты и управлять пространством, занимаемым файлами.
|
||||||
|
|
||||||
Между сервисами пересылаются целые сущности и типизация действий с ними, поля стандартного redis-сообщения:
|
### Основные функции
|
||||||
|
|
||||||
- `action` наименование операции, примеры: "create" | "delete" | "update" | "join" | "left"
|
- Миниатюры: Автоматическое создание миниатюр для изображений.
|
||||||
- `payload` json одной из сущностей: Reaction | Shout | Author | Chat | Message
|
- S3/STORJ интеграция: Загрузка файлов в через `aws-sdk-s3` и возврат публичных URL-адресов.
|
||||||
|
- Управление квотами: Ограничение объема загружаемых данных для каждого пользователя с использованием Redis.
|
||||||
|
- Отслеживание файлов: Хранение информации о загруженных файлах в Redis для управления квотами.
|
||||||
|
|
194
src/data.rs
194
src/data.rs
|
@ -1,194 +0,0 @@
|
||||||
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
|
|
||||||
use reqwest::Client as HTTPClient;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::env;
|
|
||||||
use std::error::Error;
|
|
||||||
|
|
||||||
use crate::SSEMessageData;
|
|
||||||
|
|
||||||
async fn get_author_id(user: &str) -> Result<i32, Box<dyn Error>> {
|
|
||||||
let api_base = env::var("API_BASE")?;
|
|
||||||
let query_name = "get_author_id";
|
|
||||||
let operation = "GetAuthorId";
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
// headers.insert(AUTHORIZATION, HeaderValue::from_str(token)?);
|
|
||||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
||||||
|
|
||||||
let mut variables = HashMap::<String, String>::new();
|
|
||||||
variables.insert("user".to_string(), user.to_string());
|
|
||||||
|
|
||||||
let gql = json!({
|
|
||||||
"query": format!("query {}($user: String!) {{ {}(user: $user){{ id }} }}", operation, query_name),
|
|
||||||
"operationName": operation,
|
|
||||||
"variables": variables
|
|
||||||
});
|
|
||||||
// println!("[get_author_id] GraphQL: {}", gql);
|
|
||||||
let client = HTTPClient::new();
|
|
||||||
let response = client
|
|
||||||
.post(&api_base)
|
|
||||||
.headers(headers)
|
|
||||||
.json(&gql)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
let r: HashMap<String, serde_json::Value> = response.json().await?;
|
|
||||||
let author_id = r
|
|
||||||
.get("data")
|
|
||||||
.and_then(|data| data.get(query_name))
|
|
||||||
.and_then(|claims| claims.get("id"))
|
|
||||||
.and_then(|id| id.as_i64());
|
|
||||||
|
|
||||||
match author_id {
|
|
||||||
Some(id) => {
|
|
||||||
println!("Author ID retrieved: {}", id);
|
|
||||||
Ok(id as i32)
|
|
||||||
}
|
|
||||||
None => Err(Box::new(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::Other,
|
|
||||||
"No author ID found in the response",
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(Box::new(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::Other,
|
|
||||||
format!("Request failed with status: {}", response.status()),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_id_by_token(token: &str) -> Result<i32, Box<dyn Error>> {
|
|
||||||
let auth_api_base = env::var("AUTH_URL")?;
|
|
||||||
let query_name = "validate_jwt_token";
|
|
||||||
let operation = "ValidateToken";
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
// headers.insert(AUTHORIZATION, HeaderValue::from_str(token)?);
|
|
||||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
||||||
|
|
||||||
let mut variables = HashMap::<String, HashMap<String, String>>::new();
|
|
||||||
let mut params = HashMap::<String, String>::new();
|
|
||||||
params.insert("token".to_string(), token.to_string());
|
|
||||||
params.insert("token_type".to_string(), "access_token".to_string());
|
|
||||||
variables.insert("params".to_string(), params);
|
|
||||||
|
|
||||||
let gql = json!({
|
|
||||||
"query": format!("query {}($params: ValidateJWTTokenInput!) {{ {}(params: $params) {{ is_valid claims }} }}", operation, query_name),
|
|
||||||
"operationName": operation,
|
|
||||||
"variables": variables
|
|
||||||
});
|
|
||||||
println!("[get_id_by_token] GraphQL Query: {}", gql);
|
|
||||||
let client = HTTPClient::new();
|
|
||||||
let response = client
|
|
||||||
.post(&auth_api_base)
|
|
||||||
.headers(headers)
|
|
||||||
.json(&gql)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
let r: HashMap<String, serde_json::Value> = response.json().await?;
|
|
||||||
let user_id = r
|
|
||||||
.get("data")
|
|
||||||
.and_then(|data| data.get(query_name))
|
|
||||||
.and_then(|query| query.get("claims"))
|
|
||||||
.and_then(|claims| claims.get("sub"))
|
|
||||||
.and_then(|id| id.as_str())
|
|
||||||
.map(|id| id.trim());
|
|
||||||
|
|
||||||
match user_id {
|
|
||||||
Some(id) => {
|
|
||||||
println!("[get_id_by_token] User ID retrieved: {}", id);
|
|
||||||
let author_id = get_author_id(id).await?;
|
|
||||||
Ok(author_id as i32)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!("[get_id_by_token] No user ID found in the response");
|
|
||||||
Err(Box::new(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::Other,
|
|
||||||
"No user ID found in the response",
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(Box::new(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::Other,
|
|
||||||
format!("Request failed with status: {}", response.status()),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_shout_followers(shout_id: &str) -> Result<Vec<i32>, Box<dyn Error>> {
|
|
||||||
let api_base = env::var("API_BASE")?;
|
|
||||||
let query = r#"query GetShoutFollowers($slug: String, shout_id: Int) {
|
|
||||||
get_shout_followers(slug: $slug, shout_id: $shout_id) { id }
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
let shout_id = shout_id.parse::<i32>()?;
|
|
||||||
let variables = json!({
|
|
||||||
"shout": shout_id
|
|
||||||
});
|
|
||||||
let body = json!({
|
|
||||||
"query": query,
|
|
||||||
"operationName": "GetShoutFollowers",
|
|
||||||
"variables": variables
|
|
||||||
});
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let response = client.post(&api_base).json(&body).send().await?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
let response_body: serde_json::Value = response.json().await?;
|
|
||||||
let ids: Vec<i32> = response_body["data"]["get_shout_followers"]
|
|
||||||
.as_array()
|
|
||||||
.ok_or("Failed to parse follower array")?
|
|
||||||
.iter()
|
|
||||||
.filter_map(|f| f["id"].as_i64().map(|id| id as i32))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(ids)
|
|
||||||
} else {
|
|
||||||
println!("Request failed with status: {}", response.status());
|
|
||||||
Err(Box::new(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::Other,
|
|
||||||
format!(
|
|
||||||
"[get_shout_followers] Request failed with status: {}",
|
|
||||||
response.status()
|
|
||||||
),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn is_fitting(
|
|
||||||
listener_id: i32,
|
|
||||||
message_data: SSEMessageData,
|
|
||||||
) -> Result<bool, &'static str> {
|
|
||||||
if message_data.entity == "reaction" {
|
|
||||||
// payload is Reaction
|
|
||||||
let shout_id = message_data.payload.get("shout").unwrap().as_str().unwrap();
|
|
||||||
let recipients = get_shout_followers(shout_id).await.unwrap();
|
|
||||||
|
|
||||||
Ok(recipients.contains(&listener_id))
|
|
||||||
} else if message_data.entity == "shout" {
|
|
||||||
// payload is Shout
|
|
||||||
|
|
||||||
// TODO: check all shout.communities subscribers if no then
|
|
||||||
// TODO: check all shout.topics subscribers if no then
|
|
||||||
// TODO: check all shout.authors subscribers ???
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
} else if message_data.entity == "chat" {
|
|
||||||
// payload is Chat
|
|
||||||
Ok(true)
|
|
||||||
} else if message_data.entity == "message" {
|
|
||||||
// payload is Message
|
|
||||||
Ok(true)
|
|
||||||
} else if message_data.entity == "follower" {
|
|
||||||
// payload is Author
|
|
||||||
Ok(true)
|
|
||||||
} else {
|
|
||||||
eprintln!("[data] unknown entity");
|
|
||||||
eprintln!("{:?}", message_data);
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
345
src/main.rs
345
src/main.rs
|
@ -1,204 +1,209 @@
|
||||||
use actix_web::error::{ErrorInternalServerError as ServerError, ErrorUnauthorized};
|
use actix_web::{
|
||||||
use actix_web::middleware::Logger;
|
error::{ErrorInternalServerError, ErrorUnauthorized},
|
||||||
use actix_web::{web, web::Bytes, App, HttpRequest, HttpResponse, HttpServer};
|
middleware::Logger,
|
||||||
use futures::StreamExt;
|
web, App, HttpRequest, HttpResponse, HttpServer, Result,
|
||||||
use redis::{AsyncCommands, Client};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use aws_config::{load_defaults, BehaviorVersion};
|
||||||
use serde_json::Value;
|
use aws_sdk_s3::Client as S3Client;
|
||||||
use uuid::Uuid;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use std::collections::HashMap;
|
use image::DynamicImage;
|
||||||
|
use image::imageops::FilterType;
|
||||||
|
use mime_guess::MimeGuess;
|
||||||
|
use redis::{aio::MultiplexedConnection, AsyncCommands};
|
||||||
|
use redis::Client as RedisClient;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::str::FromStr;
|
use std::io::Cursor;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::path::Path;
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use sentry::types::Dsn;
|
|
||||||
use sentry_actix;
|
|
||||||
|
|
||||||
mod data;
|
const MAX_QUOTA_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GB per week
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
tasks: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
|
redis: MultiplexedConnection, // Redis connection for managing quotas and file names
|
||||||
redis: Client,
|
s3_client: S3Client, // S3 client for uploading files
|
||||||
|
s3_bucket: String, // S3 bucket name for storing files
|
||||||
|
cdn_domain: String, // CDN domain for generating URLs
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
// Generate a thumbnail for the image
|
||||||
struct RedisMessageData {
|
fn generate_thumbnail(image: &DynamicImage) -> Result<Vec<u8>, actix_web::Error> {
|
||||||
payload: HashMap<String, Value>,
|
let thumbnail = image.resize(320, 320, FilterType::Lanczos3); // Размер миниатюры 320x320
|
||||||
action: String
|
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
thumbnail
|
||||||
|
.write_to(&mut Cursor::new(&mut buffer), image::ImageFormat::Jpeg)
|
||||||
|
.map_err(|_| ErrorInternalServerError("Failed to generate thumbnail"))?;
|
||||||
|
|
||||||
|
Ok(buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
// Upload the file to S3 and return the URL
|
||||||
pub struct SSEMessageData {
|
async fn upload_to_s3(
|
||||||
payload: HashMap<String, Value>,
|
s3_client: &S3Client,
|
||||||
action: String,
|
bucket: &str,
|
||||||
entity: String
|
key: &str,
|
||||||
|
body: Vec<u8>,
|
||||||
|
content_type: &str,
|
||||||
|
cdn_domain: &str,
|
||||||
|
) -> Result<String, actix_web::Error> {
|
||||||
|
let body_stream = ByteStream::from(body);
|
||||||
|
|
||||||
|
s3_client.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(body_stream)
|
||||||
|
.content_type(content_type)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ErrorInternalServerError("Failed to upload file to S3"))?;
|
||||||
|
|
||||||
|
Ok(format!("{}/{}", cdn_domain, key))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn connect_handler(
|
// Check and update the user's quota
|
||||||
|
async fn check_and_update_quota(
|
||||||
|
redis: &mut MultiplexedConnection,
|
||||||
|
user_id: &str,
|
||||||
|
file_size: u64,
|
||||||
|
) -> Result<(), actix_web::Error> {
|
||||||
|
let current_quota: u64 = redis.get(user_id).await.unwrap_or(0);
|
||||||
|
|
||||||
|
if current_quota + file_size > MAX_QUOTA_BYTES {
|
||||||
|
return Err(ErrorUnauthorized("Quota exceeded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
redis.incr(user_id, file_size).await.map_err(|_| ErrorInternalServerError("Failed to update quota in Redis"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy handler for serving static files and uploading them to S3
|
||||||
|
async fn proxy_handler(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
|
path: web::Path<String>,
|
||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
let token = req.headers().get("Authorization").and_then(|header_value| header_value.to_str().ok());
|
||||||
|
|
||||||
let token = match req.headers().get("Authorization") {
|
// Validate token (implementation needed)
|
||||||
Some(val) => val.to_str().unwrap_or("").split(" ").last().unwrap_or(""),
|
if token.is_none() {
|
||||||
None => match req.match_info().get("token") {
|
return Err(ErrorUnauthorized("Unauthorized"));
|
||||||
Some(val) => val,
|
|
||||||
None => match req.query_string().split('=').last() {
|
|
||||||
Some(val) => val,
|
|
||||||
None => return Err(ErrorUnauthorized("Unauthorized")),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let listener_id = data::get_id_by_token(&token).await.map_err(|e| {
|
|
||||||
eprintln!("TOKEN check failed: {}", e);
|
|
||||||
ErrorUnauthorized("Unauthorized")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut con = state.redis.get_multiplexed_async_connection().await.map_err(|e| {
|
|
||||||
eprintln!("Failed to get async connection: {}", e);
|
|
||||||
ServerError("Internal Server Error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
con.sadd::<&str, &i32, usize>("authors-online", &listener_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Failed to add author to online list: {}", e);
|
|
||||||
ServerError("Internal Server Error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let chats: Vec<String> = con
|
|
||||||
.smembers::<String, Vec<String>>(format!("chats_by_author/{}", listener_id))
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Failed to get chats by author: {}", e);
|
|
||||||
ServerError("Internal Server Error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let (tx, rx) = broadcast::channel(100);
|
|
||||||
let state_clone = state.clone();
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
let mut pubsub = state_clone.redis.get_async_pubsub().await.unwrap();
|
|
||||||
let followers_channel = format!("follower:{}", listener_id);
|
|
||||||
pubsub.subscribe(followers_channel.clone()).await.unwrap();
|
|
||||||
println!("'{}' pubsub subscribed", followers_channel);
|
|
||||||
pubsub.subscribe("shout").await.unwrap();
|
|
||||||
println!("'shout' pubsub subscribed");
|
|
||||||
pubsub.subscribe("reaction").await.unwrap();
|
|
||||||
println!("'reaction' pubsub subscribed");
|
|
||||||
|
|
||||||
// chats by member_id
|
|
||||||
pubsub.subscribe(format!("chat:{}", listener_id)).await.unwrap();
|
|
||||||
println!("'chat:{}' pubsub subscribed", listener_id);
|
|
||||||
|
|
||||||
// messages by chat_id
|
|
||||||
for chat_id in &chats {
|
|
||||||
let channel_name = format!("message:{}", chat_id);
|
|
||||||
pubsub.subscribe(&channel_name).await.unwrap();
|
|
||||||
println!("'{}' subscribed", channel_name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Some(msg) = pubsub.on_message().next().await {
|
let user_id = token.unwrap(); // Assuming the token is the user ID, adjust as necessary
|
||||||
let redis_message_str: String = msg.get_payload().unwrap();
|
|
||||||
let redis_message_data: RedisMessageData = serde_json::from_str(&redis_message_str).unwrap();
|
// Load the file (implement your file loading logic)
|
||||||
let prepared_message_data = SSEMessageData {
|
let file_path = path.into_inner();
|
||||||
payload: redis_message_data.payload,
|
let mime_type = MimeGuess::from_path(&file_path).first_or_octet_stream();
|
||||||
action: redis_message_data.action,
|
let extension = Path::new(&file_path)
|
||||||
entity: msg.get_channel_name()
|
.extension()
|
||||||
.to_owned()
|
.and_then(|ext| ext.to_str())
|
||||||
.split(":")
|
.unwrap_or("bin");
|
||||||
.next()
|
|
||||||
.unwrap_or("")
|
// Handle image files: generate thumbnail and upload both
|
||||||
.to_string()
|
if mime_type.type_() == "image" {
|
||||||
};
|
let image = image::open(&file_path).map_err(|_| ErrorInternalServerError("Failed to open image"))?;
|
||||||
if data::is_fitting(
|
|
||||||
listener_id,
|
// Generate thumbnail
|
||||||
prepared_message_data.clone(),
|
let thumbnail_data = generate_thumbnail(&image)?;
|
||||||
|
let thumbnail_key = format!("thumbnail_{}.{}", file_path, "jpg");
|
||||||
|
|
||||||
|
// Upload the thumbnail
|
||||||
|
upload_to_s3(
|
||||||
|
&state.s3_client,
|
||||||
|
&state.s3_bucket,
|
||||||
|
&thumbnail_key,
|
||||||
|
thumbnail_data.clone(),
|
||||||
|
"image/jpeg",
|
||||||
|
&state.cdn_domain,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
.is_ok()
|
|
||||||
{
|
// Prepare original image data
|
||||||
let prepared_message_str = serde_json::to_string(&prepared_message_data).unwrap();
|
let mut original_buffer = Vec::new();
|
||||||
let send_result = tx.send(prepared_message_str.clone());
|
image.write_to(&mut Cursor::new(&mut original_buffer), image::ImageFormat::Jpeg)
|
||||||
if send_result.is_err() {
|
.map_err(|_| ErrorInternalServerError("Failed to read image data"))?;
|
||||||
// remove author from online list
|
|
||||||
let _ = con
|
// Upload the original image
|
||||||
.srem::<&str, &i32, usize>("authors-online", &listener_id)
|
let image_key = format!("{}.{}", file_path, extension);
|
||||||
.await
|
let image_url = upload_to_s3(
|
||||||
.map_err(|e| {
|
&state.s3_client,
|
||||||
eprintln!("Failed to remove author from online list: {}", e);
|
&state.s3_bucket,
|
||||||
ServerError("Internal Server Error")
|
&image_key,
|
||||||
});
|
original_buffer.clone(),
|
||||||
break;
|
mime_type.essence_str(),
|
||||||
} else {
|
&state.cdn_domain,
|
||||||
println!("[handler] message handled {}", prepared_message_str);
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update quota and save filename
|
||||||
|
check_and_update_quota(&mut state.redis.clone(), user_id, original_buffer.len() as u64).await?;
|
||||||
|
save_filename_in_redis(&mut state.redis.clone(), user_id, &image_key).await?;
|
||||||
|
|
||||||
|
return Ok(HttpResponse::Ok().body(format!("Image and thumbnail uploaded to: {}", image_url)));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
state
|
|
||||||
.tasks
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.insert(format!("{}", listener_id.clone()), handle);
|
|
||||||
|
|
||||||
let server_event_stream = futures::stream::unfold(rx, |mut rx| async {
|
// Handle non-image files
|
||||||
let result = rx.recv().await;
|
let file_data = std::fs::read(&file_path).map_err(|_| ErrorInternalServerError("Failed to read file"))?;
|
||||||
match result {
|
let file_size = file_data.len() as u64;
|
||||||
Ok(server_event) => {
|
|
||||||
// Generate a random UUID as the event ID
|
|
||||||
let event_id = format!("{}", Uuid::new_v4());
|
|
||||||
|
|
||||||
let formatted_server_event = format!(
|
// Check and update the user's quota
|
||||||
"id: {}\ndata: {}\n\n",
|
check_and_update_quota(&mut state.redis.clone(), user_id, file_size).await?;
|
||||||
event_id,
|
|
||||||
server_event
|
|
||||||
);
|
|
||||||
|
|
||||||
Some((Ok::<_, actix_web::Error>(Bytes::from(formatted_server_event)), rx))
|
// Upload the file
|
||||||
},
|
let file_key = format!("{}.{}", file_path, extension);
|
||||||
Err(_) => None,
|
let file_url = upload_to_s3(
|
||||||
}
|
&state.s3_client,
|
||||||
});
|
&state.s3_bucket,
|
||||||
|
&file_key,
|
||||||
|
file_data,
|
||||||
|
mime_type.essence_str(),
|
||||||
|
&state.cdn_domain,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(HttpResponse::Ok()
|
// Save the filename in Redis for this user
|
||||||
.append_header(("content-type", "text/event-stream"))
|
save_filename_in_redis(&mut state.redis.clone(), user_id, &file_key).await?;
|
||||||
.streaming(server_event_stream))
|
|
||||||
|
Ok(HttpResponse::Ok().body(format!("File uploaded to: {}", file_url)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save filename in Redis for a specific user
|
||||||
|
async fn save_filename_in_redis(
|
||||||
|
redis: &mut MultiplexedConnection,
|
||||||
|
user_id: &str,
|
||||||
|
filename: &str,
|
||||||
|
) -> Result<(), actix_web::Error> {
|
||||||
|
redis.sadd(user_id, filename).await.map_err(|_| ErrorInternalServerError("Failed to save filename in Redis"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function to start the server
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| String::from("redis://127.0.0.1/"));
|
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
|
||||||
let client = redis::Client::open(redis_url.clone()).unwrap();
|
let redis_client = RedisClient::open(redis_url).expect("Invalid Redis URL");
|
||||||
let tasks = Arc::new(Mutex::new(HashMap::new()));
|
let redis_connection = redis_client.get_multiplexed_async_connection().await.ok().unwrap();
|
||||||
let state = AppState {
|
|
||||||
tasks: tasks.clone(),
|
// Initialize AWS S3 client
|
||||||
redis: client.clone(),
|
let s3_bucket = env::var("S3_BUCKET").expect("S3_BUCKET must be set");
|
||||||
};
|
let cdn_domain = env::var("CDN_DOMAIN").expect("CDN_DOMAIN must be set");
|
||||||
println!("Starting...");
|
let config = load_defaults(BehaviorVersion::latest()).await;
|
||||||
if let Ok(sentry_dsn) = Dsn::from_str(
|
let s3_client = S3Client::new(&config);
|
||||||
&env::var("GLITCHTIP_DSN").unwrap_or_default(),
|
|
||||||
) {
|
// Create application state
|
||||||
let sentry_options = sentry::ClientOptions {
|
let app_state = web::Data::new(AppState {
|
||||||
release: sentry::release_name!(),
|
redis: redis_connection,
|
||||||
..Default::default()
|
s3_client,
|
||||||
};
|
s3_bucket,
|
||||||
let _guard = sentry::init((sentry_dsn, sentry_options));
|
cdn_domain,
|
||||||
println!("Sentry initialized...");
|
});
|
||||||
} else {
|
|
||||||
eprintln!("Invalid DSN, sentry was not initialized.");
|
// Start HTTP server
|
||||||
}
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(sentry_actix::Sentry::new())
|
.app_data(app_state.clone())
|
||||||
.wrap(Logger::default())
|
.wrap(Logger::default())
|
||||||
.app_data(web::Data::new(state.clone()))
|
.route("/{path:.*}", web::get().to(proxy_handler))
|
||||||
.route("/", web::get().to(connect_handler))
|
|
||||||
.route("/{token}", web::get().to(connect_handler))
|
|
||||||
})
|
})
|
||||||
.bind("0.0.0.0:8080")?
|
.bind("127.0.0.1:8080")?
|
||||||
.run()
|
.run()
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user