Compare commits

..

29 Commits

Author SHA1 Message Date
7c32d347b4 BLOG-97 Remove obsolete migration script for post and label tables (#131)
All checks were successful
Frontend CI / build (push) Successful in 1m21s
### Description

> [!WARNING]
> Removing `v0.1.0` version of migration from `_sqlx_migrations` table before deploying is require.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #97

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #131
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-12 22:40:12 +08:00
eb2c829659 NO-ISSUE Merged from release/0.3 (#130)
All checks were successful
Frontend CI / build (push) Successful in 1m24s
Reviewed-on: #130
2025-08-12 22:15:25 +08:00
f62fb15375 NO-ISSUE build: update backend version
All checks were successful
Frontend CI / build (push) Successful in 1m26s
Deployment / deployment (release) Successful in 5m19s
Auto Comment On PR / add_improve_comment (pull_request) Successful in 12s
PR Title Check / pr-title-check (pull_request) Successful in 13s
2025-08-12 21:59:28 +08:00
fcada15211 BLOG-128 Fix logic for determining published post access based on user login status (#129)
Some checks failed
Frontend CI / build (push) Has been cancelled
### Description

The relationship between `is_published_only` and `has_logged_in`:

| is_published_only | has_logged_in | result |
| ----------------- | ------------- | ------ |
| T                 | T             | T      |
| T                 | F             | T      |
| F                 | T             | F      |
| F                 | F             | T      |

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #128

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #129
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-12 21:58:25 +08:00
a3892f2289 NO-ISSUE build: update app version
All checks were successful
Frontend CI / build (push) Successful in 1m11s
Deployment / deployment (release) Successful in 13m55s
2025-08-06 23:01:34 +08:00
e6b41a768f BLOG-119 Restricted access to unpublished posts (#124)
All checks were successful
Frontend CI / build (push) Successful in 1m13s
Deployment / deployment (release) Successful in 6m59s
### Description

This PR introduces an authorization layer for the post feature. It ensures that create, update, and read operations for posts are properly controlled based on user authentication status and post visibility (published vs. unpublished).

#### Key Changes:

* **Restricted Access to Unpublished Posts**:
    * Unauthenticated users can no longer access unpublished posts via the `GET /post/{id}` endpoint. Attempting to do so will now result in an `HTTP 401 Unauthorized` error.
    * The `get_all_post_info` endpoint is now aware of the user's authentication status to correctly filter posts.

* **Authentication Required for Modifications**:
    * Creating (`POST /post`) and updating (`PUT /post/{id}`) posts now requires an authenticated user. The `user_id` is passed from the web handler through the controller to the use cases.

* **New Error Type**:
    * A new `PostError::Unauthorized` variant has been added to handle access control failures gracefully.

* **API & Core Logic Updates**:
    * The `PostController`, use cases (`GetFullPostUseCase`, `GetAllPostInfoUseCase`, etc.), and web handlers have been updated to accept and process the `user_id`.
    * The `GetFullPostUseCase` now contains the primary logic to prevent unauthenticated access to draft posts.
    * OpenAPI (Utopia) documentation has been updated to reflect these new authorization rules.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #119

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #124
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-06 22:13:54 +08:00
a9df43943e BLOG-90 Fix backend docker build error (#123)
All checks were successful
Frontend CI / build (push) Successful in 1m11s
### Description

- Using `alpine` for build environment.

<https://g.co/gemini/share/ad84493a13dd>

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #122.

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #123
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-06 21:44:06 +08:00
08c5262df6 BLOG-118 Fix to allow nullable published_time to support unpublished posts (#121)
All checks were successful
Frontend CI / build (push) Successful in 1m19s
### Description

This PR updates the application to handle posts that may not have a publication date (e.g., drafts) by making the `published_time` field optional across the entire post feature stack.

This ensures that draft posts can be processed and rendered without causing errors, and prevents search engine metadata from being generated for content that is not yet published.

#### Key Changes:

* **DTO & Schema (`postInfoResponseDto.ts`):**
    * The Zod schema for `PostInfoResponseSchema` has been updated to mark `published_time` as `.nullable()`.
    * The `PostInfoResponseDto` class now correctly handles a `null` value from the API, mapping it to `Date | null`.

* **Domain Entity (`postInfo.ts`):**
    * The core `PostInfo` entity's `publishedTime` property is now typed as `Date | null` to reflect the business logic that a post may be unpublished.

* **View Model (`postInfoViewModel.ts`):**
    * Updated `publishedTime` to be `Date | null`.
    * Added a new `isPublished` boolean getter for convenient conditional logic in the UI.
    * The `formattedPublishedTime` getter now returns `string | null`.
    * Dehydration and rehydration logic (`dehydrate`/`rehydrate`) has been updated to correctly handle the nullable `publishedTime`.

* **UI Component (`PostContentPage.svelte`):**
    * The component now uses the new `isPublished` flag to conditionally render the `<StructuredData>` component for SEO. This ensures that structured data is only included for posts that have been officially published.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #118

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #121
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-06 21:36:54 +08:00
71bae3d8ca BLOG-90 Intergrate error tracking with Sentry (#120)
All checks were successful
Frontend CI / build (push) Successful in 1m29s
### Description

There are several environment variables should be set:

- Frontend
  - `PUBLIC_SENTRY_DSN`
  - `SENTRY_AUTH_TOKEN`
- Backend
  - `SENTRY_DSN`

If the dsn isn't set, errors won't be sent to Sentry.

### Package Changes

_No response_

### Screenshots

![image.png](/attachments/22e49f8d-ac01-4d09-8ff0-7ce87b787055)

### Reference

Resolves #90

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #120
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-06 20:20:47 +08:00
171410e115 BLOG-48 SEO Improvement (#116)
All checks were successful
Frontend CI / build (push) Successful in 1m14s
### Description

#### Overview
This PR improves the website's SEO by:
1. Moving title and meta description tags from app.html to individual page components
2. Adding dynamic meta descriptions based on page content
3. Implementing structured data for blog posts using JSON-LD
4. Optimizing meta descriptions for better search engine visibility

#### Changes
- **app.html**: Removed static title and meta description tags
- **HomePage.svelte**: Added descriptive title parameter to generateTitle function
- **Terminal.svelte**: Dynamically generates meta description from terminal lines
- **PostContentPage.svelte**: Added meta description and structured data for blog posts
- **PostOverallPage.svelte**: Added descriptive meta description for blog listing page
- **StructuredData.svelte**: Created new component to generate JSON-LD structured data for blog posts

#### Benefits
- Improved SEO through better metadata management
- Enhanced search engine visibility with structured data
- More accurate and dynamic meta descriptions
- Better control over page-specific metadata

> [!NOTE]
> Since sitemap auto generating is a little more complex, it will be solved in #117 in the future.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #48

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #116
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-05 11:25:39 +08:00
c66bc86771 NO-ISSUE Merged from release/0.2 (#114)
All checks were successful
Frontend CI / build (push) Successful in 1m6s
Reviewed-on: #114
2025-08-04 07:35:45 +08:00
bc20385ff2 Merge branch 'release/0.2' into NO-ISSUE_merge_v0.2.2
All checks were successful
Frontend CI / build (push) Successful in 1m8s
Auto Comment On PR / add_improve_comment (pull_request) Successful in 17s
PR Title Check / pr-title-check (pull_request) Successful in 16s
2025-08-04 07:32:40 +08:00
a6e1ee3c1c BLOG-112 Add Google Analytics integration and update environment variables (#113)
All checks were successful
Frontend CI / build (push) Successful in 1m11s
### Description

- New env var: PUBLIC_GA_MEASUREMENT_ID=G-XXX

### Package Changes

_No response_

### Screenshots

![image.png](/attachments/5d830c44-f447-4e06-907a-2a0cbf976ca8)

### Reference

Resolves #112

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #113
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-04 07:29:10 +08:00
18f29655bf BLOG-92 Fix improve google font loading efficiency (#111)
All checks were successful
Frontend CI / build (push) Successful in 1m10s
### Description

Reference: https://web.dev/learn/performance/understanding-the-critical-path?utm_source=lighthouse&utm_medium=lr&hl=zh-tw#render-blocking_resources/

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #92

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #111
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-02 15:26:49 +08:00
b953d0bf0d BLOG-109 Fix published_time schema to include offset (#110)
All checks were successful
Frontend CI / build (push) Successful in 1m8s
### Description

Reference: https://zod.dev/api?id=iso-dates#iso-datetimes

- Add `{ offset: true }` as options.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #109

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #110
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-02 14:55:44 +08:00
a5f66616c4 BLOG-104 Implement CRUD functionality for Posts (#108)
All checks were successful
Frontend CI / build (push) Successful in 1m8s
### Description

This pull request introduces the core functionality for creating and updating posts, completing the backend CRUD operations for the `post` feature. It includes new API endpoints, database schema changes, and corresponding updates across the entire application stack from the database layer to the frontend.

#### Backend API

-   **Added new authenticated endpoints:**
    -   `POST /post`: To create a new post.
    -   `PUT /post/{id}`: To update an existing post.
-   Implemented the full vertical slice for these operations, including:
    -   `CreatePostUseCase` and `UpdatePostUseCase`.
    -   Repository and DB service methods for creating, updating, and associating posts with labels.
    -   Transactional database operations to ensure data integrity when creating/updating posts and their associated labels.

#### Database

-   Added a new migration to include an `"order"` column in the `post_label` table.
-   This column preserves the user-defined order of labels for each post.
-   Queries have been updated to fetch and sort labels based on this new column.

#### API Schema & Documentation

-   Enhanced `utoipa` OpenAPI documentation with more specific formats for data types:
    -   `#[schema(format = Uri)]` for URLs like `preview_image_url`.
    -   `#[schema(format = Email)]` for user emails.
    -   `#[schema(format = DateTime)]` for timestamps.
-   Standardized the `published_time` field to use the RFC3339 string format instead of a numeric timestamp, improving API clarity and interoperability.

#### Frontend

-   Updated the `PostInfoResponseDto` in the frontend to correctly parse the new `DateTime` (ISO string) format for `published_time`.

#### Refactoring

-   Renamed `get_full_post` to a more descriptive `get_post_by_id` across the post feature module for better code clarity.

### Package Changes

```toml
utoipa = { version = "5.4.0", features = [
    "actix_extras",
    "non_strict_integers",
    "url",
] }
```

### Screenshots

_No response_

### Reference

Resolves #104

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #108
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-02 14:35:27 +08:00
71528294ae BLOG-105 Implement CRUD functionality for Labels (#107)
All checks were successful
Frontend CI / build (push) Successful in 1m8s
### Description

This PR introduces full CRUD (Create, Read, Update) functionality for post labels, implemented by following the existing Clean Architecture.

#### Backend

* **New API Endpoints for Label Management:**
    * `POST /label`: Create a new label (**authentication required**).
    * `PUT /label/{id}`: Update a label by its ID (**authentication required**).
    * `GET /label`: Get all labels.

* **Architectural Implementation:**
    * **Delivery Layer**: Added `CreateLabelRequestDto`, `UpdateLabelRequestDto`, and updated `PostController` with methods to handle label-related operations.
    * **Application Layer**: Created corresponding use cases (`CreateLabelUseCase`, `UpdateLabelUseCase`, `GetAllLabelsUseCase`) to handle business logic.
    * **Gateway/Framework Layer**: Implemented `LabelRepository` and `LabelDbService` to manage database interactions, including creating, updating, and querying labels.

* **Route Adjustment:**
    * The route for fetching all post info has been changed from `GET /post/all` to `GET /post` to be more RESTful.

#### Frontend

* **API Call Update:**
    * To match the backend route change, the API path for fetching all posts is updated from `/post/all` to `/post`.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #105

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #107
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-02 10:46:00 +08:00
e255e076dc BLOG-103 Add API documentation with Utoipa (#106)
All checks were successful
Frontend CI / build (push) Successful in 1m8s
### Description

This PR integrates the **`utoipa`** and **`utoipa-redoc`** crates to automatically generate OpenAPI-compliant API documentation for the backend project.

#### Overview

To improve development efficiency and API maintainability, this change introduces `utoipa` to automate the API documentation process. By adding specific attribute macros to the source code, we can generate detailed API specifications directly and serve them through an interactive UI provided by `utoipa-redoc`.

#### Key Changes

* **Dependencies Added**
    * Added `utoipa`, `utoipa-gen`, and `utoipa-redoc` to `Cargo.toml`.
    * `utoipa` is used to define OpenAPI objects.
    * `utoipa-redoc` is used to serve the ReDoc documentation UI.

* **Code Refactoring**
    * **HTTP handler logic** in each feature (`auth`, `image`, `post`) has been extracted from the `..._web_routes.rs` files into their own dedicated files (e.g., `get_post_by_id_handler.rs`). This makes the code structure cleaner and simplifies adding documentation attributes to each handler.
    * Renamed the `PostController` method from `get_full_post` to `get_post_by_id` for a more RESTful-compliant naming convention.

* **API Doc Annotation**
    * Added `#[derive(ToSchema)]` or `#[derive(IntoParams)]` to all DTOs (Data Transfer Objects) so they can be recognized by `utoipa` to generate the corresponding schemas.
    * Added the `#[utoipa::path]` macro to all HTTP handler functions, describing the API's path, HTTP method, tags, summary, expected responses, and security settings.

* **Doc Aggregation & Serving**
    * Added an `..._api_doc.rs` file in each feature module to aggregate all API paths within that module.
    * Added a new `api_doc.rs` file in the `server` crate to merge the OpenAPI documents from all features, set global information (like title, version, and the OAuth2 security scheme), and serve the documentation page on the `/redoc` route using `Redoc::with_url`.

### Package Changes

```toml
utoipa = { version = "5.4.0", features = ["actix_extras"] }
utoipa-redoc = { version = "6.0.0", features = ["actix-web"] }
```

### Screenshots

![image.png](/attachments/f5b4b268-f550-4d9e-9321-49a00f6b8e1a)

### Reference

Resolves #103

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #106
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-02 06:51:37 +08:00
f986810540 BLOG-100 User retrieval functionality in authentication module (#102)
All checks were successful
Frontend CI / build (push) Successful in 1m9s
### Description

- Endpoint: GET `/me`, returns the whole user data.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #100

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #102
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-01 18:42:22 +08:00
197d7773ef BLOG-86 Checking authentication before uploading image (#101)
All checks were successful
Frontend CI / build (push) Successful in 1m8s
### Description

This PR introduces a generic authentication middleware to protect application routes. The primary goal is to prevent unauthenticated users from uploading images.

#### Changes Implemented

* **Authentication Middleware**:
    * Created a new `auth_middleware` that checks the user's session for a valid `user_id`.
    * If a `user_id` exists, it's added to the request extensions, making it available to downstream handlers.

* **`UserId` Extractor**:
    * A `UserId` type that implements `FromRequest` has been added.
    * This allows route handlers to declaratively require authentication by simply adding `user_id: UserId` as a parameter. If the user is not logged in, the extractor automatically returns an `ErrorUnauthorized` response.

* **Route Protection**:
    * The `upload_image_handler` now includes the `UserId` extractor, securing the endpoint.
    * A new `/auth/me` route has been added for easily verifying the logged-in user's ID during development and testing.

* **Minor Refinements**:
    * The `logout_handler` now uses `session.clear()` for more robust session termination.
    * Corrected the default Redis URL from `redis://127.0.1:6379` to `redis://127.0.0.1:6379`.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #86

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #101
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-01 18:26:39 +08:00
0d6810f3d5 NO-ISSUE Remove .sqlx from gitignore (#99)
All checks were successful
Frontend CI / build (push) Successful in 1m9s
### Description

- `.sqlx` should be check into version control system because it is required when rust compiling if there is no available online sql server.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

__NO_ISSUE__

### Checklist

- [x] A milestone is set
- [ ] The related issuse has been linked to this branch

Reviewed-on: #99
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-01 15:17:41 +08:00
c6661f3222 BLOG-95 Seperate SQL migration files (#98)
All checks were successful
Frontend CI / build (push) Successful in 1m10s
### Description

- In beta environment, `v0.3.0` migration has been run, a manual revertion is required; in real environment, there is nothing to do, but to do #97 and remove migration record for `v0.1.1` manually.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #95

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #98
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-01 15:02:29 +08:00
9c88b4bb55 BLOG-94 Create user in DB when first login through OIDC (#96)
All checks were successful
Frontend CI / build (push) Successful in 1m8s
### Description

This PR introduces the functionality to persist user information in the database. When a user logs in via OIDC for the first time, a new user record is created. Subsequent logins will retrieve the existing user data from the database.

This change ensures that users have a persistent identity within our system, identified by their unique combination of OIDC issuer and subject ID.

#### Key Changes

* **User Persistence Logic**:
    * In `ExchangeAuthCodeUseCase`, after successfully exchanging the authorization code, the logic now checks if the user exists in our database using their `issuer` and `source_id`.
    * If the user is not found (`AuthError::UserNotFound`), a new record is created in the `user` table.
    * The `User` entity returned by the use case now contains the internal database `id`.

* **Database Integration in Auth Feature**:
    * Introduced a new `UserDbService` trait and its `sqlx`-based implementation, `UserDbServiceImpl`, to handle database operations for users.
    * The `AuthRepository` is extended to include methods for querying (`get_user_by_source_id`) and saving (`save_user`) users, delegating the calls to the new `UserDbService`.
    * The dependency injection container in `server/src/container.rs` has been updated to provide the `UserDbServiceImpl` to the `AuthRepositoryImpl`.

* **Domain and Data Model Updates**:
    * The `User` domain entity now includes `id` (the database primary key) and `issuer` (from OIDC claims) to uniquely identify a user across different identity providers.
    * The `UserResponseDto` now exposes the internal `id` instead of the `source_id`.

* **Session Management**:
    * The user's session now stores the database `user_id` (`i32`) instead of the entire user object. This is more efficient and secure.
    * Session keys have been centralized into a `constants.rs` file for better maintainability.

#### Database Changes

* A new database migration has been added to create the `user` table.
* The table includes columns for `id`, `issuer`, `source_id`, `displayed_name`, and `email`.
* A **`UNIQUE` index** has been created on `(source_id, issuer)` to guarantee that each user from a specific identity provider is stored only once.

#### Refactoring

* Minor refactoring in the `image` feature to change `id: Option<i32>` to `id: i32` for consistency with the new `User` entity model.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #94

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #96
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-01 13:24:08 +08:00
dd0567c937 BLOG-85 Implement OIDC authentication (#93)
All checks were successful
Frontend CI / build (push) Successful in 1m7s
### Description

- Login with configured OIDC issuer, and then save the logged in information in server session.
- Endpoints:
  - GET `/auth/login`
  - GET `/auth/callback`
  - GET `/auth/logout`

### Package Changes

```toml
actix-session = { version = "0.10.1", features = ["redis-session"] }
hex = "0.4.3"
openidconnect = { version = "4.0.1", features = [
    "reqwest",
    "reqwest-blocking",
] }
```

### Screenshots

<video src="attachments/8b15b576-61db-41b9-8587-b4b885018c93" title="Screencast From 2025-07-30 03-34-26.mp4" controls></video>

### Reference

Resolves #85

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #93
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-07-30 03:46:49 +08:00
ab3050db69 BLOG-78 Backend image upload and download (#84)
All checks were successful
Frontend CI / build (push) Successful in 1m4s
### Description

- Add some endpoints about image:
  - POST `/image/upload`
  - GET `/image/{id}`

> [!NOTE]
> Since there isn't identity authentication, the `/image` endpoints should be restricted to private network in nginx.

> [!NOTE]
> Volume for backend should be configured in `pod.yaml`.

### Package Changes

```toml
actix-multipart = "0.7.2"
```

### Screenshots

_No response_

### Reference

Resolves #78

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #84
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-07-27 13:10:46 +08:00
f400bcf486 BLOG-79 AI review bot (#83)
All checks were successful
Frontend CI / build (push) Successful in 1m5s
### Description

- Use Qudo Merge as self-hosted PR agent
- Add a workflow that auto comment /improve command to start the agent

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #79

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #83
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-07-26 07:01:50 +08:00
95fabee99d BLOG-73 Change website title dynamically (#77)
All checks were successful
Frontend CI / build (push) Successful in 1m7s
### Description

- Use `generateTitle` to combine app name and page title.

### Package Changes

_No response_

### Screenshots

|Home|Post Overall|Post Content|
|-|-|-|
|![Screenshot From 2025-07-26 01-58-04.png](/attachments/70ed7440-bd5a-4e9d-a747-bb785c9f7e16)|![Screenshot From 2025-07-26 01-58-27.png](/attachments/58386f0d-e476-4795-8ac4-f0abc6586721)|![Screenshot From 2025-07-26 01-58-31.png](/attachments/cf7cb5a6-af5e-4dc6-907f-8cb8253f1b13)|

### Reference

Resolves #73

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #77
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-07-26 02:01:24 +08:00
9ad3809f47 BLOG-70 Add tooltip for post preview (#76)
All checks were successful
Frontend CI / build (push) Successful in 1m7s
### Description

- A a `title` attribute on the `<a>` element.
- Make the title as `<h2>` and the description as `<p>`.

### Package Changes

_No response_

### Screenshots

![image.png](/attachments/0d4b815e-d1c5-432c-8174-53273189a9c3)

### Reference

Resolves #

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #76
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-07-26 01:29:18 +08:00
ff2b86358d NO-ISSUE Merged from release/0.2 (#75)
All checks were successful
Frontend CI / build (push) Successful in 1m6s
Reviewed-on: #75
2025-07-26 01:03:43 +08:00
196 changed files with 7396 additions and 310 deletions

View 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
View File

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

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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"]

View File

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

View 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

View File

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

View File

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

View 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))
}
}

View File

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

View File

@ -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,
}
}
}

View 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;

View File

@ -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>;
}

View File

@ -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
}
}

View File

@ -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(),
}
}
}

View 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,
}
}
}

View 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>;
}

View File

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

View File

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

View 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),
}
}
}

View File

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

View File

@ -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>;
}

View 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;

View File

@ -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)
}
}

View File

@ -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,
}

View File

@ -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
}
}

View File

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

View File

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

View 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,
}

View File

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

View File

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

View File

@ -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)
}
}

View 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,
}
}
}

View File

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

View File

@ -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,
})
}
}

View 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;

View 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()
}

View 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()
}
}

View 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)));
}

View 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";

View File

@ -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()
}
}
}

View File

@ -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()
}
},
}
}

View 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()
}
}
}

View File

@ -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()
}

View File

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

View File

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

View File

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

View 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)
}
}

View File

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

View 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

View File

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

View 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;

View File

@ -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,
})
}
}

View File

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

View File

@ -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,
}
}
}

View File

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

View File

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

View 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,
}
}
}

View File

@ -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>;
}

View File

@ -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,
})
}
}

View File

@ -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>;
}

View File

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

View File

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

View 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),
}
}
}

View File

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

View File

@ -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>;
}

View File

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

View File

@ -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
}
}

View File

@ -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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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())),
}
}
}

View File

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

View File

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

View File

@ -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)
}
}

View 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;

View File

@ -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>);

View 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()
}

View 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)),
);
}

View File

@ -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>,
}

View File

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

View File

@ -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

View File

@ -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