エロ画像検出APIを用いてエロ画像のアップロードを阻止してみる【後編】

こんにちは！すぎちゃんです！

 
今回は前編に引き続き、Fargateコンテナと連携をした「エロ画像防止機能」をlaravelで作っていこうと思います! :muscle:

 こちらの記事にも「いいね :heart: 」よろしくお願いします！

 :::note info
[エロ画像検出APIを用いてエロ画像のアップロードを阻止してみる【前編】](https://qiita.com/sugichaaaan/items/738867e4bc3a1a0077d9)
:::

## :file_folder: ディレクトリ構造

今回の実装で使用する、クラス、bladeファイルを一挙公開いたします！

srcを起点にtreeを起動してしまうと枠に収まりきらなくなるので、直下のappディレクトリ、resourcesディレクトリでそれぞれコマンドを叩きファイル構造を可視化しました。

`(省略)`と末尾についているディレクトリについては、今回のエロ画像検出機能とは関係がないので配下のディレクトリ、及びファイルの表示は控えさせていただきます:sweat_drops: 
 
### [src/app](https://github.com/sugizakikun/docker-laravel/tree/develop/src/app)(サーバーサイド)

```bash
sugi@sugizaki-mac app % tree
.
├── Auth(省略)
├── Console(省略)
├── Exceptions(省略)
├── Http
│   ├── Controllers
│   │   ├── Profile
│   │   │   ├── ProfileController.php
│   │   │   └── ProfileImageController.php
│   ├── Domains
│   │   └── NsfwApi
│   │       ├── NsfwErrorResponseDomain.php
│   │       └── NsfwOutputResponseDomain.php
│   ├── Kernel.php
│   ├── Middleware(省略)
│   └── Services
│       └── Profile
│           ├── DeleteProfileImage.php
│           └── UpdateProfileImage.php
├── Models(省略)
├── Providers(省略)
├── Util
│   └── HttpClient.php
└── Validators(省略)
```

さっくりと説明すると・・・

**Controllers:**
フロントエンドとサーバーサイドの繋ぎ目の役割を果たす。
bladeから受け取った変数を受け取り、Serviceクラスに渡す。
そしてServiceクラスで処理した結果を受け取り、bladeにまた投げる。
**DBの処理をここで書かないのがエンジニア間での暗黙の了解。**

**Services:**
DB操作（レコードの生成・更新・削除）や外部APIの呼び出しを担当。

**Domains:**
出力用データの整形。
出力用の型を決めておくと何かと運用・保守がスムーズになる。

[`NsfwOutputResponseDomain.php`](https://github.com/sugizakikun/docker-laravel/blob/develop/src/app/Http/Domains/NsfwApi/NsfwErrorResponseDomain.php)は正常時に出力されたレスポンスを格納し、
[`NsfwErrorResponseDomain.php`](https://github.com/sugizakikun/docker-laravel/blob/develop/src/app/Http/Domains/NsfwApi/NsfwErrorResponseDomain.php)はエラー時に出力されるレスポンスを格納する。



### [src/resources](https://github.com/sugizakikun/docker-laravel/tree/develop/src/resources) (画面関連)
```bash
sugi@sugizaki-mac resources % tree
.
├── css(省略)
├── js(省略)
├── lang　(省略)
├── sass　(省略)
└── views
    ├── auth (省略)
    ├── components
    │   ├── alert.blade.php
    │   ├── form_action_modal.blade.php
    │   └── profile_image.blade.php
    ├── layouts
    │   └── app.blade.php
    └── profile.blade.php
```

今回メインで編集するのが[`profile.blade.php`](https://github.com/sugizakikun/docker-laravel/blob/develop/src/resources/views/profile.blade.php)です！

[`alert.blade.php`](https://github.com/sugizakikun/docker-laravel/blob/develop/src/resources/views/components/alert.blade.php)は画像アップロード時orエロ画像検出時のアラート、[`form_action_modal.blade.php`](https://github.com/sugizakikun/docker-laravel/blob/develop/src/resources/views/components/form_action_modal.blade.php)については画像アップロードフォームにそれぞれ対応します！

次はS3へヒアウィーゴー!!:train: 


##  :book: S3の設定

### 1. 初期設定

S3の設定がお済みで無い方は以下の記事を参考にしてください。

:::note warn
[[AWS S3]LaravelアプリケーションでS3に画像を保存する](https://qiita.com/kouki_o9/items/dcc40b30924fd3b30787)
:::

### 2. S3バケットポリシーの更新

1で作成したS3バケットの詳細画面に遷移し、画面上部に位置する「アクセス許可」のタブをクリックします。

![スクリーンショット 2024-09-02 23.19.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/903a35ed-cca8-0532-516f-58cba51e946b.png)

下にスクロールしていくと「バケットポリシー」のブロックが表示されます。ブロック右上にある「編集」ボタンをタップし、以下の内容を貼り付けてください。

```bucket_policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::pictures-local/*",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": [
                        <端末のIPアドレス>,
                        <Fargate taskに紐づいているIPアドレス> // 今回追加するもの
                    ]
                }
            }
        }
    ]
```

`<Fargate taskに紐づいているIPアドレス>`には[前回の記事](https://qiita.com/sugichaaaan/items/738867e4bc3a1a0077d9#%E5%8B%95%E4%BD%9C%E7%A2%BA%E8%AA%8D)で設定したタスクのパブリックIPアドレスを設定しましょう :smiley: 

これらの設定を怠ると、折角S3に画像をアップロードしてもnsfw_apiが画像にアクセスできません :bomb: 


## :computer: サーバーサイドの処理

### 1. 画像の前処理
アップロードされた画像がエロ画像かそうでないかを問わず、以下の流れで一旦S3に保管されます！

1. ユーザーからアップロードされた画像ファイルを、ローカルのストレージ（`public/img`ディレクトリ）に保存
2. 一意のファイル名を生成するために、MD5ハッシュ関数を用いてランダムな文字列を作成し、それを36進数に変換してファイル名に設定する
3. ローカルストレージに保存された画像を取得し、その内容を$fileContentsに格納
4. 取得した画像の内容をS3バケットにアップロード。アップロードしたS3オブジェクトには、2で生成したランダムなファイル名を紐付ける
5. アップロードした画像のS3上のURLを取得し、$s3Pathに格納する


```ProfileImageController.php
public function update(Request $request, UpdateProfileImage $updateProfileImage)
{
    if(!array_key_exists("image", $request->all())){
        return null;
    }

    #　①
    $path = $request->file('image')->store('public/img');

    $nsfwResponse = $updateProfileImage->execute($path);

    # ...省略
}
```

```UpdateProfileImage.php
public function execute(?string $path)
{
    #　②
    $randomStr = base_convert(md5(uniqid()), 16,36);
    $fileName = $randomStr.'.png';

    # ③
    $fileContents = Storage::get($path);

    # ④
    Storage::disk('s3')->put($fileName, $fileContents);
    # ⑤
    $s3Path = Storage::disk('s3')->url($fileName);

   # ...以下略
}
```

上記のステップが終わった後はnsfw_apiにリクエストを送り、
apiレスポンスの結果によりサーバーサイドの処理を分岐させます。

### 2-a. 猫の画像をアップロードした場合:cat2: 

![cat_image_uploaded.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/9ff63437-ccdf-7dae-9a00-79b27faa78a3.gif)

nsfw_apiによってエロ画像と判定されなかった画像は、以下のステップで処理が行われます。

1. usersテーブルに1で保存したオブジェクトのURLとキーを保管する
2. もし更新前の画像がデフォルトのものでなければ、その画像の情報をS3バケットから削除する
3. ローカルのストレージから画像データを削除する 
3. viewに渡すデータを整形する

```UpdateProfileImage.php
public function execute(?string $path)
{
    # ...省略 

    # ①
    $user = Auth::user();
    $oldProfileImageKey = $user->profile_image_key;

    $user->profile_image_url = $s3Path;
    $user->profile_image_key = $fileName;
    $user->save();

    # ②
    if($oldProfileImageKey &&  $oldProfileImageKey !== $fileName) {
        $this->deleteUploadedImage($oldProfileImageKey);
    }

    # ③
    Storage::delete($path);

    # ④
    return new NsfwOutputResponseDomain(
        $nsfwApiResponse['score'],
        $nsfwApiResponse['url']
    );
}
```

### 2-b. エロ画像をアップロードした場合 :love_hotel: 

![nsfw_image_uploaded.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/30473ffe-9860-f8d3-b972-e03652c3ffda.gif)

S3にアップロードされた画像がエロ画像と判定された場合は以下のステップで処理を行います。

1. S3オブジェクトをバケットから削除する
2. ローカルのストレージからも画像を削除する
3. viewに渡すデータを整形する

```UpdateProfileImage.php
public function execute(?string $path)
{
    # ... 省略
    $nsfwApiResponse = $this->sendNsfwApiRequest($s3Path);

    # ... 省略

    if ($nsfwApiResponse['score'] >= 0.8) {
        # ①
        $this->deleteUploadedImage($fileName);
        # ②
        Storage::delete($path);

        # ③
        return new NsfwOutputResponseDomain(
            $nsfwApiResponse['score'],
            $nsfwApiResponse['url']
        );
    }

    # ... 省略
}
```
### 3. それ以降の処理

```ProfileImageController.php
 public function update(Request $request, UpdateProfileImage $updateProfileImage)
    {
        # ... 省略
        # ①
        $nsfwResponse = $updateProfileImage->execute($path);

        # ②
        $nsfwResponseArray =  $nsfwResponse->toArray();

        # NSFW応答の処理
        # ③
        $redirectData = [
            'bgColor' => $nsfwResponseArray['alertBgColor'],
            'result' => ($nsfwResponse instanceof NsfwErrorResponseDomain)
                ? $nsfwResponseArray['code'] . ' error: ' . $nsfwResponseArray['message']
                : $nsfwResponseArray['message']
        ];

        return redirect('/profile')->with($redirectData);
    }
```


このステップでは[`UpdateProfileImage.php`](https://github.com/sugizakikun/docker-laravel/blob/develop/src/app/Http/Services/Profile/UpdateProfileImage.php)のexecuteメソッドの処理結果を[`profile.blade.php`](https://github.com/sugizakikun/docker-laravel/blob/develop/src/resources/views/profile.blade.php)ファイルに表示させるまでの処理を説明します。

1. サービスクラス`UpdateProfileImage.php`で処理した内容を$nsfwResponseに格納する。`NsfwOutputResponseDomain` or `NsfwErrorResponseDomain`のどちらかが代入される
2. 出力用のデータを整形する
3. アラートの背景色、メッセージ(※1)を出力する

:::note info
メッセージのルールは[こちら](https://github.com/sugizakikun/docker-laravel/issues/21#issuecomment-2323414887)で参照できます。
:::

メッセージと背景色をviewファイルに受け渡すと、
画像更新時にアラートバルーンが表示されます！

視覚的にわかりやすくするために登録成功時は<font color="rgb(107, 142, 35)">**緑色**</font>、エロ画像アップロード時orエラー時は<font color="red">**赤色**</font>にするなど工夫を凝らしました:smile: 



[**profile.blade.php**](https://github.com/sugizakikun/docker-laravel/blob/develop/src/resources/views/profile.blade.php)

```html
@if(session('result'))
    <x-alert :message="session('result')" :color="session('bgColor') ?? 'success'"/>
@endif

<!-- 以下略 -->
```
[**alert.blade.php**](https://github.com/sugizakikun/docker-laravel/blob/develop/src/resources/views/components/alert.blade.php)
```html
<div class="alert alert-{{$color}} d-flex align-items-center alert-dismissible fade show mb-3" role="alert">
    <div>{{ $message }}</div>
    <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
```

以上が実装の流れとなります！
ここまでお読みいただきありがとうございます :peach: 

## :cat: まとめ 

本記事では、エロ画像検出APIの利用方法とその重要性について解説しました！

エロ画像のアップロードを防ぐことは、ユーザー体験の向上やコンプライアンスの遵守においてすごい大切です。また、AWS ECS、nsfw_apiなどのAPIを活用することで、効率的に画像のモデレーションを実現できることがわかりました:thumbsup: 

ぜひ本記事を参考に、自分のプロジェクトにも導入してみてください!!!