diff --git a/.github/workflows/bff-ci.yml b/.github/workflows/bff-ci.yml new file mode 100644 index 0000000..9be8284 --- /dev/null +++ b/.github/workflows/bff-ci.yml @@ -0,0 +1,134 @@ +name: BFF Service CI (Rust) + +on: + push: + branches: ["dev", "master"] + paths: + - "backend/service/bff_service/**" + - "backend/libs/rust-core/**" + - "backend/proto/**" + - ".github/workflows/bff-ci.yml" + pull_request: + branches: ["dev", "master"] + paths: + - "backend/service/bff_service/**" + - "backend/libs/rust-core/**" + - "backend/proto/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + WORKING_DIR: backend/service/bff_service + +jobs: + lint-and-format: + name: Code Quality (Fmt & Clippy) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Protoc (Required for tonic-build) + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + + - name: Check Formatting + working-directory: ${{ env.WORKING_DIR }} + run: cargo fmt --all -- --check + + - name: Run Clippy (Strict Mode) + working-directory: ${{ env.WORKING_DIR }} + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + security-audit: + name: Security Audit (Cargo Audit) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + uses: taiki-e/install-action@cargo-audit + + - name: Run Vulnerability Scan + working-directory: ${{ env.WORKING_DIR }} + run: cargo audit + + test-and-coverage: + name: Unit & Integration Tests + runs-on: ubuntu-latest + needs: [lint-and-format, security-audit] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + + - name: Run Tests & Generate Coverage + working-directory: ${{ env.WORKING_DIR }} + run: cargo llvm-cov test --all-features --workspace --lcov --output-path lcov.info --fail-under-lines 80 + + - name: Upload Coverage to Codecov (Optional) + uses: codecov/codecov-action@v4 + with: + file: ${{ env.WORKING_DIR }}/lcov.info + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + docker-build-scan: + name: Build Docker & Trivy Scan + runs-on: ubuntu-latest + needs: test-and-coverage + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker Image + uses: docker/build-push-action@v5 + with: + context: backend + file: backend/service/bff_service/Dockerfile + push: false + tags: stremo/bff-service:dev-test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: "stremo/bff-service:dev-test" + format: "table" + exit-code: "1" + ignore-unfixed: true + vuln-type: "os,library" + severity: "CRITICAL,HIGH" diff --git a/backend/proto/auth.proto b/backend/proto/auth.proto new file mode 100644 index 0000000..8337e99 --- /dev/null +++ b/backend/proto/auth.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package stremo.auth.v1; + +service AuthService { + rpc Login(LoginRequest) returns (LoginResponse); + rpc Register(RegisterRequest) returns (RegisterResponse); +} + +message LoginRequest { + string email = 1; + string password = 2; +} + +message LoginResponse { + string access_token = 1; + string refresh_token = 2; + uint32 expires_in = 3; +} + +message RegisterRequest { + string username = 1; + string email = 2; + string password = 3; +} + +message RegisterResponse { + string id = 1; + string message = 2; +} diff --git a/backend/proto/stream_meta.proto b/backend/proto/stream_meta.proto new file mode 100644 index 0000000..6eb9454 --- /dev/null +++ b/backend/proto/stream_meta.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package stremo.stream_meta.v1; + +service StreamMetaService { + rpc GetLiveStreams(GetLiveStreamsRequest) returns (GetLiveStreamsResponse); +} + +message GetLiveStreamsRequest { + int32 limit = 1; + string cursor = 2; +} + +message StreamDto { + string stream_id = 1; + string title = 2; + string category = 3; + int32 viewers_count = 4; +} + +message GetLiveStreamsResponse { + repeated StreamDto streams = 1; + string next_cursor = 2; +} diff --git a/backend/service/bff_service/.dockerignore b/backend/service/bff_service/.dockerignore new file mode 100644 index 0000000..584000c --- /dev/null +++ b/backend/service/bff_service/.dockerignore @@ -0,0 +1,6 @@ +target/ +**/*.rs.bk +Cargo.lock +.git/ +.env +Dockerfile diff --git a/backend/service/bff_service/Cargo.toml b/backend/service/bff_service/Cargo.toml new file mode 100644 index 0000000..264ee4e --- /dev/null +++ b/backend/service/bff_service/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "bff-service" +version = "0.1.0" +edition = "2021" + +[dependencies] +rust-core = { path = "../../libs/rust-core" } +axum = "0.7" +tokio = { version = "1.37", features = ["full"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["trace", "cors"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +jsonwebtoken = "9.3" +tonic = "0.11" +prost = "0.12" +redis = { version = "0.25", features = ["tokio-comp"] } +bb8 = "0.8" +bb8-redis = "0.15" +dotenvy = "0.15" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1.8", features = ["v4", "serde"] } +anyhow = "1.0.102" +[build-dependencies] +tonic-build = "0.11" diff --git a/backend/service/bff_service/Dockerfile b/backend/service/bff_service/Dockerfile new file mode 100644 index 0000000..e65591a --- /dev/null +++ b/backend/service/bff_service/Dockerfile @@ -0,0 +1,14 @@ +FROM rust:1.85-slim-bookworm AS builder +RUN apt-get update && apt-get install -y pkg-config libssl-dev cmake build-essential +WORKDIR /app + +COPY . . +RUN cargo build --release -p bff-service + +FROM gcr.io/distroless/cc-debian12 +WORKDIR /app +COPY --from=builder /app/target/release/bff-service /app/bff-service +ENV RUST_LOG=info +ENV PORT=8000 +EXPOSE 8000 +CMD ["/app/bff-service"] diff --git a/backend/service/bff_service/build.rs b/backend/service/bff_service/build.rs new file mode 100644 index 0000000..31d43c9 --- /dev/null +++ b/backend/service/bff_service/build.rs @@ -0,0 +1,20 @@ +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=../../proto/auth.proto"); + println!("cargo:rerun-if-changed=../../proto/stream_meta.proto"); + println!("cargo:rerun-if-changed=../../proto/errors.proto"); + + tonic_build::configure() + .build_server(false) + .build_client(true) + .out_dir(std::env::var("OUT_DIR").unwrap()) + .compile( + &[ + "../../proto/auth.proto", + "../../proto/stream_meta.proto", + "../../proto/errors.proto", + ], + &["../../proto"], + )?; + + Ok(()) +} diff --git a/backend/service/bff_service/src/api/handlers/auth.rs b/backend/service/bff_service/src/api/handlers/auth.rs new file mode 100644 index 0000000..f753f6f --- /dev/null +++ b/backend/service/bff_service/src/api/handlers/auth.rs @@ -0,0 +1,51 @@ +use axum::{extract::State, Json}; +use std::sync::Arc; + +use crate::{ + models::{ + requests::{LoginRequest, RegisterRequest}, + responses::{LoginResponse, RegisterResponse}, + }, + utils::errors::AppError, + utils::state::AppState, +}; + +pub async fn login_handler( + State(state): State>, + Json(payload): Json, +) -> Result, AppError> { + if payload.email.is_empty() || payload.password.is_empty() { + return Err(AppError::BadRequest( + "Email and password are required".to_string(), + )); + } + + let mut auth_client = state.auth_grpc_client.clone(); + + let grpc_res = auth_client.login(payload.email, payload.password).await?; + + Ok(Json(LoginResponse { + access_token: grpc_res.access_token, + refresh_token: grpc_res.refresh_token, + expires_in: grpc_res.expires_in, + })) +} + +pub async fn register_handler( + State(state): State>, + Json(payload): Json, +) -> Result, AppError> { + if payload.username.is_empty() { + return Err(AppError::BadRequest("Username is required".to_string())); + } + + let mut auth_client = state.auth_grpc_client.clone(); + let grpc_res = auth_client + .register(payload.username, payload.email, payload.password) + .await?; + + Ok(Json(RegisterResponse { + id: grpc_res.id, + message: grpc_res.message, + })) +} diff --git a/backend/service/bff_service/src/api/handlers/mod.rs b/backend/service/bff_service/src/api/handlers/mod.rs new file mode 100644 index 0000000..1d16316 --- /dev/null +++ b/backend/service/bff_service/src/api/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod stream; diff --git a/backend/service/bff_service/src/api/handlers/stream.rs b/backend/service/bff_service/src/api/handlers/stream.rs new file mode 100644 index 0000000..8533072 --- /dev/null +++ b/backend/service/bff_service/src/api/handlers/stream.rs @@ -0,0 +1,41 @@ +use axum::{ + extract::{Query, State}, + Json, +}; +use std::sync::Arc; + +use crate::{ + models::{ + requests::GetStreamsQuery, + responses::{StreamCatalogItem, StreamCatalogResponse}, + }, + utils::errors::AppError, + utils::state::AppState, +}; + +pub async fn get_live_streams_handler( + State(state): State>, + Query(query): Query, +) -> Result, AppError> { + let limit = query.limit.unwrap_or(20); + let cursor = query.cursor.unwrap_or_default(); + + let mut stream_client = state.stream_meta_grpc_client.clone(); + let grpc_res = stream_client.get_live_streams(limit, cursor).await?; + + let streams = grpc_res + .streams + .into_iter() + .map(|s| StreamCatalogItem { + stream_id: s.stream_id, + title: s.title, + category: s.category, + viewers_count: s.viewers_count, + }) + .collect(); + + Ok(Json(StreamCatalogResponse { + data: streams, + next_cursor: grpc_res.next_cursor, + })) +} diff --git a/backend/service/bff_service/src/api/mod.rs b/backend/service/bff_service/src/api/mod.rs new file mode 100644 index 0000000..c0c696a --- /dev/null +++ b/backend/service/bff_service/src/api/mod.rs @@ -0,0 +1,2 @@ +pub mod handlers; +pub mod routes; diff --git a/backend/service/bff_service/src/api/routes/auth.rs b/backend/service/bff_service/src/api/routes/auth.rs new file mode 100644 index 0000000..dde6964 --- /dev/null +++ b/backend/service/bff_service/src/api/routes/auth.rs @@ -0,0 +1,9 @@ +use crate::{api::handlers::auth, utils::state::AppState}; +use axum::{routing::post, Router}; +use std::sync::Arc; + +pub fn router() -> Router> { + Router::new() + .route("/login", post(auth::login_handler)) + .route("/register", post(auth::register_handler)) +} diff --git a/backend/service/bff_service/src/api/routes/mod.rs b/backend/service/bff_service/src/api/routes/mod.rs new file mode 100644 index 0000000..1d16316 --- /dev/null +++ b/backend/service/bff_service/src/api/routes/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod stream; diff --git a/backend/service/bff_service/src/api/routes/stream.rs b/backend/service/bff_service/src/api/routes/stream.rs new file mode 100644 index 0000000..14f61cc --- /dev/null +++ b/backend/service/bff_service/src/api/routes/stream.rs @@ -0,0 +1,7 @@ +use crate::{api::handlers::stream, utils::state::AppState}; +use axum::{routing::get, Router}; +use std::sync::Arc; + +pub fn router() -> Router> { + Router::new().route("/live", get(stream::get_live_streams_handler)) +} diff --git a/backend/service/bff_service/src/config.rs b/backend/service/bff_service/src/config.rs new file mode 100644 index 0000000..e3d25a3 --- /dev/null +++ b/backend/service/bff_service/src/config.rs @@ -0,0 +1,29 @@ +#[derive(Clone, Debug)] +pub struct Config { + pub port: u16, + pub redis_url: String, + pub auth_service_grpc_url: String, + pub stream_meta_grpc_url: String, + pub jwt_secret: String, +} + +impl Config { + pub fn from_env() -> Self { + dotenvy::dotenv().ok(); + + Self { + port: std::env::var("PORT") + .unwrap_or_else(|_| "8000".to_string()) + .parse() + .expect("PORT must be a number"), + redis_url: std::env::var("REDIS_URL") + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()), + auth_service_grpc_url: std::env::var("AUTH_SERVICE_GRPC_URL") + .unwrap_or_else(|_| "http://127.0.0.1:50051".to_string()), + stream_meta_grpc_url: std::env::var("STREAM_META_SERVICE_GRPC_URL") + .unwrap_or_else(|_| "http://127.0.0.1:50052".to_string()), + jwt_secret: std::env::var("JWT_SECRET") + .unwrap_or_else(|_| "super_secret_key_for_local_dev".to_string()), + } + } +} diff --git a/backend/service/bff_service/src/grpc_clients/auth_client.rs b/backend/service/bff_service/src/grpc_clients/auth_client.rs new file mode 100644 index 0000000..d609276 --- /dev/null +++ b/backend/service/bff_service/src/grpc_clients/auth_client.rs @@ -0,0 +1,43 @@ +use super::pb::auth::{ + auth_service_client::AuthServiceClient, LoginRequest, LoginResponse, RegisterRequest, + RegisterResponse, +}; +use crate::utils::errors::AppError; +use tonic::transport::Channel; + +#[derive(Clone)] +pub struct AuthGrpcClient { + client: AuthServiceClient, +} + +impl AuthGrpcClient { + pub async fn connect(url: String) -> Result> { + let client = AuthServiceClient::connect(url).await?; + Ok(Self { client }) + } + + pub async fn login( + &mut self, + email: String, + password: String, + ) -> Result { + let request = tonic::Request::new(LoginRequest { email, password }); + let response = self.client.login(request).await?; + Ok(response.into_inner()) + } + + pub async fn register( + &mut self, + username: String, + email: String, + password: String, + ) -> Result { + let request = tonic::Request::new(RegisterRequest { + username, + email, + password, + }); + let response = self.client.register(request).await?; + Ok(response.into_inner()) + } +} diff --git a/backend/service/bff_service/src/grpc_clients/mod.rs b/backend/service/bff_service/src/grpc_clients/mod.rs new file mode 100644 index 0000000..81097bd --- /dev/null +++ b/backend/service/bff_service/src/grpc_clients/mod.rs @@ -0,0 +1,11 @@ +pub mod auth_client; +pub mod stream_meta_client; + +pub mod pb { + pub mod auth { + tonic::include_proto!("stremo.auth.v1"); + } + pub mod stream_meta { + tonic::include_proto!("stremo.stream_meta.v1"); + } +} diff --git a/backend/service/bff_service/src/grpc_clients/stream_meta_client.rs b/backend/service/bff_service/src/grpc_clients/stream_meta_client.rs new file mode 100644 index 0000000..75aac2e --- /dev/null +++ b/backend/service/bff_service/src/grpc_clients/stream_meta_client.rs @@ -0,0 +1,28 @@ +use super::pb::stream_meta::{ + stream_meta_service_client::StreamMetaServiceClient, GetLiveStreamsRequest, + GetLiveStreamsResponse, +}; +use crate::utils::errors::AppError; +use tonic::transport::Channel; + +#[derive(Clone)] +pub struct StreamMetaGrpcClient { + client: StreamMetaServiceClient, +} + +impl StreamMetaGrpcClient { + pub async fn connect(url: String) -> Result> { + let client = StreamMetaServiceClient::connect(url).await?; + Ok(Self { client }) + } + + pub async fn get_live_streams( + &mut self, + limit: i32, + cursor: String, + ) -> Result { + let request = tonic::Request::new(GetLiveStreamsRequest { limit, cursor }); + let response = self.client.get_live_streams(request).await?; + Ok(response.into_inner()) + } +} diff --git a/backend/service/bff_service/src/main.rs b/backend/service/bff_service/src/main.rs new file mode 100644 index 0000000..4dc5545 --- /dev/null +++ b/backend/service/bff_service/src/main.rs @@ -0,0 +1,97 @@ +use axum::Router; +use bb8::Pool; +use bb8_redis::RedisConnectionManager; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::signal; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +mod api; +mod config; +mod grpc_clients; +mod middleware; +mod models; +mod utils; + +pub use utils::state::AppState; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info,bff_service=debug".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + tracing::info!("Starting BFF Service (API Gateway)..."); + + let config = config::Config::from_env(); + let manager = RedisConnectionManager::new(config.redis_url.clone())?; + let redis_pool = Pool::builder().build(manager).await?; + + tracing::info!("Connecting to gRPC backends..."); + let auth_grpc_client = + grpc_clients::auth_client::AuthGrpcClient::connect(config.auth_service_grpc_url.clone()) + .await + .expect("Failed to connect to Auth Service"); + + let stream_meta_grpc_client = grpc_clients::stream_meta_client::StreamMetaGrpcClient::connect( + config.stream_meta_grpc_url.clone(), + ) + .await + .expect("Failed to connect to Stream Meta Service"); + + let state = Arc::new(AppState { + config: config.clone(), + redis_pool, + auth_grpc_client, + stream_meta_grpc_client, + }); + + let app = Router::new() + .nest("/v1/auth", api::routes::auth::router()) + .nest("/v1/stream", api::routes::stream::router()) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::rate_limiter::rate_limit_middleware, + )) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); + tracing::info!("Gateway listening on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("Failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + tracing::info!("Received shutdown signal, starting graceful shutdown..."); +} diff --git a/backend/service/bff_service/src/middleware/jwt_auth.rs b/backend/service/bff_service/src/middleware/jwt_auth.rs new file mode 100644 index 0000000..100d452 --- /dev/null +++ b/backend/service/bff_service/src/middleware/jwt_auth.rs @@ -0,0 +1,66 @@ +use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; +use jsonwebtoken::{decode, DecodingKey, Validation}; +use redis::AsyncCommands; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::{utils::errors::AppError, utils::state::AppState}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, + pub username: String, + pub exp: usize, +} + +#[async_trait] +impl FromRequestParts> for Claims { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let auth_header = parts + .headers + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or_else(|| AppError::Unauthorized("Token is missing".to_string()))?; + + if !auth_header.starts_with("Bearer ") { + return Err(AppError::Unauthorized("Invalid token format".to_string())); + } + + let token = &auth_header[7..]; + + let token_data = decode::( + token, + &DecodingKey::from_secret(state.config.jwt_secret.as_bytes()), + &Validation::default(), + ) + .map_err(|_| AppError::Unauthorized("Invalid token".to_string()))?; + + let mut redis_conn = state.redis_pool.get().await.map_err(|e| { + tracing::error!("Redis pool error: {}", e); + AppError::InternalError(anyhow::anyhow!("DB Connection Error")) + })?; + + let blacklist_key = format!("blacklist:{}", token_data.claims.sub); + let is_blacklisted: bool = redis_conn + .exists(&blacklist_key) + .await + .map_err(|_| AppError::InternalError(anyhow::anyhow!("Redis Error")))?; + + if is_blacklisted { + tracing::warn!( + "Access attempt with revoked token: {}", + token_data.claims.sub + ); + return Err(AppError::Unauthorized( + "Session revoked. Re-login required.".to_string(), + )); + } + + Ok(token_data.claims) + } +} diff --git a/backend/service/bff_service/src/middleware/mod.rs b/backend/service/bff_service/src/middleware/mod.rs new file mode 100644 index 0000000..38b63cd --- /dev/null +++ b/backend/service/bff_service/src/middleware/mod.rs @@ -0,0 +1,2 @@ +pub mod jwt_auth; +pub mod rate_limiter; diff --git a/backend/service/bff_service/src/middleware/rate_limiter.rs b/backend/service/bff_service/src/middleware/rate_limiter.rs new file mode 100644 index 0000000..ce89738 --- /dev/null +++ b/backend/service/bff_service/src/middleware/rate_limiter.rs @@ -0,0 +1,72 @@ +use axum::{ + extract::{Request, State}, + middleware::Next, + response::Response, +}; +use tracing_subscriber::fmt::format; + +use crate::{ + models::requests, + utils::{errors::AppError, state::AppState}, +}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +pub async fn rate_limit_middleware( + State(state): State>, + req: Request, + next: Next, +) -> Result { + let ip = req + .headers() + .get("x-forwarded-for") + .and_then(|val| val.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(|s| s.trim().to_string()) + .or_else(|| { + req.headers() + .get("x-real-ip") + .and_then(|val| val.to_str().ok()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "unknown_ip".to_string()); + + let redis_key = format!("rate_limit:ip: {}", ip); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + + let window_start = now - 60_000; + let request_id = Uuid::new_v4().to_string(); + + let mut redis_conn = state.redis_pool.get().await.map_err(|e| { + tracing::error!("Redis pool error: {}", e); + AppError::InternalError(anyhow::anyhow!("DB Connection Error")) + })?; + + let (_, _, count, _): ((), (), i32, ()) = redis::pipe() + .atomic() + .zrembyscore(&redis_key, 0, window_start as i64) + .zadd(&redis_key, &request_id, now as i64) + .zcard(&redis_key) + .expire(&redis_key, 60) + .query_async(&mut *redis_conn) + .await + .map_err(|e| { + tracing::error!("Redis pipeline error: {}", e); + AppError::InternalError(anyhow::anyhow!("Redis Error")) + })?; + + if count > 100 { + tracing::warn!( + "Rate limit exceeded (Sliding Window) for IP: {} (count: {})", + ip, + count + ); + return Err(AppError::BadRequest("Too Many Requests".to_string())); + } + + Ok(next.run(req).await) +} diff --git a/backend/service/bff_service/src/models/mod.rs b/backend/service/bff_service/src/models/mod.rs new file mode 100644 index 0000000..116da0f --- /dev/null +++ b/backend/service/bff_service/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod requests; +pub mod responses; diff --git a/backend/service/bff_service/src/models/requests.rs b/backend/service/bff_service/src/models/requests.rs new file mode 100644 index 0000000..b9d837d --- /dev/null +++ b/backend/service/bff_service/src/models/requests.rs @@ -0,0 +1,20 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub email: String, + pub password: String, +} + +#[derive(Deserialize)] +pub struct GetStreamsQuery { + pub limit: Option, + pub cursor: Option, +} diff --git a/backend/service/bff_service/src/models/responses.rs b/backend/service/bff_service/src/models/responses.rs new file mode 100644 index 0000000..b01769c --- /dev/null +++ b/backend/service/bff_service/src/models/responses.rs @@ -0,0 +1,28 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct LoginResponse { + pub access_token: String, + pub refresh_token: String, + pub expires_in: u32, +} + +#[derive(Serialize)] +pub struct RegisterResponse { + pub id: String, + pub message: String, +} + +#[derive(Serialize)] +pub struct StreamCatalogItem { + pub stream_id: String, + pub title: String, + pub category: String, + pub viewers_count: i32, +} + +#[derive(Serialize)] +pub struct StreamCatalogResponse { + pub data: Vec, + pub next_cursor: String, +} diff --git a/backend/service/bff_service/src/utils/errors.rs b/backend/service/bff_service/src/utils/errors.rs new file mode 100644 index 0000000..5590ce9 --- /dev/null +++ b/backend/service/bff_service/src/utils/errors.rs @@ -0,0 +1,80 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("Internal Server Error")] + InternalError(anyhow::Error), + + #[error("Invalid Credentials")] + InvalidCredentials, + + #[error("Unauthorized: {0}")] + Unauthorized(String), + + #[error("Bad Request: {0}")] + BadRequest(String), + + #[error("gRPC Error: {0}")] + GrpcError(String), +} + +impl From for AppError { + fn from(err: anyhow::Error) -> Self { + AppError::InternalError(err) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, error_code, error_message) = match self { + AppError::InternalError(ref e) => { + tracing::error!("Internal Server Error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + "Что-то пошло не так на сервере".to_string(), + ) + } + AppError::InvalidCredentials => ( + StatusCode::UNAUTHORIZED, + "INVALID_CREDENTIALS", + "Неверный email или пароль".to_string(), + ), + AppError::Unauthorized(ref msg) => { + (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.to_string()) + } + AppError::BadRequest(ref msg) => { + (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg.to_string()) + } + AppError::GrpcError(ref msg) => { + tracing::error!("gRPC call failed: {}", msg); + ( + StatusCode::BAD_GATEWAY, + "UPSTREAM_ERROR", + "Внутренний сервис недоступен".to_string(), + ) + } + }; + + let body = Json(json!({ + "error": { + "code": error_code, + "message": error_message + } + })); + + (status, body).into_response() + } +} + +impl From for AppError { + fn from(status: tonic::Status) -> Self { + AppError::GrpcError(status.message().to_string()) + } +} diff --git a/backend/service/bff_service/src/utils/mod.rs b/backend/service/bff_service/src/utils/mod.rs new file mode 100644 index 0000000..a209a6f --- /dev/null +++ b/backend/service/bff_service/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod errors; +pub mod state; diff --git a/backend/service/bff_service/src/utils/state.rs b/backend/service/bff_service/src/utils/state.rs new file mode 100644 index 0000000..62b8810 --- /dev/null +++ b/backend/service/bff_service/src/utils/state.rs @@ -0,0 +1,10 @@ +use crate::{config, grpc_clients}; +use bb8::Pool; +use bb8_redis::RedisConnectionManager; + +pub struct AppState { + pub config: config::Config, + pub redis_pool: Pool, + pub auth_grpc_client: grpc_clients::auth_client::AuthGrpcClient, + pub stream_meta_grpc_client: grpc_clients::stream_meta_client::StreamMetaGrpcClient, +}