【AWS】エロ画像のアップロードを試みる不届き者を密告する機能を実装してみた【前半】

叡智に富んだ読者の皆様、ご機嫌よう。

健康診断と確定申告を無事に済ませ、肩の荷が降りたすぎちゃんです。
お陰で長時間ランニングをしても疲れにくくなりました。


今日はS3の[イベント通知機能](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/EventNotifications.html)のアウトプットも兼ねて、エロ画像のアップロードを試みた人間を密告する機能を実装していきます。

:::note info
**本記事の想定読了時間**
①AWSコンソール上でのみ完結したい人：0.5~1時間
②エロ画像検出APIを立ち上げた上で試したい人：1.5~2時間
:::


## 📕前回までのあらすじ

本題に入る前に今までのあらすじを簡単にご説明します。

https://qiita.com/sugichaaaan/items/738867e4bc3a1a0077d9

昨年は、EugenCepoiさんの作った[nsfw_api](https://github.com/EugenCepoi/nsfw_api)をAWSのECRに移行し、AWS環境で画像の不適切度を0~1の範囲でスコア化するAPIの実装に成功しました。

https://qiita.com/sugichaaaan/items/826bf81a853ef951a541

後半ではLaravelからS3バケットに画像をアップロードできるようにしました。
アップロードされた画像のオブジェクトURLを入力値とし、不適切度をバックエンドでも取得できるよう[APIの組み込み](https://github.com/sugizakikun/docker-laravel/pull/25/files#diff-a51d3a903cb5d5946d68508f5cd4c3b75f801e4856ffd8c9647dcd2682b06b23)を行いました。

バックエンドでは以下のように不適切度による処理の場合分けがなされます。

1. **0.8未満の場合**：特に何もしない :no_good: 
2. **0.8以上の場合**：S3バケットからオブジェクトを**抹消する**:knife: 

この機能によりあからさまなエロ画像による汚染を未然に防ぐことができ、
健全なコミュニティづくりに大きく貢献することが期待できます。

しかし[成果物](https://github.com/sugizakikun/docker-laravel/issues/21#issuecomment-2677396037)をAWSを業務で使っている友人に見せたところ・・・
すぎちゃんは雷を落とされてしまいました・・・ :thunder_cloud_rain: 



## :warning: 問題点

AWSが超できる友人に色々理由を聞いたところ、以下の問題点を指摘されました。

:::note warn
1. 「誰がどの画像をいつアップロードしたか」の証跡が取れていない
1. エロ画像のアップロードを検知した時、管理者側に通知を届けるべき
:::


友人「ただ動くものを作ればいいってもんじゃない。
例えばAWSには[**運用上の優秀性**](https://wa.aws.amazon.com/wellarchitected/2020-07-02T19-33-23/wat.pillar.operationalExcellence.ja.html)ってのがあるでしょ？」

すぎちゃん「参考書では見たことあるけど、
それが一体何を意味しているかがわからないよ… :sweat_drops:」

友人「例えば、誰がどのタイミングでそのAPIを実行したかのログを取る**追跡可能性**、EventBridgeとLambdaを組み合わせて**処理を自動化**することを含むんだ。」

すぎちゃん「なんか超イカしてる！:cat:
確かに今までのやつだとエロ画像がアップロードされそうになっても
アップロードを試みた人間を特定することはできなかった・・・！
だから**特定できるようにすればいい**ってことか！」

友人「そういうこと！これをマスターすれば一人前のAWSエンジニアに近づけるよ！」

そんな経緯でS3のイベント通知を用いて「**エロ画像アップロードを試みる不届きものを密告する**」機能を作ることになりました。

## 前準備

以前は不適切だと判断された画像をS3バケットから抹消していましたが、悪質なユーザーを特定するためにもやはり行動履歴は残しておくべきであることを学びました。

今回は以下の流れでアップロードされた画像を処理します。

![スクリーンショット 2025-03-03 9.21.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/9f1468fd-f961-450e-9a39-2b55469cce40.png)


1. S3バケットに画像をアップロードする
(この時誰がアップロードしたかの証跡を残すため、メタデータも付与する)
2. S3からオブジェクトURLを取得する 
3. 2で取得したオブジェクトURLをエロ画像検出APIの入力値とし不適切度を算出する 
4. 不適切度をアプリケーションに渡す
5. 4で算出された不適切度に基づきアプリケーション側で以下の処理を実行する
5-a. 0.8未満の場合：何もしない
5-b. 0.8以上の場合：`is_erotic`**タグ**をS3オブジェクトに付与する:new:

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/object-tagging.html

さらに不適切と判断されたS3オブジェクトにタグを付与された場合、
AWS側でS3イベント通知を送る機能も実装します。
それでは実際のAWSのコンソール画面で設定していきましょう:red_car: 


## :one: AWS側の設定

SNS経由でS3のイベント通知を受け取りたい場合、
以下の手順で設定を行います。

1. SNSの作成
3. S3イベント通知の設定
4. S3イベント通知とSNSトピックとの紐付け

### 1. SNSの設定
まずはSNSの設定からです。
まずは[トピックの作成](https://us-east-1.console.aws.amazon.com/sns/v3/home?region=us-east-1#/create-topic)に遷移します。

![スクリーンショット 2025-02-24 15.03.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/3136ec58-964e-48c8-8c6f-08253da1c8bf.png)

タイプは**スタンダード**、名前は`inform_erotic_art_uploaded`にそれぞれ設定します。

画面最下部の「トピックの作成」をタップし、成功すればトピックの詳細画面に遷移します。

次にトピックに紐づく**サブスクリプション**の設定を行います。
「サブスクリプション」タブをタップし、赤枠で囲っているボタンをタップすると以下のような[サブスクリプション作成画面](https://us-east-1.console.aws.amazon.com/sns/v3/home?region=us-east-1#/create-subscription)が表示されます。


![sns_subscription_before.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/9a135466-8dde-4485-bd34-617dc1a8c8b7.png)

次にトピックに紐づく**サブスクリプション**の設定を行います。
「サブスクリプション」タブをタップし、テーブルの右上の「サブスクリプションの作成」ボタンをタップすると以下のような[サブスクリプション作成画面](https://us-east-1.console.aws.amazon.com/sns/v3/home?region=us-east-1#/create-subscription)が表示されます。

![setting_subscription.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/78a6e325-2a80-4fc0-b5d2-8ff98d29b014.png)


今回はメールで通知を受け取りたいので、プロトコルのドロップダウンメニューから**Eメール**を選択します。

![sns_protocol.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/25c66595-2630-4b27-be51-bd5dceae8ac1.png)

またエンドポイントは自分が通知を受け取りたい任意のメールアドレスを入力してください。

![sns_email_setting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/fe4b155c-5d6f-4a32-8e83-1288db210752.png)

設定が完了すると、入力したメールアドレスのメールボックスに「Subscription Confirmation」という題名のメールが届きます。

![sns_subscription_mail.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/57612e41-30ab-405a-92c8-70197083429c.png)

メールのリンク「**Confirm subscription**」をタップすると、以下のような画面が表示されます。

![sns_subscription_confirmed.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/2a87f9aa-3f4b-4acc-bc58-74bace5ad2e6.png)

この画面が表示されていれば、SNSからの通知を受け取ることができるようになります。


### 2. S3イベント通知の設定
S3のイベント通知の設定を行います。

任意のS3バケットの詳細画面に遷移し、
画面上部の「プロパティ」タブをタップします。

![s3bucket_detail.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/17aef10a-72c1-4f94-bf99-8fe01f267c4a.png)


プロパティをタップした後、下にスクロールしていくと「**イベント通知**」のブロックが表示されます。

![スクリーンショット 2025-02-24 15.00.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/d44053e7-4c3e-4119-abcf-6697fd351584.png)

赤枠で囲まれている「イベント通知を作成」をタップすると、
以下の**イベント通知作成画面**が表示されます。

![s3_event_notification_setting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/bf1ba543-172f-41fb-b52f-5b0987434ed5.png)

イベント名には`detect_erotic_image_uploaded`を設定し、
イベントタイプの設定では「**追加されたオブジェクトタグ**」を選択してください。

![スクリーンショット 2025-02-24 15.11.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/7402a9d8-4c95-48ca-9da3-f4c2dfdccfc9.png)



### 3. S3イベント通知とSNSトピックとの紐付け
最後にS3イベント通知とSNSトピックの紐付けについて説明します。

任意のバケットの**イベント通知作成画面**の最下部までスクロールしてください。
そうすると**送信先**の設定画面が表示されます。

![スクリーンショット 2025-02-24 15.11.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/24194353-2c4a-4ffa-9e86-6d61a6d42b48.png)

送信先には**SNSトピック**、SNSトピックのドロップダウンメニューでは**1-1. SNSの設定**で作成したSNSトピック(`inform_erotic_art_uploaded`)を選択します。

そして画面最下部の「変更の保存」をタップすると、以下のようなエラーが表示されます。

![スクリーンショット 2025-02-24 15.12.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/4dba3b09-309e-4735-ace3-d18955196d73.png)

```
Unable to validate the following destination configurations
```

https://blog.interstellar.co.jp/2022/05/19/what-to-do-if-you-get-an-error-unable-to-validate-the-following-destination-configurations-when-trying-to-configure-amazon-s3-to-send-event-notifications-to-amazon-sns/

ググってみたところ、↑の記事が良さそうなので書いている通りにSNSのアクセスポリシーを設定しました。

再度SNSトピックの詳細画面に遷移し、画面上部の「編集」ボタンをタップします。

![sns_access_policy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/197b4719-3ad5-4e4a-ab87-60cf91c1fb22.png)

SNSトピックの編集画面をスクロールしていくと「**アクセスポリシー - オプション**」が表示されます。

![sns_access_policy_setting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/f818b0c8-10ed-4a4f-a381-12794af898a0.png)

アクセスポリシーを覗いてみるとどうやら、S3からのアクセスを許可していないがために先ほどのエラーが発生しました。

なのでアクセス許可を付与するべく、アクセスポリシーのStatementに以下のポリシーを追加します。

```json
{
  "Sid": "S3-policy",
  "Effect": "Allow",
  "Principal": {
    "AWS": "*"
  },
  "Action": "SNS:Publish",
  "Resource": "arn:aws:sns:us-east-1:<アカウントID>:inform_erotic_art_uploaded",
  "Condition": {
    "StringEquals": {
      "AWS:SourceArn": "arn:aws:s3:::pictures-local"
    }
  }
}
```

アクセスポリシーの変更を保存した後、イベント通知作成画面の「変更を保存」ボタンをタップするとイベント通知一覧に`detect_erotic_image_uploaded`が追加されていることを確認できます！

![s3_sns_connect_success.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/7b639e31-8414-4ca6-8d06-895365e81e83.png)

## :two:  バックエンドのコードを書く
:::note info
急ぎの方はこのセクションを読み飛ばしてもらっても構いません。あくまでも参考程度です:pray: 
:::

:::note warn
お急ぎで無い方は[こちらの記事](https://qiita.com/sugichaaaan/items/738867e4bc3a1a0077d9)でエロ画像APIを立ち上げておいてください。慣れている方は15分、慣れていない人は1時間ほどかかりますがその分勉強になると思います :smile_cat: 
:::

### 1. ログインユーザーの情報を取得

[ProfileImageController.php](https://github.com/sugizakikun/docker-laravel/blob/feature/attach-metadata-to-image/src/app/Http/Controllers/Profile/ProfileImageController.php)
```ProfileImageController.php
    public function update(Request $request, UpdateProfileImage $updateProfileImage)
    {
        if(!array_key_exists("image", $request->all())){
            return null;
        }

        $user = Auth::user(); # ①ログインユーザーの情報を取得
        $uploadedFile = $request->file('image');

        $nsfwResponse = $updateProfileImage->execute($uploadedFile, $user);

        // 省略
    }
```

`$user = Auth::user();`でログインユーザーの情報を取得します。
それをビジネスロジック(`$updateProfileImage->execute()`)の引数として引き渡します。

### 2. ログインユーザーの情報をS3オブジェクトに付与する

①で取得したログインユーザー情報を`storeImage()`メソッドの引数として引き渡します。

[UpdateProfileImage.php](https://github.com/sugizakikun/docker-laravel/blob/feature/attach-metadata-to-image/src/app/Http/Services/Profile/UpdateProfileImage.php)

```UpdateProfileImage.php
    public function execute(UploadedFile $uploadedFile, User $user)
    {
        $storeImageOutput = $this->storeImage($uploadedFile, $user); #②ログインユーザーの情報をstoreImageに引き渡す
        // 省略
    }
```

ImageUploaderTraitクラスの`storeImage`メソッドはWebサーバー上の画像をS3バケットに保管するためのクラスです。

[ImageUploaderTrait.php](https://github.com/sugizakikun/docker-laravel/blob/feature/attach-metadata-to-image/src/app/Http/Services/Common/ImageUploaderTrait.php)
```ImageUploaderTrait.php
    public function storeImage(UploadedFile $uploadedFile, User $user):array
    {
        $path = $uploadedFile->store('public/img');
        $fileContents = Storage::get($path);

        $randomStr = base_convert(md5(uniqid()), 16,36);
        $ext = $uploadedFile->guessExtension();
        $fileName = "$randomStr.$ext";

        // ②ログインユーザーの情報をS3オブジェクトに付与
        $params = [
            'Metadata' => [
                'user_id' => $user->id, 
                'user_name' => $user->name
            ]
        ];  

        Storage::disk('s3')->put($fileName, $fileContents, $params);
        
        return [
            'url'  => Storage::disk('s3')->url($fileName),
            'key' => $fileName,
            'local_path' => $path,
            'file_contents' => $fileContents
        ];
    }
```

`Storage::disk('s3')->put()`メソッドの第3引数にメタデータの情報をセットします。
メタデータには①で取得したログインユーザーのID, ユーザー名を指定します。

### 3. エロ画像検出APIで画像の不適切度を算出する

ステップ3ではエロ画像検出APIを呼び出し、S3にアップロードされた画像の不適切度を測ります。

[UpdateProfileImage.php](https://github.com/sugizakikun/docker-laravel/blob/feature/attach-metadata-to-image/src/app/Http/Services/Profile/UpdateProfileImage.php)

```UpdateProfileImage.php
    public function execute(UploadedFile $uploadedFile, User $user)
    {
        // 省略
        
        # ③nsfw_apiを呼び出す
        $nsfwApiResponse = $this->nsfwApiClient->singlePrediction($storeImageOutput['url']);
        
        // 省略

    }
```

[NsfwApiClient.php](https://github.com/sugizakikun/docker-laravel/blob/feature/attach-metadata-to-image/src/app/Util/NsfwApiClient.php)
```NsfwApiClient.php
class NsfwApiClient
{
    public function __construct(HttpClient $httpClient)
    {
        $this->httpClient = $httpClient;
        $this->prefix = 'http://<エロ画像検出APIのパブリックIPアドレス>:5000';
    }

    /**
     * @param string $s3Path
     * @return mixed
     */
    public function singlePrediction(string $s3Path)
    {
        $endPoint = $this->prefix
            .'/?url='
            .urlencode($s3Path);

        $jsonString = $this->httpClient->get($endPoint);
        return  json_decode($jsonString, true);
    }
    
    // 省略
}
```

S3オブジェクトのURLを`singlePrediction()`に引き渡し、下記のようにクエリパラメータとして設定します。

[HttpClient.php](https://github.com/sugizakikun/docker-laravel/blob/feature/attach-metadata-to-image/src/app/Util/Common/HttpClient.php)
```HttpClient.php
class HttpClient
{
    /**
     * @param string $url
     * @param array $headers
     * @return false|string
     */
    public static function get(string $url)
    {
        $options = [
            'http' => [
                'method'=> 'GET',
                'header'=> 'Content-type: application/json; charset=UTF-8'
            ]
        ];

        $context = stream_context_create($options);
        return file_get_contents($url, false, $context);
    }

    // 省略
}
```

```
http://<エロ画像検出APIのパブリックIPアドレス>:5000/?url=<S3オブジェクトのURL>
```

`<エロ画像検出APIのパブリックIPアドレス>`の部分にはエロ画像検出APIのパブリックアドレスを指定してください。

### 4. 不適切と判断されたS3オブジェクトにタグ付け

最後に③で算出した不適切度が0.8以上であれば、S3オブジェクトに`is_erotic`タグを付与する処理を追加します。

```UpdateProfileImage.php
    public function execute(UploadedFile $uploadedFile, User $user)
    {
        // 省略

        # ④NSFWスコアが0.8以上の場合はS3オブジェクトにタグを付与する
        if( $nsfwApiResponse['score'] >= 0.8 ){
            # is_eroticタグを不適切画像に付与する
            Storage::disk('s3')->put(
                $storeImageOutput['key'], 
                $storeImageOutput['file_contents'], 
                ['Tagging' => ['is_erotic'=>'1']] 
            );
            
            return new NsfwOutputResponseDomain(
                $nsfwApiResponse['score'],
                $nsfwApiResponse['url']
            );
        }

        // 省略
    }
```

## :three: テスト（AWSコンソールのみで完結）

それでは動作確認のフェーズに移ります！

まず試しに、S3バケットにすけべ絵をアップロードします。
（どんなものをアップロードしたかはご想像にお任せします:peach:)

![スクリーンショット 2025-02-24 15.28.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/6148064f-7e31-4c6c-8c88-540dfac37ab0.png)

また証跡のため、メタデータの設定も行います。
今回はLaravelからの繋ぎ込みを行わないので、コンソールから手動で設定します。
![スクリーンショット 2025-03-02 17.56.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/f7d070a9-c40f-48e0-a8e9-d3de5db104ef.png)

将来的に誰がアップロードしたかを追跡したいので、ユーザーID・ユーザー名を記録しようと思います。

タイプはどちらも「ユーザー定義」を選択肢、
キーと値はそれぞれ以下のように設定してください。

| 属性名 |キー|値|
| :--- | :--- | :--- |
| ユーザーID | `x-amz-meta-user_id` | 114514 |
| ユーザー名 | `x-amz-meta-user_name` | tadokoro |


アップロードが完了したらすけべ絵の詳細画面に遷移し下にスクロールしてください。
**タグを編集**ブロックの右上に位置する「編集」ボタンをタップすると、タグの編集画面に遷移します。

以下のようにタグを編集します。

|キー|値|
| :--- | :--- |
| `is_erotic` | 1|

このタグを設定することで、アップロードされた画像が不適切かどうかを一目で判断できるようになります。また、**特定のタグを持つオブジェクトに対して、一定期間が過ぎた後に自動削除するライフサイクルポリシーを設定することも可能です**。

https://blog.serverworks.co.jp/tech/2016/12/21/s3-object-tag/

![s3_object_put_tag.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/ca8c0a78-4f1d-48c9-b874-0df99f6eaccc.png)

タグの設定が終われば画面右下の「変更の保存」をタップしましょう。
そしてメールボックスを覗いてみると・・・

![masked.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401561/6a55a17c-8069-48f2-9f56-b402775f6fe4.png)



**なんということでしょう!!!
なんともよみづいらいJSON形式の文面が出てきたではありませんか・・・!!**


タグ付与時にSNS経由でメールが送信されたことを確認することはできましたが、
付与対象のS3オブジェクトのメタデータまで詳細には記されていませんでした。

おまけにsourceIPAddressなどのセンシティブな情報が平文で送られてくるので、
セキュリティ上あまりよろしくありません :cry: 

:::note warn
S3イベント通知からSNS経由でメールを受信すると、読みづらい文章で送られてくる。
:::

## まとめ

今回は、S3のイベント通知を活用し、S3オブジェクトのメタデータを取得して、SNSを通じて不届き者の情報を密告する機能を実装してみました。

その結果、S3のイベント通知から直接SNSを経由すると、可読性の低いJSON形式のメッセージが送信されることが分かりました:crying_cat_face: 
また、SNSでは**S3オブジェクトのメタデータを直接参照できない**という課題も判明し、結果こそ思うようにいかなかったものの、大きな学びが得られました :smile_cat: 

次回は、Lambdaを経由することで柔軟な機能を実装する方法について解説します！