Compare commits
29 Commits
release/0.
...
main
Author | SHA1 | Date | |
---|---|---|---|
7c32d347b4 | |||
eb2c829659 | |||
f62fb15375 | |||
fcada15211 | |||
a3892f2289 | |||
e6b41a768f | |||
a9df43943e | |||
08c5262df6 | |||
71bae3d8ca | |||
171410e115 | |||
c66bc86771 | |||
bc20385ff2 | |||
a6e1ee3c1c | |||
18f29655bf | |||
b953d0bf0d | |||
a5f66616c4 | |||
71528294ae | |||
e255e076dc | |||
f986810540 | |||
197d7773ef | |||
0d6810f3d5 | |||
c6661f3222 | |||
9c88b4bb55 | |||
dd0567c937 | |||
ab3050db69 | |||
f400bcf486 | |||
95fabee99d | |||
9ad3809f47 | |||
ff2b86358d |
20
.gitea/workflows/pr-agent.yaml
Normal file
20
.gitea/workflows/pr-agent.yaml
Normal file
@ -0,0 +1,20 @@
|
||||
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
1
backend/.gitignore
vendored
@ -1,3 +1,2 @@
|
||||
.env
|
||||
/.sqlx
|
||||
/target
|
||||
|
16
backend/.sqlx/query-0c9effcc24f4319c47898e0ade4e5ccef3c47c014cfcb65805cbf1c625fef1e7.json
generated
Normal file
16
backend/.sqlx/query-0c9effcc24f4319c47898e0ade4e5ccef3c47c014cfcb65805cbf1c625fef1e7.json
generated
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"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"
|
||||
}
|
28
backend/.sqlx/query-1926140fd0232511d302cd514f41af1e619a1c68b94e18cdc53234c9de701390.json
generated
Normal file
28
backend/.sqlx/query-1926140fd0232511d302cd514f41af1e619a1c68b94e18cdc53234c9de701390.json
generated
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
32
backend/.sqlx/query-38181c2e36077c546944fbfe124c623706f920fd6b1a9a1cd143ecee6c9d5019.json
generated
Normal file
32
backend/.sqlx/query-38181c2e36077c546944fbfe124c623706f920fd6b1a9a1cd143ecee6c9d5019.json
generated
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"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"
|
||||
}
|
16
backend/.sqlx/query-5189bdfd0aa6b4ac478cc48efde4cdbd9cc9605fe0f2c4dc4506827fa0fd2ad6.json
generated
Normal file
16
backend/.sqlx/query-5189bdfd0aa6b4ac478cc48efde4cdbd9cc9605fe0f2c4dc4506827fa0fd2ad6.json
generated
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"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"
|
||||
}
|
22
backend/.sqlx/query-715922e4ffa6881f23ea890ebf77abd86937c3f4fe606572156a29d4441028e9.json
generated
Normal file
22
backend/.sqlx/query-715922e4ffa6881f23ea890ebf77abd86937c3f4fe606572156a29d4441028e9.json
generated
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"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"
|
||||
}
|
47
backend/.sqlx/query-9c3ae9a539390e3b0493d325439fdc73cb2925bdb17330a21db4341e5822291b.json
generated
Normal file
47
backend/.sqlx/query-9c3ae9a539390e3b0493d325439fdc73cb2925bdb17330a21db4341e5822291b.json
generated
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"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"
|
||||
}
|
46
backend/.sqlx/query-9d1ffa7a71c8830d75eeeb26800ee7a7d8ede2410b423985caffd86361ad9263.json
generated
Normal file
46
backend/.sqlx/query-9d1ffa7a71c8830d75eeeb26800ee7a7d8ede2410b423985caffd86361ad9263.json
generated
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"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"
|
||||
}
|
34
backend/.sqlx/query-a0e1ed95ce9d705653281455cc59e8ed130a496b09dccbf89e919f4c9798e91a.json
generated
Normal file
34
backend/.sqlx/query-a0e1ed95ce9d705653281455cc59e8ed130a496b09dccbf89e919f4c9798e91a.json
generated
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"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"
|
||||
}
|
14
backend/.sqlx/query-b084aa65fa3cdb1abdd02fd9e2ade04a71dd98eef245780a6f34f0b72564f63e.json
generated
Normal file
14
backend/.sqlx/query-b084aa65fa3cdb1abdd02fd9e2ade04a71dd98eef245780a6f34f0b72564f63e.json
generated
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM post_label\n WHERE post_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b084aa65fa3cdb1abdd02fd9e2ade04a71dd98eef245780a6f34f0b72564f63e"
|
||||
}
|
19
backend/.sqlx/query-d0867ba2857fedcdc9a754d0394c4f040d559118d0b9f8b6f4dcd6e6fde5d381.json
generated
Normal file
19
backend/.sqlx/query-d0867ba2857fedcdc9a754d0394c4f040d559118d0b9f8b6f4dcd6e6fde5d381.json
generated
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"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"
|
||||
}
|
25
backend/.sqlx/query-e9741186ea464ef1ba3598223ad9f042ec876cbdc4e2d9eca787ff1de598551c.json
generated
Normal file
25
backend/.sqlx/query-e9741186ea464ef1ba3598223ad9f042ec876cbdc4e2d9eca787ff1de598551c.json
generated
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"
|
||||
}
|
26
backend/.sqlx/query-f0c2c0fe0a30790e88449da79c859d4e3829b9b2a6a496c9a429a05fbdb2e30a.json
generated
Normal file
26
backend/.sqlx/query-f0c2c0fe0a30790e88449da79c859d4e3829b9b2a6a496c9a429a05fbdb2e30a.json
generated
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"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"
|
||||
}
|
23
backend/.sqlx/query-f4ef2b4e53389d2bf6a6299fc4e4ffd0df1393e1805ae1c37306b25c721de7e3.json
generated
Normal file
23
backend/.sqlx/query-f4ef2b4e53389d2bf6a6299fc4e4ffd0df1393e1805ae1c37306b25c721de7e3.json
generated
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"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"
|
||||
}
|
1771
backend/Cargo.lock
generated
1771
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,20 +1,39 @@
|
||||
[workspace]
|
||||
members = ["feature/post", "server"]
|
||||
members = [
|
||||
"server",
|
||||
"feature/auth",
|
||||
"feature/common",
|
||||
"feature/image",
|
||||
"feature/post",
|
||||
"feature/common",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
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",
|
||||
@ -23,6 +42,15 @@ 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"
|
||||
|
@ -1,22 +1,32 @@
|
||||
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 rust:1-alpine AS base
|
||||
RUN apk add --no-cache build-base openssl-dev openssl-libs-static
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release --target x86_64-unknown-linux-musl
|
||||
RUN cargo build --release
|
||||
|
||||
FROM alpine:latest AS runner
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/server .
|
||||
COPY --from=builder /app/target/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"]
|
||||
|
@ -39,4 +39,3 @@
|
||||
```bash
|
||||
RUST_LOG=debug watchexec -e rs -r 'cargo run'
|
||||
```
|
||||
|
||||
|
17
backend/feature/auth/Cargo.toml
Normal file
17
backend/feature/auth/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[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
|
2
backend/feature/auth/src/adapter.rs
Normal file
2
backend/feature/auth/src/adapter.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod delivery;
|
||||
pub mod gateway;
|
3
backend/feature/auth/src/adapter/delivery.rs
Normal file
3
backend/feature/auth/src/adapter/delivery.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod auth_controller;
|
||||
pub mod oidc_callback_query_dto;
|
||||
pub mod user_response_dto;
|
77
backend/feature/auth/src/adapter/delivery/auth_controller.rs
Normal file
77
backend/feature/auth/src/adapter/delivery/auth_controller.rs
Normal file
@ -0,0 +1,77 @@
|
||||
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))
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct OidcCallbackQueryDto {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
5
backend/feature/auth/src/adapter/gateway.rs
Normal file
5
backend/feature/auth/src/adapter/gateway.rs
Normal file
@ -0,0 +1,5 @@
|
||||
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;
|
@ -0,0 +1,16 @@
|
||||
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>;
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
33
backend/feature/auth/src/adapter/gateway/user_db_mapper.rs
Normal file
33
backend/feature/auth/src/adapter/gateway/user_db_mapper.rs
Normal file
@ -0,0 +1,33 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
18
backend/feature/auth/src/adapter/gateway/user_db_service.rs
Normal file
18
backend/feature/auth/src/adapter/gateway/user_db_service.rs
Normal file
@ -0,0 +1,18 @@
|
||||
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>;
|
||||
}
|
3
backend/feature/auth/src/application.rs
Normal file
3
backend/feature/auth/src/application.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod error;
|
||||
pub mod gateway;
|
||||
pub mod use_case;
|
1
backend/feature/auth/src/application/error.rs
Normal file
1
backend/feature/auth/src/application/error.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod auth_error;
|
24
backend/feature/auth/src/application/error/auth_error.rs
Normal file
24
backend/feature/auth/src/application/error/auth_error.rs
Normal file
@ -0,0 +1,24 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
1
backend/feature/auth/src/application/gateway.rs
Normal file
1
backend/feature/auth/src/application/gateway.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod auth_repository;
|
@ -0,0 +1,21 @@
|
||||
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>;
|
||||
}
|
3
backend/feature/auth/src/application/use_case.rs
Normal file
3
backend/feature/auth/src/application/use_case.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod exchange_auth_code_use_case;
|
||||
pub mod get_auth_url_use_case;
|
||||
pub mod get_user_use_case;
|
@ -0,0 +1,72 @@
|
||||
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)
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
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,
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
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
|
||||
}
|
||||
}
|
1
backend/feature/auth/src/domain.rs
Normal file
1
backend/feature/auth/src/domain.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod entity;
|
1
backend/feature/auth/src/domain/entity.rs
Normal file
1
backend/feature/auth/src/domain/entity.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod user;
|
8
backend/feature/auth/src/domain/entity/user.rs
Normal file
8
backend/feature/auth/src/domain/entity/user.rs
Normal file
@ -0,0 +1,8 @@
|
||||
#[derive(Clone)]
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub issuer: String,
|
||||
pub source_id: String,
|
||||
pub displayed_name: String,
|
||||
pub email: String,
|
||||
}
|
3
backend/feature/auth/src/framework.rs
Normal file
3
backend/feature/auth/src/framework.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod db;
|
||||
pub mod oidc;
|
||||
pub mod web;
|
2
backend/feature/auth/src/framework/db.rs
Normal file
2
backend/feature/auth/src/framework/db.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod user_db_service_impl;
|
||||
pub mod user_record;
|
@ -0,0 +1,86 @@
|
||||
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)
|
||||
}
|
||||
}
|
24
backend/feature/auth/src/framework/db/user_record.rs
Normal file
24
backend/feature/auth/src/framework/db/user_record.rs
Normal file
@ -0,0 +1,24 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
1
backend/feature/auth/src/framework/oidc.rs
Normal file
1
backend/feature/auth/src/framework/oidc.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod auth_oidc_service_impl;
|
@ -0,0 +1,111 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
9
backend/feature/auth/src/framework/web.rs
Normal file
9
backend/feature/auth/src/framework/web.rs
Normal file
@ -0,0 +1,9 @@
|
||||
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;
|
17
backend/feature/auth/src/framework/web/auth_api_doc.rs
Normal file
17
backend/feature/auth/src/framework/web/auth_api_doc.rs
Normal file
@ -0,0 +1,17 @@
|
||||
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()
|
||||
}
|
55
backend/feature/auth/src/framework/web/auth_middleware.rs
Normal file
55
backend/feature/auth/src/framework/web/auth_middleware.rs
Normal file
@ -0,0 +1,55 @@
|
||||
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()
|
||||
}
|
||||
}
|
18
backend/feature/auth/src/framework/web/auth_web_routes.rs
Normal file
18
backend/feature/auth/src/framework/web/auth_web_routes.rs
Normal file
@ -0,0 +1,18 @@
|
||||
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)));
|
||||
}
|
3
backend/feature/auth/src/framework/web/constants.rs
Normal file
3
backend/feature/auth/src/framework/web/constants.rs
Normal file
@ -0,0 +1,3 @@
|
||||
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";
|
@ -0,0 +1,39 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
49
backend/feature/auth/src/framework/web/oidc_login_handler.rs
Normal file
49
backend/feature/auth/src/framework/web/oidc_login_handler.rs
Normal file
@ -0,0 +1,49 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
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()
|
||||
}
|
4
backend/feature/auth/src/lib.rs
Normal file
4
backend/feature/auth/src/lib.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod adapter;
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod framework;
|
7
backend/feature/common/Cargo.toml
Normal file
7
backend/feature/common/Cargo.toml
Normal file
@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sqlx.workspace = true
|
1
backend/feature/common/src/framework.rs
Normal file
1
backend/feature/common/src/framework.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod error;
|
21
backend/feature/common/src/framework/error.rs
Normal file
21
backend/feature/common/src/framework/error.rs
Normal file
@ -0,0 +1,21 @@
|
||||
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)
|
||||
}
|
||||
}
|
1
backend/feature/common/src/lib.rs
Normal file
1
backend/feature/common/src/lib.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod framework;
|
18
backend/feature/image/Cargo.toml
Normal file
18
backend/feature/image/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[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
|
2
backend/feature/image/src/adapter.rs
Normal file
2
backend/feature/image/src/adapter.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod delivery;
|
||||
pub mod gateway;
|
4
backend/feature/image/src/adapter/delivery.rs
Normal file
4
backend/feature/image/src/adapter/delivery.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod image_controller;
|
||||
pub mod image_info_response_dto;
|
||||
pub mod image_request_dto;
|
||||
pub mod image_response_dto;
|
@ -0,0 +1,82 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ImageInfoResponseDto {
|
||||
pub id: i32,
|
||||
pub mime_type: String,
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
pub struct ImageResponseDto {
|
||||
pub id: i32,
|
||||
pub mime_type: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
4
backend/feature/image/src/adapter/gateway.rs
Normal file
4
backend/feature/image/src/adapter/gateway.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod image_db_service;
|
||||
pub mod image_db_mapper;
|
||||
pub mod image_repository_impl;
|
||||
pub mod image_storage;
|
25
backend/feature/image/src/adapter/gateway/image_db_mapper.rs
Normal file
25
backend/feature/image/src/adapter/gateway/image_db_mapper.rs
Normal file
@ -0,0 +1,25 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
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>;
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
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>;
|
||||
}
|
3
backend/feature/image/src/application.rs
Normal file
3
backend/feature/image/src/application.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod error;
|
||||
pub mod gateway;
|
||||
pub mod use_case;
|
1
backend/feature/image/src/application/error.rs
Normal file
1
backend/feature/image/src/application/error.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod image_error;
|
18
backend/feature/image/src/application/error/image_error.rs
Normal file
18
backend/feature/image/src/application/error/image_error.rs
Normal file
@ -0,0 +1,18 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
1
backend/feature/image/src/application/gateway.rs
Normal file
1
backend/feature/image/src/application/gateway.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod image_repository;
|
@ -0,0 +1,9 @@
|
||||
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>;
|
||||
}
|
2
backend/feature/image/src/application/use_case.rs
Normal file
2
backend/feature/image/src/application/use_case.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod get_image_use_case;
|
||||
pub mod upload_image_use_case;
|
@ -0,0 +1,30 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
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
|
||||
}
|
||||
}
|
1
backend/feature/image/src/domain.rs
Normal file
1
backend/feature/image/src/domain.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod entity;
|
1
backend/feature/image/src/domain/entity.rs
Normal file
1
backend/feature/image/src/domain/entity.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod image;
|
5
backend/feature/image/src/domain/entity/image.rs
Normal file
5
backend/feature/image/src/domain/entity/image.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub struct Image {
|
||||
pub id: i32,
|
||||
pub mime_type: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
3
backend/feature/image/src/framework.rs
Normal file
3
backend/feature/image/src/framework.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod db;
|
||||
pub mod storage;
|
||||
pub mod web;
|
2
backend/feature/image/src/framework/db.rs
Normal file
2
backend/feature/image/src/framework/db.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod image_db_service_impl;
|
||||
pub mod image_record;
|
@ -0,0 +1,66 @@
|
||||
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())),
|
||||
}
|
||||
}
|
||||
}
|
5
backend/feature/image/src/framework/db/image_record.rs
Normal file
5
backend/feature/image/src/framework/db/image_record.rs
Normal file
@ -0,0 +1,5 @@
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct ImageRecord {
|
||||
pub id: i32,
|
||||
pub mime_type: String,
|
||||
}
|
1
backend/feature/image/src/framework/storage.rs
Normal file
1
backend/feature/image/src/framework/storage.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod image_storage_impl;
|
@ -0,0 +1,43 @@
|
||||
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)
|
||||
}
|
||||
}
|
5
backend/feature/image/src/framework/web.rs
Normal file
5
backend/feature/image/src/framework/web.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod image_api_doc;
|
||||
pub mod image_web_routes;
|
||||
|
||||
mod get_image_by_id_handler;
|
||||
mod upload_image_handler;
|
@ -0,0 +1,49 @@
|
||||
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>);
|
13
backend/feature/image/src/framework/web/image_api_doc.rs
Normal file
13
backend/feature/image/src/framework/web/image_api_doc.rs
Normal file
@ -0,0 +1,13 @@
|
||||
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()
|
||||
}
|
13
backend/feature/image/src/framework/web/image_web_routes.rs
Normal file
13
backend/feature/image/src/framework/web/image_web_routes.rs
Normal file
@ -0,0 +1,13 @@
|
||||
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)),
|
||||
);
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
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>,
|
||||
}
|
4
backend/feature/image/src/lib.rs
Normal file
4
backend/feature/image/src/lib.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod adapter;
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod framework;
|
@ -5,8 +5,13 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
actix-web.workspace = true
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
log.workspace = true
|
||||
sentry.workspace = true
|
||||
serde.workspace = true
|
||||
sqlx.workspace = true
|
||||
utoipa.workspace = true
|
||||
|
||||
auth.workspace = true
|
||||
common.workspace = true
|
||||
|
@ -1,6 +1,11 @@
|
||||
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
Loading…
x
Reference in New Issue
Block a user