Compare commits

..

No commits in common. "main" and "release/0.2" have entirely different histories.

196 changed files with 309 additions and 7395 deletions

View File

@ -1,20 +0,0 @@
name: Auto Comment On PR
on:
pull_request:
types:
- opened
- reopened
jobs:
add_improve_comment:
runs-on: ubuntu-latest
steps:
- name: Add '/improve' comment to PR
run: |
echo "Target URL: ${{ vars.GIT_PROVIDER_URL }}/api/v1/repos/${{ gitea.repository }}/issues/${{ gitea.ref_name }}/comments"
curl --fail -X POST \
-H "Authorization: token ${{ secrets.BOT_PAT }}" \
-H "Content-Type: application/json" \
-d '{"body": "/improve"}' \
"${{ vars.GIT_PROVIDER_URL }}/api/v1/repos/${{ gitea.repository }}/issues/${{ gitea.ref_name }}/comments"

1
backend/.gitignore vendored
View File

@ -1,2 +1,3 @@
.env
/.sqlx
/target

View File

@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO post_label (\n post_id, label_id, \"order\"\n ) VALUES ($1, $2, $3)\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Int4",
"Int4"
]
},
"nullable": []
},
"hash": "0c9effcc24f4319c47898e0ade4e5ccef3c47c014cfcb65805cbf1c625fef1e7"
}

View File

@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, mime_type\n FROM image\n WHERE id = $1 AND deleted_time IS NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "mime_type",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false
]
},
"hash": "1926140fd0232511d302cd514f41af1e619a1c68b94e18cdc53234c9de701390"
}

View File

@ -1,32 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, name, color\n FROM label\n WHERE deleted_time IS NULL\n ORDER BY id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "color",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false
]
},
"hash": "38181c2e36077c546944fbfe124c623706f920fd6b1a9a1cd143ecee6c9d5019"
}

View File

@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE label\n SET name = $1, color = $2\n WHERE id = $3 AND deleted_time IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Int4"
]
},
"nullable": []
},
"hash": "5189bdfd0aa6b4ac478cc48efde4cdbd9cc9605fe0f2c4dc4506827fa0fd2ad6"
}

View File

@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO image (mime_type)\n VALUES ($1)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "715922e4ffa6881f23ea890ebf77abd86937c3f4fe606572156a29d4441028e9"
}

View File

@ -1,47 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, issuer, source_id, displayed_name, email\n FROM \"user\"\n WHERE issuer = $1 AND source_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "issuer",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "source_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "displayed_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "9c3ae9a539390e3b0493d325439fdc73cb2925bdb17330a21db4341e5822291b"
}

View File

@ -1,46 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, issuer, source_id, displayed_name, email\n FROM \"user\"\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "issuer",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "source_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "displayed_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "9d1ffa7a71c8830d75eeeb26800ee7a7d8ede2410b423985caffd86361ad9263"
}

View File

@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, name, color\n FROM label\n WHERE id = $1 AND deleted_time IS NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "color",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "a0e1ed95ce9d705653281455cc59e8ed130a496b09dccbf89e919f4c9798e91a"
}

View File

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM post_label\n WHERE post_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "b084aa65fa3cdb1abdd02fd9e2ade04a71dd98eef245780a6f34f0b72564f63e"
}

View File

@ -1,19 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE post\n SET \n title = $1, \n description = $2, \n preview_image_url = $3, \n content = $4, \n published_time = $5\n WHERE id = $6\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Timestamp",
"Int4"
]
},
"nullable": []
},
"hash": "d0867ba2857fedcdc9a754d0394c4f040d559118d0b9f8b6f4dcd6e6fde5d381"
}

View File

@ -1,25 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO \"user\" (issuer, source_id, displayed_name, email)\n VALUES ($1, $2, $3, $4)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "e9741186ea464ef1ba3598223ad9f042ec876cbdc4e2d9eca787ff1de598551c"
}

View File

@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO post (\n title, description, preview_image_url, content, published_time\n ) VALUES ($1, $2, $3, $4, $5)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Timestamp"
]
},
"nullable": [
false
]
},
"hash": "f0c2c0fe0a30790e88449da79c859d4e3829b9b2a6a496c9a429a05fbdb2e30a"
}

View File

@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO label (name, color)\n VALUES ($1, $2)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "f4ef2b4e53389d2bf6a6299fc4e4ffd0df1393e1805ae1c37306b25c721de7e3"
}

1769
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +1,20 @@
[workspace]
members = [
"server",
"feature/auth",
"feature/common",
"feature/image",
"feature/post",
"feature/common",
]
members = ["feature/post", "server"]
resolver = "2"
[workspace.package]
version = "0.3.1"
version = "0.2.0"
edition = "2024"
[profile.release]
debug = true
[workspace.dependencies]
actix-multipart = "0.7.2"
actix-session = { version = "0.10.1", features = ["redis-session"] }
actix-web = "4.10.2"
anyhow = "1.0.98"
async-trait = "0.1.88"
chrono = "0.4.41"
dotenv = "0.15.0"
env_logger = "0.11.8"
futures = "0.3.31"
hex = "0.4.3"
log = "0.4.27"
openidconnect = { version = "4.0.1", features = [
"reqwest",
"reqwest-blocking",
] }
percent-encoding = "2.3.1"
sentry = { version = "0.42.0", features = ["actix", "anyhow"] }
serde = { version = "1.0.219", features = ["derive"] }
sqlx = { version = "0.8.5", features = [
"chrono",
@ -42,15 +23,6 @@ sqlx = { version = "0.8.5", features = [
"runtime-tokio-rustls",
] }
tokio = { version = "1.45.0", features = ["full"] }
utoipa = { version = "5.4.0", features = [
"actix_extras",
"non_strict_integers",
"url",
] }
utoipa-redoc = { version = "6.0.0", features = ["actix-web"] }
server.path = "server"
auth.path = "feature/auth"
common.path = "feature/common"
image.path = "feature/image"
post.path = "feature/post"

View File

@ -1,32 +1,22 @@
FROM rust:1-alpine AS base
RUN apk add --no-cache build-base openssl-dev openssl-libs-static
FROM rust:1-slim AS base
RUN apt update -qq && apt install -y -qq --no-install-recommends musl-tools
RUN rustup target add x86_64-unknown-linux-musl
FROM base AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
RUN cargo build --release --target x86_64-unknown-linux-musl
FROM alpine:latest AS runner
WORKDIR /app
COPY --from=builder /app/target/release/server .
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/server .
EXPOSE 8080
VOLUME ["/app/static"]
ENV RUST_LOG=info
ENV RUST_BACKTRACE=1
ENV HOST=0.0.0.0
ENV PORT=8080
ENV STORAGE_PATH=/app/static
ENV DATABASE_HOST=127.0.0.1
ENV DATABASE_PORT=5432
ENV DATABASE_USER=postgres
ENV DATABASE_PASSWORD=
ENV DATABASE_NAME=postgres
ENV REIDS_URL=redis://127.0.0.1:6379
ENV SESSION_KEY='64-bytes-hex-string-which-can-be-generated-by-`openssl rand -hex 64`'
ENV OIDC_ISSUER_URL=
ENV OIDC_REDIRECT_URL=
ENV OIDC_CLIENT_ID=
ENV OIDC_CLIENT_SECRET=
ENV SENTRY_DSN=
CMD ["./server"]

View File

@ -39,3 +39,4 @@
```bash
RUST_LOG=debug watchexec -e rs -r 'cargo run'
```

View File

@ -1,17 +0,0 @@
[package]
name = "auth"
version.workspace = true
edition.workspace = true
[dependencies]
actix-session.workspace = true
actix-web.workspace = true
anyhow.workspace = true
async-trait.workspace = true
openidconnect.workspace = true
sentry.workspace = true
serde.workspace = true
sqlx.workspace = true
utoipa.workspace = true
common.workspace = true

View File

@ -1,2 +0,0 @@
pub mod delivery;
pub mod gateway;

View File

@ -1,3 +0,0 @@
pub mod auth_controller;
pub mod oidc_callback_query_dto;
pub mod user_response_dto;

View File

@ -1,77 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
adapter::delivery::{
oidc_callback_query_dto::OidcCallbackQueryDto, user_response_dto::UserResponseDto,
},
application::{
error::auth_error::AuthError,
use_case::{
exchange_auth_code_use_case::ExchangeAuthCodeUseCase,
get_auth_url_use_case::{AuthUrl, GetAuthUrlUseCase},
get_user_use_case::GetUserUseCase,
},
},
};
#[async_trait]
pub trait AuthController: Send + Sync {
fn oidc_login(&self) -> Result<AuthUrl, AuthError>;
async fn oidc_callback(
&self,
query: OidcCallbackQueryDto,
expected_state: &str,
expected_nonce: &str,
) -> Result<UserResponseDto, AuthError>;
async fn get_user(&self, user_id: i32) -> Result<UserResponseDto, AuthError>;
}
pub struct AuthControllerImpl {
get_auth_url_use_case: Arc<dyn GetAuthUrlUseCase>,
exchange_auth_code_use_case: Arc<dyn ExchangeAuthCodeUseCase>,
get_user_use_case: Arc<dyn GetUserUseCase>,
}
impl AuthControllerImpl {
pub fn new(
get_auth_url_use_case: Arc<dyn GetAuthUrlUseCase>,
exchange_auth_code_use_case: Arc<dyn ExchangeAuthCodeUseCase>,
get_user_use_case: Arc<dyn GetUserUseCase>,
) -> Self {
Self {
get_auth_url_use_case,
exchange_auth_code_use_case,
get_user_use_case,
}
}
}
#[async_trait]
impl AuthController for AuthControllerImpl {
fn oidc_login(&self) -> Result<AuthUrl, AuthError> {
self.get_auth_url_use_case.execute()
}
async fn oidc_callback(
&self,
query: OidcCallbackQueryDto,
expected_state: &str,
expected_nonce: &str,
) -> Result<UserResponseDto, AuthError> {
let result = self
.exchange_auth_code_use_case
.execute(&query.code, &query.state, expected_state, expected_nonce)
.await;
result.map(|user| UserResponseDto::from(user))
}
async fn get_user(&self, user_id: i32) -> Result<UserResponseDto, AuthError> {
let user = self.get_user_use_case.execute(user_id).await?;
Ok(UserResponseDto::from(user))
}
}

View File

@ -1,8 +0,0 @@
use serde::Deserialize;
use utoipa::IntoParams;
#[derive(Deserialize, IntoParams)]
pub struct OidcCallbackQueryDto {
pub code: String,
pub state: String,
}

View File

@ -1,23 +0,0 @@
use serde::Serialize;
use utoipa::ToSchema;
use crate::domain::entity::user::User;
#[derive(Serialize, ToSchema)]
pub struct UserResponseDto {
pub id: i32,
pub displayed_name: String,
#[schema(format = Email)]
pub email: String,
}
impl From<User> for UserResponseDto {
fn from(user: User) -> Self {
UserResponseDto {
id: user.id,
displayed_name: user.displayed_name,
email: user.email,
}
}
}

View File

@ -1,5 +0,0 @@
pub mod auth_oidc_service;
pub mod auth_repository_impl;
pub mod oidc_claims_response_dto;
pub mod user_db_service;
pub mod user_db_mapper;

View File

@ -1,16 +0,0 @@
use async_trait::async_trait;
use crate::{
adapter::gateway::oidc_claims_response_dto::OidcClaimsResponseDto,
application::{error::auth_error::AuthError, use_case::get_auth_url_use_case::AuthUrl},
};
#[async_trait]
pub trait AuthOidcService: Send + Sync {
fn get_auth_url(&self) -> Result<AuthUrl, AuthError>;
async fn exchange_auth_code(
&self,
code: &str,
expected_nonce: &str,
) -> Result<OidcClaimsResponseDto, AuthError>;
}

View File

@ -1,74 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
adapter::gateway::{
auth_oidc_service::AuthOidcService, user_db_mapper::UserMapper,
user_db_service::UserDbService,
},
application::{
error::auth_error::AuthError, gateway::auth_repository::AuthRepository,
use_case::get_auth_url_use_case::AuthUrl,
},
domain::entity::user::User,
};
pub struct AuthRepositoryImpl {
user_db_service: Arc<dyn UserDbService>,
auth_oidc_service: Arc<dyn AuthOidcService>,
}
impl AuthRepositoryImpl {
pub fn new(
user_db_service: Arc<dyn UserDbService>,
auth_oidc_service: Arc<dyn AuthOidcService>,
) -> Self {
Self {
user_db_service,
auth_oidc_service,
}
}
}
#[async_trait]
impl AuthRepository for AuthRepositoryImpl {
fn get_auth_url(&self) -> Result<AuthUrl, AuthError> {
self.auth_oidc_service.get_auth_url()
}
async fn exchange_auth_code(
&self,
code: &str,
expected_nonce: &str,
) -> Result<User, AuthError> {
self.auth_oidc_service
.exchange_auth_code(code, expected_nonce)
.await
.map(|dto| dto.into_entity())
}
async fn get_user_by_id(&self, user_id: i32) -> Result<User, AuthError> {
self.user_db_service
.get_user_by_id(user_id)
.await
.map(|mapper| mapper.into_entity())
}
async fn get_user_by_source_id(
&self,
issuer: &str,
source_id: &str,
) -> Result<User, AuthError> {
self.user_db_service
.get_user_by_source_id(issuer, source_id)
.await
.map(|mapper| mapper.into_entity())
}
async fn save_user(&self, user: User) -> Result<i32, AuthError> {
self.user_db_service
.create_user(UserMapper::from(user))
.await
}
}

View File

@ -1,20 +0,0 @@
use crate::domain::entity::user::User;
pub struct OidcClaimsResponseDto {
pub sub: String,
pub issuer: String,
pub preferred_username: Option<String>,
pub email: Option<String>,
}
impl OidcClaimsResponseDto {
pub fn into_entity(self) -> User {
User {
id: -1,
issuer: self.issuer,
source_id: self.sub,
displayed_name: self.preferred_username.unwrap_or_default(),
email: self.email.unwrap_or_default(),
}
}
}

View File

@ -1,33 +0,0 @@
use crate::domain::entity::user::User;
pub struct UserMapper {
pub id: i32,
pub issuer: String,
pub source_id: String,
pub displayed_name: String,
pub email: String,
}
impl From<User> for UserMapper {
fn from(user: User) -> Self {
Self {
id: user.id,
issuer: user.issuer,
source_id: user.source_id,
displayed_name: user.displayed_name,
email: user.email,
}
}
}
impl UserMapper {
pub fn into_entity(self) -> User {
User {
id: self.id,
issuer: self.issuer,
source_id: self.source_id,
displayed_name: self.displayed_name,
email: self.email,
}
}
}

View File

@ -1,18 +0,0 @@
use async_trait::async_trait;
use crate::{
adapter::gateway::user_db_mapper::UserMapper, application::error::auth_error::AuthError,
};
#[async_trait]
pub trait UserDbService: Send + Sync {
async fn get_user_by_id(&self, user_id: i32) -> Result<UserMapper, AuthError>;
async fn get_user_by_source_id(
&self,
issuer: &str,
source_id: &str,
) -> Result<UserMapper, AuthError>;
async fn create_user(&self, user: UserMapper) -> Result<i32, AuthError>;
}

View File

@ -1,3 +0,0 @@
pub mod error;
pub mod gateway;
pub mod use_case;

View File

@ -1 +0,0 @@
pub mod auth_error;

View File

@ -1,24 +0,0 @@
use std::fmt::Display;
#[derive(Debug)]
pub enum AuthError {
InvalidState,
InvalidNonce,
InvalidAuthCode,
InvalidIdToken,
UserNotFound,
Unexpected(anyhow::Error),
}
impl Display for AuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthError::InvalidState => write!(f, "Invalid state"),
AuthError::InvalidNonce => write!(f, "Invalid nonce"),
AuthError::InvalidAuthCode => write!(f, "Invalid authentication code"),
AuthError::InvalidIdToken => write!(f, "Invalid ID token"),
AuthError::UserNotFound => write!(f, "User not found"),
AuthError::Unexpected(e) => write!(f, "Unexpected error: {}", e),
}
}
}

View File

@ -1 +0,0 @@
pub mod auth_repository;

View File

@ -1,21 +0,0 @@
use async_trait::async_trait;
use crate::{
application::{error::auth_error::AuthError, use_case::get_auth_url_use_case::AuthUrl},
domain::entity::user::User,
};
#[async_trait]
pub trait AuthRepository: Send + Sync {
fn get_auth_url(&self) -> Result<AuthUrl, AuthError>;
async fn exchange_auth_code(&self, code: &str, expected_nonce: &str)
-> Result<User, AuthError>;
async fn get_user_by_id(&self, user_id: i32) -> Result<User, AuthError>;
async fn get_user_by_source_id(&self, issuer: &str, source_id: &str)
-> Result<User, AuthError>;
async fn save_user(&self, user: User) -> Result<i32, AuthError>;
}

View File

@ -1,3 +0,0 @@
pub mod exchange_auth_code_use_case;
pub mod get_auth_url_use_case;
pub mod get_user_use_case;

View File

@ -1,72 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::auth_error::AuthError, gateway::auth_repository::AuthRepository},
domain::entity::user::User,
};
#[async_trait]
pub trait ExchangeAuthCodeUseCase: Send + Sync {
async fn execute(
&self,
code: &str,
received_state: &str,
expected_state: &str,
expected_nonce: &str,
) -> Result<User, AuthError>;
}
pub struct ExchangeAuthCodeUseCaseImpl {
auth_repository: Arc<dyn AuthRepository>,
}
impl ExchangeAuthCodeUseCaseImpl {
pub fn new(auth_repository: Arc<dyn AuthRepository>) -> Self {
Self { auth_repository }
}
}
#[async_trait]
impl ExchangeAuthCodeUseCase for ExchangeAuthCodeUseCaseImpl {
async fn execute(
&self,
code: &str,
received_state: &str,
expected_state: &str,
expected_nonce: &str,
) -> Result<User, AuthError> {
if received_state != expected_state {
return Err(AuthError::InvalidState);
}
let mut logged_in_user = self
.auth_repository
.exchange_auth_code(code, expected_nonce)
.await?;
let saved_user_result = self
.auth_repository
.get_user_by_source_id(&logged_in_user.issuer, &logged_in_user.source_id)
.await;
match saved_user_result {
Ok(user) => {
logged_in_user.id = user.id;
}
Err(AuthError::UserNotFound) => {
let id = self
.auth_repository
.save_user(logged_in_user.clone())
.await?;
logged_in_user.id = id;
}
Err(e) => {
return Err(e);
}
};
Ok(logged_in_user)
}
}

View File

@ -1,29 +0,0 @@
use std::sync::Arc;
use crate::application::{error::auth_error::AuthError, gateway::auth_repository::AuthRepository};
pub trait GetAuthUrlUseCase: Send + Sync {
fn execute(&self) -> Result<AuthUrl, AuthError>;
}
pub struct LoginUseCaseImpl {
auth_repository: Arc<dyn AuthRepository>,
}
impl LoginUseCaseImpl {
pub fn new(auth_repository: Arc<dyn AuthRepository>) -> Self {
Self { auth_repository }
}
}
impl GetAuthUrlUseCase for LoginUseCaseImpl {
fn execute(&self) -> Result<AuthUrl, AuthError> {
self.auth_repository.get_auth_url()
}
}
pub struct AuthUrl {
pub url: String,
pub state: String,
pub nonce: String,
}

View File

@ -1,30 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::auth_error::AuthError, gateway::auth_repository::AuthRepository},
domain::entity::user::User,
};
#[async_trait]
pub trait GetUserUseCase: Send + Sync {
async fn execute(&self, user_id: i32) -> Result<User, AuthError>;
}
pub struct GetUserUseCaseImpl {
auth_repository: Arc<dyn AuthRepository>,
}
impl GetUserUseCaseImpl {
pub fn new(auth_repository: Arc<dyn AuthRepository>) -> Self {
Self { auth_repository }
}
}
#[async_trait]
impl GetUserUseCase for GetUserUseCaseImpl {
async fn execute(&self, user_id: i32) -> Result<User, AuthError> {
self.auth_repository.get_user_by_id(user_id).await
}
}

View File

@ -1 +0,0 @@
pub mod entity;

View File

@ -1 +0,0 @@
pub mod user;

View File

@ -1,8 +0,0 @@
#[derive(Clone)]
pub struct User {
pub id: i32,
pub issuer: String,
pub source_id: String,
pub displayed_name: String,
pub email: String,
}

View File

@ -1,3 +0,0 @@
pub mod db;
pub mod oidc;
pub mod web;

View File

@ -1,2 +0,0 @@
pub mod user_db_service_impl;
pub mod user_record;

View File

@ -1,86 +0,0 @@
use async_trait::async_trait;
use common::framework::error::DatabaseError;
use sqlx::{Pool, Postgres};
use crate::{
adapter::gateway::{user_db_mapper::UserMapper, user_db_service::UserDbService},
application::error::auth_error::AuthError,
framework::db::user_record::UserRecord,
};
pub struct UserDbServiceImpl {
db_pool: Pool<Postgres>,
}
impl UserDbServiceImpl {
pub fn new(db_pool: Pool<Postgres>) -> Self {
Self { db_pool }
}
}
#[async_trait]
impl UserDbService for UserDbServiceImpl {
async fn get_user_by_id(&self, user_id: i32) -> Result<UserMapper, AuthError> {
let record = sqlx::query_as!(
UserRecord,
r#"
SELECT id, issuer, source_id, displayed_name, email
FROM "user"
WHERE id = $1
"#,
user_id
)
.fetch_optional(&self.db_pool)
.await
.map_err(|e| AuthError::Unexpected(DatabaseError(e).into()))?;
match record {
Some(record) => Ok(record.into_mapper()),
None => Err(AuthError::UserNotFound),
}
}
async fn get_user_by_source_id(
&self,
issuer: &str,
source_id: &str,
) -> Result<UserMapper, AuthError> {
let record = sqlx::query_as!(
UserRecord,
r#"
SELECT id, issuer, source_id, displayed_name, email
FROM "user"
WHERE issuer = $1 AND source_id = $2
"#,
issuer,
source_id
)
.fetch_optional(&self.db_pool)
.await
.map_err(|e| AuthError::Unexpected(DatabaseError(e).into()))?;
match record {
Some(record) => Ok(record.into_mapper()),
None => Err(AuthError::UserNotFound),
}
}
async fn create_user(&self, user: UserMapper) -> Result<i32, AuthError> {
let id = sqlx::query_scalar!(
r#"
INSERT INTO "user" (issuer, source_id, displayed_name, email)
VALUES ($1, $2, $3, $4)
RETURNING id
"#,
user.issuer,
user.source_id,
user.displayed_name,
user.email
)
.fetch_one(&self.db_pool)
.await
.map_err(|e| AuthError::Unexpected(DatabaseError(e).into()))?;
Ok(id)
}
}

View File

@ -1,24 +0,0 @@
use sqlx::FromRow;
use crate::adapter::gateway::user_db_mapper::UserMapper;
#[derive(FromRow)]
pub struct UserRecord {
pub id: i32,
pub issuer: String,
pub source_id: String,
pub displayed_name: String,
pub email: String,
}
impl UserRecord {
pub fn into_mapper(self) -> UserMapper {
UserMapper {
id: self.id,
issuer: self.issuer,
source_id: self.source_id,
displayed_name: self.displayed_name,
email: self.email,
}
}
}

View File

@ -1 +0,0 @@
pub mod auth_oidc_service_impl;

View File

@ -1,111 +0,0 @@
use async_trait::async_trait;
use openidconnect::{
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
EndpointSet, Nonce, RedirectUrl, TokenResponse as _,
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
};
use crate::{
adapter::gateway::{
auth_oidc_service::AuthOidcService, oidc_claims_response_dto::OidcClaimsResponseDto,
},
application::{error::auth_error::AuthError, use_case::get_auth_url_use_case::AuthUrl},
};
type CompleteClient<
HasAuthUrl = EndpointSet,
HasDeviceAuthUrl = EndpointNotSet,
HasIntrospectionUrl = EndpointNotSet,
HasRevocationUrl = EndpointNotSet,
HasTokenUrl = EndpointMaybeSet,
HasUserInfoUrl = EndpointMaybeSet,
> = CoreClient<
HasAuthUrl,
HasDeviceAuthUrl,
HasIntrospectionUrl,
HasRevocationUrl,
HasTokenUrl,
HasUserInfoUrl,
>;
pub struct AuthOidcServiceImpl {
oidc_client: CompleteClient,
http_client: openidconnect::reqwest::Client,
}
impl AuthOidcServiceImpl {
pub fn new(
provider_metadata: CoreProviderMetadata,
client_id: &str,
client_secret: &str,
redirect_url: RedirectUrl,
http_client: openidconnect::reqwest::Client,
) -> Self {
Self {
oidc_client: CoreClient::from_provider_metadata(
provider_metadata,
ClientId::new(client_id.to_string()),
Some(ClientSecret::new(client_secret.to_string())),
)
.set_redirect_uri(redirect_url),
http_client,
}
}
}
#[async_trait]
impl AuthOidcService for AuthOidcServiceImpl {
fn get_auth_url(&self) -> Result<AuthUrl, AuthError> {
let (url, state, nonce) = self
.oidc_client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.url();
Ok(AuthUrl {
url: url.to_string(),
state: state.secret().into(),
nonce: nonce.secret().into(),
})
}
async fn exchange_auth_code(
&self,
code: &str,
expected_nonce: &str,
) -> Result<OidcClaimsResponseDto, AuthError> {
let token_response = self
.oidc_client
.exchange_code(AuthorizationCode::new(code.to_string()))
.unwrap()
.request_async(&self.http_client)
.await
.map_err(|_| AuthError::InvalidAuthCode)?;
let id_token = token_response.id_token().ok_or(AuthError::InvalidIdToken)?;
let claims = id_token
.claims(
&self.oidc_client.id_token_verifier(),
&Nonce::new(expected_nonce.to_string()),
)
.map_err(|_| AuthError::InvalidIdToken)?;
let issuer = claims.issuer().to_string();
let preferred_username = claims
.preferred_username()
.map(|username| username.to_string());
let email = claims.email().map(|email| email.to_string());
Ok(OidcClaimsResponseDto {
sub: claims.subject().to_string(),
issuer: issuer,
preferred_username: preferred_username,
email: email,
})
}
}

View File

@ -1,9 +0,0 @@
pub mod auth_api_doc;
pub mod auth_middleware;
pub mod auth_web_routes;
pub mod get_logged_in_user_handler;
pub mod oidc_callback_handler;
pub mod oidc_login_handler;
pub mod oidc_logout_handler;
mod constants;

View File

@ -1,17 +0,0 @@
use crate::framework::web::{
get_logged_in_user_handler, oidc_callback_handler, oidc_login_handler, oidc_logout_handler,
};
use utoipa::{OpenApi, openapi};
#[derive(OpenApi)]
#[openapi(paths(
get_logged_in_user_handler::get_logged_in_user_handler,
oidc_callback_handler::oidc_callback_handler,
oidc_login_handler::oidc_login_handler,
oidc_logout_handler::oidc_logout_handler
))]
struct ApiDoc;
pub fn openapi() -> openapi::OpenApi {
ApiDoc::openapi()
}

View File

@ -1,55 +0,0 @@
use std::{
fmt::Display,
future::{self, Ready},
};
use actix_session::SessionExt;
use actix_web::{FromRequest, HttpRequest, dev::Payload};
use crate::framework::web::constants::SESSION_KEY_USER_ID;
pub struct UserId(i32);
impl UserId {
pub fn get(&self) -> i32 {
self.0
}
}
impl FromRequest for UserId {
type Error = UnauthorizedError;
type Future = Ready<Result<Self, UnauthorizedError>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let user_id_result = req.get_session().get::<i32>(SESSION_KEY_USER_ID);
let user_id = match user_id_result {
Ok(id) => id,
_ => return future::ready(Err(UnauthorizedError)),
};
match user_id {
Some(id) => future::ready(Ok(UserId(id))),
None => future::ready(Err(UnauthorizedError)),
}
}
}
#[derive(Debug)]
pub struct UnauthorizedError;
impl Display for UnauthorizedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Unauthorized access")
}
}
impl actix_web::ResponseError for UnauthorizedError {
fn status_code(&self) -> actix_web::http::StatusCode {
actix_web::http::StatusCode::UNAUTHORIZED
}
fn error_response(&self) -> actix_web::HttpResponse {
actix_web::HttpResponse::Unauthorized().finish()
}
}

View File

@ -1,18 +0,0 @@
use actix_web::web;
use crate::framework::web::{
get_logged_in_user_handler::get_logged_in_user_handler,
oidc_callback_handler::oidc_callback_handler, oidc_login_handler::oidc_login_handler,
oidc_logout_handler::oidc_logout_handler,
};
pub fn configure_auth_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/auth")
.route("/login", web::get().to(oidc_login_handler))
.route("/callback", web::get().to(oidc_callback_handler))
.route("/logout", web::get().to(oidc_logout_handler)),
);
cfg.service(web::resource("/me").route(web::get().to(get_logged_in_user_handler)));
}

View File

@ -1,3 +0,0 @@
pub const SESSION_KEY_AUTH_STATE: &str = "auth_state";
pub const SESSION_KEY_AUTH_NONCE: &str = "auth_nonce";
pub const SESSION_KEY_USER_ID: &str = "user_id";

View File

@ -1,39 +0,0 @@
use actix_web::{HttpResponse, Responder, web};
use anyhow::anyhow;
use sentry::integrations::anyhow::capture_anyhow;
use crate::{
adapter::delivery::{auth_controller::AuthController, user_response_dto::UserResponseDto},
application::error::auth_error::AuthError,
framework::web::auth_middleware::UserId,
};
#[utoipa::path(
get,
path = "/me",
tag = "auth",
summary = "Get logged-in user information",
responses(
(status = 200, body = UserResponseDto),
),
security(
("oauth2" = [])
)
)]
pub async fn get_logged_in_user_handler(
auth_controller: web::Data<dyn AuthController>,
user_id: UserId,
) -> impl Responder {
let result = auth_controller.get_user(user_id.get()).await;
match result {
Ok(user) => HttpResponse::Ok().json(user),
Err(e) => {
match e {
AuthError::Unexpected(e) => capture_anyhow(&e),
_ => capture_anyhow(&anyhow!(e)),
};
HttpResponse::InternalServerError().finish()
}
}
}

View File

@ -1,74 +0,0 @@
use actix_session::Session;
use actix_web::{HttpResponse, Responder, http::header, web};
use anyhow::anyhow;
use sentry::integrations::anyhow::capture_anyhow;
use crate::{
adapter::delivery::{
auth_controller::AuthController, oidc_callback_query_dto::OidcCallbackQueryDto,
},
application::error::auth_error::AuthError,
framework::web::constants::{
SESSION_KEY_AUTH_NONCE, SESSION_KEY_AUTH_STATE, SESSION_KEY_USER_ID,
},
};
#[utoipa::path(
get,
path = "/auth/callback",
tag = "auth",
summary = "Handle OIDC callback",
params(
OidcCallbackQueryDto
),
responses(
(status = 302, description = "Redirect to home page"),
(status = 400, description = "Invalid state or nonce"),
)
)]
pub async fn oidc_callback_handler(
auth_controller: web::Data<dyn AuthController>,
query: web::Query<OidcCallbackQueryDto>,
session: Session,
) -> impl Responder {
let expected_state = match session.get::<String>(SESSION_KEY_AUTH_STATE) {
Ok(Some(state)) => state,
_ => return HttpResponse::BadRequest().finish(),
};
let expected_nonce = match session.get::<String>(SESSION_KEY_AUTH_NONCE) {
Ok(Some(nonce)) => nonce,
_ => return HttpResponse::BadRequest().finish(),
};
let result = auth_controller
.oidc_callback(query.into_inner(), &expected_state, &expected_nonce)
.await;
session.remove(SESSION_KEY_AUTH_STATE);
session.remove(SESSION_KEY_AUTH_NONCE);
match result {
Ok(user) => {
if let Err(e) = session.insert::<i32>(SESSION_KEY_USER_ID, user.id) {
capture_anyhow(&e.into());
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Found()
.append_header((header::LOCATION, "/"))
.finish()
}
Err(e) => match e {
AuthError::InvalidAuthCode
| AuthError::InvalidIdToken
| AuthError::InvalidNonce
| AuthError::InvalidState => HttpResponse::BadRequest().finish(),
_ => {
match e {
AuthError::Unexpected(e) => capture_anyhow(&e),
_ => capture_anyhow(&anyhow!(e)),
};
HttpResponse::InternalServerError().finish()
}
},
}
}

View File

@ -1,49 +0,0 @@
use actix_session::Session;
use actix_web::{HttpResponse, Responder, http::header, web};
use anyhow::anyhow;
use sentry::integrations::anyhow::capture_anyhow;
use crate::{
adapter::delivery::auth_controller::AuthController,
application::error::auth_error::AuthError,
framework::web::constants::{SESSION_KEY_AUTH_NONCE, SESSION_KEY_AUTH_STATE},
};
#[utoipa::path(
get,
path = "/auth/login",
tag = "auth",
summary = "Initiate OIDC login",
responses(
(status = 302, description = "Redirect to OIDC provider")
)
)]
pub async fn oidc_login_handler(
auth_controller: web::Data<dyn AuthController>,
session: Session,
) -> impl Responder {
let result = auth_controller.oidc_login();
match result {
Ok(auth_url) => {
if let Err(e) = session.insert::<String>(SESSION_KEY_AUTH_STATE, auth_url.state) {
capture_anyhow(&e.into());
return HttpResponse::InternalServerError().finish();
}
if let Err(e) = session.insert::<String>(SESSION_KEY_AUTH_NONCE, auth_url.nonce) {
capture_anyhow(&e.into());
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Found()
.append_header((header::LOCATION, auth_url.url))
.finish()
}
Err(e) => {
match e {
AuthError::Unexpected(e) => capture_anyhow(&e),
_ => capture_anyhow(&anyhow!(e)),
};
HttpResponse::InternalServerError().finish()
}
}
}

View File

@ -1,18 +0,0 @@
use actix_session::Session;
use actix_web::{HttpResponse, Responder, http::header};
#[utoipa::path(
get,
path = "/auth/logout",
tag = "auth",
summary = "Logout user",
responses(
(status = 302, description = "Redirect to home page")
)
)]
pub async fn oidc_logout_handler(session: Session) -> impl Responder {
session.clear();
HttpResponse::Found()
.append_header((header::LOCATION, "/"))
.finish()
}

View File

@ -1,4 +0,0 @@
pub mod adapter;
pub mod application;
pub mod domain;
pub mod framework;

View File

@ -1,7 +0,0 @@
[package]
name = "common"
version.workspace = true
edition.workspace = true
[dependencies]
sqlx.workspace = true

View File

@ -1 +0,0 @@
pub mod error;

View File

@ -1,21 +0,0 @@
use std::fmt::Display;
#[derive(Debug)]
pub struct IOError(pub std::io::Error);
#[derive(Debug)]
pub struct DatabaseError(pub sqlx::Error);
impl std::error::Error for IOError {}
impl Display for IOError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for DatabaseError {}
impl Display for DatabaseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

View File

@ -1 +0,0 @@
pub mod framework;

View File

@ -1,18 +0,0 @@
[package]
name = "image"
version.workspace = true
edition.workspace = true
[dependencies]
actix-multipart.workspace = true
actix-web.workspace = true
anyhow.workspace = true
async-trait.workspace = true
futures.workspace = true
sentry.workspace = true
serde.workspace = true
sqlx.workspace = true
utoipa.workspace = true
auth.workspace = true
common.workspace = true

View File

@ -1,2 +0,0 @@
pub mod delivery;
pub mod gateway;

View File

@ -1,4 +0,0 @@
pub mod image_controller;
pub mod image_info_response_dto;
pub mod image_request_dto;
pub mod image_response_dto;

View File

@ -1,82 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
adapter::delivery::{
image_info_response_dto::ImageInfoResponseDto, image_request_dto::ImageRequestDto,
image_response_dto::ImageResponseDto,
},
application::{
error::image_error::ImageError,
use_case::{
get_image_use_case::GetImageUseCase, upload_image_use_case::UploadImageUseCase,
},
},
};
#[async_trait]
pub trait ImageController: Send + Sync {
async fn upload_image(
&self,
image: ImageRequestDto,
) -> Result<ImageInfoResponseDto, ImageError>;
async fn get_image_by_id(&self, id: i32) -> Result<ImageResponseDto, ImageError>;
}
pub struct ImageControllerImpl {
upload_image_use_case: Arc<dyn UploadImageUseCase>,
get_image_use_case: Arc<dyn GetImageUseCase>,
mime_type_whitelist: Vec<String>,
}
impl ImageControllerImpl {
pub fn new(
upload_image_use_case: Arc<dyn UploadImageUseCase>,
get_image_use_case: Arc<dyn GetImageUseCase>,
) -> Self {
Self {
upload_image_use_case,
get_image_use_case,
mime_type_whitelist: vec![
"image/jpeg".to_string(),
"image/png".to_string(),
"image/gif".to_string(),
"image/webp".to_string(),
],
}
}
}
#[async_trait]
impl ImageController for ImageControllerImpl {
async fn upload_image(
&self,
image: ImageRequestDto,
) -> Result<ImageInfoResponseDto, ImageError> {
if !self.mime_type_whitelist.contains(&image.mime_type) {
return Err(ImageError::UnsupportedMimeType(image.mime_type));
}
let mime_type = image.mime_type.clone();
let id = self
.upload_image_use_case
.execute(image.into_entity())
.await?;
Ok(ImageInfoResponseDto {
id: id,
mime_type: mime_type,
})
}
async fn get_image_by_id(&self, id: i32) -> Result<ImageResponseDto, ImageError> {
let image = self.get_image_use_case.execute(id).await?;
Ok(ImageResponseDto {
id: id,
mime_type: image.mime_type,
data: image.data,
})
}
}

View File

@ -1,8 +0,0 @@
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct ImageInfoResponseDto {
pub id: i32,
pub mime_type: String,
}

View File

@ -1,16 +0,0 @@
use crate::domain::entity::image::Image;
pub struct ImageRequestDto {
pub mime_type: String,
pub data: Vec<u8>,
}
impl ImageRequestDto {
pub fn into_entity(self) -> Image {
Image {
id: -1,
mime_type: self.mime_type,
data: self.data,
}
}
}

View File

@ -1,5 +0,0 @@
pub struct ImageResponseDto {
pub id: i32,
pub mime_type: String,
pub data: Vec<u8>,
}

View File

@ -1,4 +0,0 @@
pub mod image_db_service;
pub mod image_db_mapper;
pub mod image_repository_impl;
pub mod image_storage;

View File

@ -1,25 +0,0 @@
use crate::domain::entity::image::Image;
pub struct ImageDbMapper {
pub id: i32,
pub mime_type: String,
}
impl ImageDbMapper {
pub fn into_entity(self) -> Image {
Image {
id: self.id,
mime_type: self.mime_type,
data: Vec::new(),
}
}
}
impl From<Image> for ImageDbMapper {
fn from(image: Image) -> Self {
ImageDbMapper {
id: image.id,
mime_type: image.mime_type,
}
}
}

View File

@ -1,11 +0,0 @@
use async_trait::async_trait;
use crate::{
adapter::gateway::image_db_mapper::ImageDbMapper, application::error::image_error::ImageError,
};
#[async_trait]
pub trait ImageDbService: Send + Sync {
async fn create_image_info(&self, image: ImageDbMapper) -> Result<i32, ImageError>;
async fn get_image_info_by_id(&self, id: i32) -> Result<ImageDbMapper, ImageError>;
}

View File

@ -1,52 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
adapter::gateway::{
image_db_mapper::ImageDbMapper, image_db_service::ImageDbService,
image_storage::ImageStorage,
},
application::{error::image_error::ImageError, gateway::image_repository::ImageRepository},
domain::entity::image::Image,
};
pub struct ImageRepositoryImpl {
image_db_service: Arc<dyn ImageDbService>,
image_storage: Arc<dyn ImageStorage>,
}
impl ImageRepositoryImpl {
pub fn new(
image_db_service: Arc<dyn ImageDbService>,
image_storage: Arc<dyn ImageStorage>,
) -> Self {
Self {
image_db_service,
image_storage,
}
}
}
#[async_trait]
impl ImageRepository for ImageRepositoryImpl {
async fn save_image(&self, image: Image) -> Result<i32, ImageError> {
let data = image.data.clone();
let image_id = self
.image_db_service
.create_image_info(ImageDbMapper::from(image))
.await?;
self.image_storage.write_data(image_id, &data)?;
Ok(image_id)
}
async fn get_image_by_id(&self, id: i32) -> Result<Image, ImageError> {
let image_mapper = self.image_db_service.get_image_info_by_id(id).await?;
let data = self.image_storage.read_data(id)?;
Ok(Image {
id: image_mapper.id,
mime_type: image_mapper.mime_type,
data,
})
}
}

View File

@ -1,6 +0,0 @@
use crate::application::error::image_error::ImageError;
pub trait ImageStorage: Send + Sync {
fn write_data(&self, id: i32, data: &[u8]) -> Result<(), ImageError>;
fn read_data(&self, id: i32) -> Result<Vec<u8>, ImageError>;
}

View File

@ -1,3 +0,0 @@
pub mod error;
pub mod gateway;
pub mod use_case;

View File

@ -1 +0,0 @@
pub mod image_error;

View File

@ -1,18 +0,0 @@
use std::fmt::Display;
#[derive(Debug)]
pub enum ImageError {
NotFound,
UnsupportedMimeType(String),
Unexpected(anyhow::Error),
}
impl Display for ImageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ImageError::NotFound => write!(f, "Image not found"),
ImageError::UnsupportedMimeType(mime) => write!(f, "Unsupported MIME type: {}", mime),
ImageError::Unexpected(e) => write!(f, "Unexpected error: {}", e),
}
}
}

View File

@ -1 +0,0 @@
pub mod image_repository;

View File

@ -1,9 +0,0 @@
use async_trait::async_trait;
use crate::{application::error::image_error::ImageError, domain::entity::image::Image};
#[async_trait]
pub trait ImageRepository: Send + Sync {
async fn save_image(&self, image: Image) -> Result<i32, ImageError>;
async fn get_image_by_id(&self, id: i32) -> Result<Image, ImageError>;
}

View File

@ -1,2 +0,0 @@
pub mod get_image_use_case;
pub mod upload_image_use_case;

View File

@ -1,30 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::image_error::ImageError, gateway::image_repository::ImageRepository},
domain::entity::image::Image,
};
#[async_trait]
pub trait GetImageUseCase: Send + Sync {
async fn execute(&self, id: i32) -> Result<Image, ImageError>;
}
pub struct GetImageUseCaseImpl {
image_repository: Arc<dyn ImageRepository>,
}
impl GetImageUseCaseImpl {
pub fn new(image_repository: Arc<dyn ImageRepository>) -> Self {
Self { image_repository }
}
}
#[async_trait]
impl GetImageUseCase for GetImageUseCaseImpl {
async fn execute(&self, id: i32) -> Result<Image, ImageError> {
self.image_repository.get_image_by_id(id).await
}
}

View File

@ -1,28 +0,0 @@
use crate::{
application::{error::image_error::ImageError, gateway::image_repository::ImageRepository},
domain::entity::image::Image,
};
use async_trait::async_trait;
use std::sync::Arc;
#[async_trait]
pub trait UploadImageUseCase: Send + Sync {
async fn execute(&self, image: Image) -> Result<i32, ImageError>;
}
pub struct UploadImageUseCaseImpl {
image_repository: Arc<dyn ImageRepository>,
}
impl UploadImageUseCaseImpl {
pub fn new(image_repository: Arc<dyn ImageRepository>) -> Self {
Self { image_repository }
}
}
#[async_trait]
impl UploadImageUseCase for UploadImageUseCaseImpl {
async fn execute(&self, image: Image) -> Result<i32, ImageError> {
self.image_repository.save_image(image).await
}
}

View File

@ -1 +0,0 @@
pub mod entity;

View File

@ -1 +0,0 @@
pub mod image;

View File

@ -1,5 +0,0 @@
pub struct Image {
pub id: i32,
pub mime_type: String,
pub data: Vec<u8>,
}

View File

@ -1,3 +0,0 @@
pub mod db;
pub mod storage;
pub mod web;

View File

@ -1,2 +0,0 @@
pub mod image_db_service_impl;
pub mod image_record;

View File

@ -1,66 +0,0 @@
use async_trait::async_trait;
use common::framework::error::DatabaseError;
use sqlx::{Pool, Postgres};
use crate::{
adapter::gateway::{image_db_mapper::ImageDbMapper, image_db_service::ImageDbService},
application::error::image_error::ImageError,
framework::db::image_record::ImageRecord,
};
pub struct ImageDbServiceImpl {
db_pool: Pool<Postgres>,
}
impl ImageDbServiceImpl {
pub fn new(db_pool: Pool<Postgres>) -> Self {
Self { db_pool }
}
}
#[async_trait]
impl ImageDbService for ImageDbServiceImpl {
async fn create_image_info(&self, image: ImageDbMapper) -> Result<i32, ImageError> {
let mime_type = image.mime_type.clone();
let id = sqlx::query_scalar!(
r#"
INSERT INTO image (mime_type)
VALUES ($1)
RETURNING id
"#,
mime_type
)
.fetch_one(&self.db_pool)
.await;
match id {
Ok(id) => Ok(id),
Err(e) => Err(ImageError::Unexpected(DatabaseError(e).into())),
}
}
async fn get_image_info_by_id(&self, id: i32) -> Result<ImageDbMapper, ImageError> {
let image_record = sqlx::query_as!(
ImageRecord,
r#"
SELECT id, mime_type
FROM image
WHERE id = $1 AND deleted_time IS NULL
"#,
id
)
.fetch_optional(&self.db_pool)
.await;
match image_record {
Ok(record) => match record {
Some(record) => Ok(ImageDbMapper {
id: record.id,
mime_type: record.mime_type,
}),
None => Err(ImageError::NotFound),
},
Err(e) => Err(ImageError::Unexpected(DatabaseError(e).into())),
}
}
}

View File

@ -1,5 +0,0 @@
#[derive(sqlx::FromRow)]
pub struct ImageRecord {
pub id: i32,
pub mime_type: String,
}

View File

@ -1 +0,0 @@
pub mod image_storage_impl;

View File

@ -1,43 +0,0 @@
use std::{
fs::{self, File},
io::Write,
};
use common::framework::error::IOError;
use crate::{
adapter::gateway::image_storage::ImageStorage, application::error::image_error::ImageError,
};
pub struct ImageStorageImpl {
sotrage_path: String,
}
impl ImageStorageImpl {
pub fn new(storage_path: &str) -> Self {
ImageStorageImpl {
sotrage_path: storage_path.to_string(),
}
}
}
impl ImageStorage for ImageStorageImpl {
fn write_data(&self, id: i32, data: &[u8]) -> Result<(), ImageError> {
let dir_path = format!("{}/images", self.sotrage_path);
fs::create_dir_all(&dir_path).map_err(|e| ImageError::Unexpected(IOError(e).into()))?;
let file_path = format!("{}/{}", dir_path, id);
let mut file =
File::create(&file_path).map_err(|e| ImageError::Unexpected(IOError(e).into()))?;
file.write_all(data)
.map_err(|e| ImageError::Unexpected(e.into()))?;
Ok(())
}
fn read_data(&self, id: i32) -> Result<Vec<u8>, ImageError> {
let file_path = format!("{}/images/{}", self.sotrage_path, id);
let data = fs::read(&file_path).map_err(|e| ImageError::Unexpected(IOError(e).into()))?;
Ok(data)
}
}

View File

@ -1,5 +0,0 @@
pub mod image_api_doc;
pub mod image_web_routes;
mod get_image_by_id_handler;
mod upload_image_handler;

View File

@ -1,49 +0,0 @@
use actix_web::{HttpResponse, Responder, web};
use anyhow::anyhow;
use sentry::integrations::anyhow::capture_anyhow;
use utoipa::ToSchema;
use crate::{
adapter::delivery::image_controller::ImageController,
application::error::image_error::ImageError,
};
#[utoipa::path(
get,
path = "/image/{id}",
tag = "image",
summary = "Get image by ID",
responses (
(status = 200, body = inline(ResponseBodySchema), content_type = "image/*"),
(status = 404, description = "Image not found")
)
)]
pub async fn get_image_by_id_handler(
image_controller: web::Data<dyn ImageController>,
path: web::Path<i32>,
) -> impl Responder {
let id = path.into_inner();
let result = image_controller.get_image_by_id(id).await;
match result {
Ok(image_response) => HttpResponse::Ok()
.content_type(image_response.mime_type)
.body(image_response.data),
Err(e) => match e {
ImageError::NotFound => HttpResponse::NotFound().finish(),
ImageError::Unexpected(e) => {
capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}
_ => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
},
}
}
#[derive(ToSchema)]
#[schema(value_type = String, format = Binary)]
#[allow(dead_code)]
struct ResponseBodySchema(Vec<u8>);

View File

@ -1,13 +0,0 @@
use crate::framework::web::{get_image_by_id_handler, upload_image_handler};
use utoipa::{OpenApi, openapi};
#[derive(OpenApi)]
#[openapi(paths(
get_image_by_id_handler::get_image_by_id_handler,
upload_image_handler::upload_image_handler
))]
struct ApiDoc;
pub fn openapi() -> openapi::OpenApi {
ApiDoc::openapi()
}

View File

@ -1,13 +0,0 @@
use actix_web::web;
use crate::framework::web::{
get_image_by_id_handler::get_image_by_id_handler, upload_image_handler::upload_image_handler,
};
pub fn configure_image_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/image")
.route("/upload", web::post().to(upload_image_handler))
.route("/{id}", web::get().to(get_image_by_id_handler)),
);
}

View File

@ -1,98 +0,0 @@
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Responder, web};
use anyhow::anyhow;
use auth::framework::web::auth_middleware::UserId;
use futures::StreamExt;
use sentry::integrations::anyhow::capture_anyhow;
use utoipa::ToSchema;
use crate::{
adapter::delivery::{
image_controller::ImageController, image_info_response_dto::ImageInfoResponseDto,
image_request_dto::ImageRequestDto,
},
application::error::image_error::ImageError,
};
#[utoipa::path(
post,
path = "/image/upload",
tag = "image",
summary = "Upload an image",
request_body (
content = RequestBodySchema,
content_type = "multipart/form-data",
),
responses (
(status = 201, body = ImageInfoResponseDto),
(status = 400, description = "Unsupported MIME type or file field not found"),
),
security(
("oauth2" = [])
)
)]
pub async fn upload_image_handler(
image_controller: web::Data<dyn ImageController>,
mut payload: Multipart,
_: UserId,
) -> impl Responder {
let mut image_request_dto: Option<ImageRequestDto> = None;
while let Some(item) = payload.next().await {
let mut field = match item {
Ok(field) => field,
Err(_) => return HttpResponse::BadRequest().finish(),
};
if field.name() != Some("file") {
continue;
}
let mime_type = field
.content_type()
.cloned()
.map(|mt| mt.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let mut data = Vec::new();
while let Some(chunk) = field.next().await {
match chunk {
Ok(bytes) => data.extend_from_slice(&bytes),
Err(_) => return HttpResponse::InternalServerError().finish(),
}
}
image_request_dto = Some(ImageRequestDto { mime_type, data });
break;
}
let image_request_dto = match image_request_dto {
Some(dto) => dto,
None => return HttpResponse::BadRequest().finish(),
};
let result = image_controller.upload_image(image_request_dto).await;
match result {
Ok(image_info) => HttpResponse::Created().json(image_info),
Err(e) => match e {
ImageError::UnsupportedMimeType(mime_type) => {
HttpResponse::BadRequest().body(format!("Unsupported MIME type: {}", mime_type))
}
ImageError::Unexpected(e) => {
capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}
_ => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
},
}
}
#[derive(ToSchema)]
#[allow(dead_code)]
struct RequestBodySchema {
#[schema(value_type = String, format = Binary)]
file: Vec<u8>,
}

View File

@ -1,4 +0,0 @@
pub mod adapter;
pub mod application;
pub mod domain;
pub mod framework;

View File

@ -5,13 +5,8 @@ edition.workspace = true
[dependencies]
actix-web.workspace = true
anyhow.workspace = true
async-trait.workspace = true
chrono.workspace = true
sentry.workspace = true
log.workspace = true
serde.workspace = true
sqlx.workspace = true
utoipa.workspace = true
auth.workspace = true
common.workspace = true

View File

@ -1,11 +1,6 @@
pub mod color_request_dto;
pub mod color_response_dto;
pub mod create_label_request_dto;
pub mod create_post_request_dto;
pub mod label_response_dto;
pub mod post_controller;
pub mod post_info_query_dto;
pub mod post_info_response_dto;
pub mod post_response_dto;
pub mod update_label_request_dto;
pub mod update_post_request_dto;

Some files were not shown because too many files have changed in this diff Show More