【AWS】LaravelアプリをEC2デプロイ⑥【S3編】

# 0. はじめに
大阪のLaravel初学者サウナーこと、kazumakishimoto([@kazuma_dev](https://twitter.com/kazuma_dev))です！
Laravelで`S3`へ画像アップロードする方法です。

## 0-1. 前回記事
- 【AWS】LaravelアプリをEC2デプロイ【まとめ編】

https://qiita.com/kazumakishimoto/items/e0e109fca443a4acd2a1

- 【AWS】LaravelアプリをEC2デプロイ①【CloudFormation / EC2 / RDS編】

https://qiita.com/kazumakishimoto/items/0859a5a365b226c1b43a

- 【AWS】LaravelアプリをEC2デプロイ②【Route53編】

https://qiita.com/kazumakishimoto/items/825f8c5f94d0160b2364

- 【AWS】LaravelアプリをEC2デプロイ③【ACM / ELB編】

https://qiita.com/kazumakishimoto/items/bdb07c6d4881384f7063

- 【AWS】LaravelアプリをEC2デプロイ④【CircleCI / CodeDeploy編】

https://qiita.com/kazumakishimoto/items/09b84473c44c5899ee57

- 【AWS】LaravelアプリをEC2デプロイ⑤【SNS / Chatbot編】

https://qiita.com/kazumakishimoto/items/b38c34a1275c4e9cd373

## 0-2. 全体の流れ
> [1.S3](#1-s3)
> [2.Laravel](#2-laravel)
> [補足](#補足)
> [Reference](#reference)
> [次回記事](#次回記事)

## 0-3. 本記事の対象者
- LaravelでS3へ画像アップロード機能を実装したい方

## 0-4. 事前準備
- スマホで`Google Authenticator`をDL

https://support.google.com/accounts/answer/1066447

- AWSアカウント作成済み
- リージョンはアジアパシフィック(東京)ap-northeast-1
- `grfl`や`hoge`はサンプル名なので適宜変更して下さい

https://aws.amazon.com/jp/register-flow/

## 0-5. 要件
- IAMでS3用ユーザー追加(パスワード / MFA)
- S3でバケットとポリシー作成
- Laravelの画像アップロード機能

## 0-6. 本番環境
|ツール|バージョン|
|:--|:--|
|OS|Amazon Linux 2|
|nginx|1.12|
|PHP|7.4.28|
|Laravel|6.20.44|
|MySQL|5.7.37|
|Composer|1.10.26|
|Node.js|13.14.0|

## 0-7. AWS構成図
![aws](https://user-images.githubusercontent.com/68370181/178103075-eec5508a-4d29-409c-84f4-3f687fa9cd5d.png)


# 1. S3
## 1-1. S3用ユーザー追加(IAM)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/0c93ae8b-e648-8e6d-e212-a90f807c861d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/a2401008-5b56-80eb-732d-7e868ae8d633.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/d4f65d4d-a6d9-01d0-fca3-c5c9ffb7074a.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/61c17bad-dfeb-a3f8-7bee-169225d3bace.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/3f22d208-5a9e-0c49-9b72-bb8422ffac67.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/a3d21beb-8d1c-63cb-0960-2067081647fd.png)

## 1-2. パスワード設定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/e70574d7-c134-2ffd-43f8-6a4eb78f64b2.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/fcf6939b-a869-c64d-cca2-20122912e8d4.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/606a3e95-e1c4-cb66-cf48-2ec40a0868d9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/02cb1ffa-da7d-cb80-e30a-bad7cb9f59cb.png)

## 1-3. 二段階認証(MFA)
- スマホで`Google Authenticator`をDL

https://support.google.com/accounts/answer/1066447

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/b5cc3f13-afbc-1140-700d-9e01d2f1b859.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/677b261e-622b-911e-c43d-f9c533a5bf89.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/2968ba31-a716-adfb-f475-93fe97a01164.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/3ad10305-e5fd-617f-0c88-440a49f07821.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/bcdc92b2-03a1-5397-4a02-876737fed6c1.png)

https://qiita.com/takahashi-kazuki/items/ef409aa4b523469f6551

## 1-4. バケット作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/bc62e3f7-3294-3aa0-5dca-205f118d027a.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/3d57df31-b05b-5bd7-786c-fa0e7288b53f.png)

## 1-5. ポリシー追加
- ユーザー名`grfl-s3`→`grfl`に変わってますが、スクショ用のため気にしないでください…
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/81042c36-08ad-e56a-9c77-a6ce6446e1e1.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/cd935f51-3a16-e609-fb29-264d485ba170.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/3d1573d0-0785-cf43-350b-7108aa676414.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/7cd9703f-4a51-cd37-a61e-aedc3999f5ef.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/f8599850-06d5-4f0b-dae5-62b7013843dd.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/f79ae2aa-81e2-f9c2-42a0-b41cb4dd5686.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/be41c2c2-7dd5-120a-e574-7c3c361d4664.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/65250acf-e253-817c-7b86-b33312d041c3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/efe9b576-cfaf-c89e-b3ce-b5cd1e6fedf1.png)

## 1-6. S3パッケージインストール
```terminal:local
$ composer require league/flysystem-aws-s3-v3
```

# 2. Laravel
## 2-1. .env
```.env:.env
AWS_ACCESS_KEY_ID= csvの認証情報に記載されているAccess key ID
AWS_SECRET_ACCESS_KEY= csvの認証情報に記載されているSecret access key
AWS_DEFAULT_REGION=ap-northeast-1 （リージョンをアジアパシフィック東京で作成したため）
AWS_BUCKET= 作成したバケット名
```

## 2-2. config
```php:config/filesystems.php
return [

    'default' => env('FILESYSTEM_DRIVER', 'local'),

    'cloud' => env('FILESYSTEM_CLOUD', 's3'),

    'disks' => [

        'local' => [
            'driver' => 'local',
            'root' => storage_path('app'),
        ],

        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => env('APP_URL').'/storage',
            'visibility' => 'public',
        ],

        's3' => [
            'driver' => 's3',
            'key' => env('AWS_ACCESS_KEY_ID'),
            'secret' => env('AWS_SECRET_ACCESS_KEY'),
            'region' => env('AWS_DEFAULT_REGION'),
            'bucket' => env('AWS_BUCKET'),
            'url' => env('AWS_URL'),
            'endpoint' => env('AWS_ENDPOINT'),
        ],

    ],

];
```

## 2-3. Route
```php:routes/web.php
# ユーザー投稿関係(create, store, edit, update, destroy)
Route::get('/', 'ArticleController@index')->name('articles.index');
Route::resource('/articles', 'ArticleController')->except(['index', 'show'])->middleware('auth');
Route::resource('/articles', 'ArticleController')->only(['show']);

### ログイン状態で使用可能 ###
Route::middleware('auth')->group(function () {
    Route::prefix('users/{name}')->name('users.')->group(function () {
        // プロフィール編集
        Route::get('/edit', 'UserController@edit')->name('edit');
        // プロフィール更新
        Route::patch('/update', 'UserController@update')->name('update');
    });
});
```

## 2-4. Migration
```
$ php artisan make:model User --migration
$ php artisan make:model Article --migration
```

### 2-4-1. create_user_table

```php:database/migrations/XXXXXXXXXXX_create_users_table.php
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('twitter_id')->nullable();
            $table->string('name')->unique();
            $table->string('age')->nullable();
            $table->string('gender')->nullable();
            $table->string('avatar')->nullable();
            $table->text('introduction')->nullable();
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password')->nullable();
            $table->rememberToken();
            $table->timestamps();
        });
    }
```

### 2-4-2. create_articles_table
```php:database/migrations/XXXXXXXXXXX_create_articles_table.php
    public function up()
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->bigInteger('user_id')->unsigned();
            $table->bigInteger('pref_id')->nullable();
            $table->text('title');
            $table->text('body');
            $table->string('image')->nullable();
            $table->timestamps();
            $table->softDeletes();

            $table->foreign('user_id')
            ->references('id')
            ->on('users')
            ->onDelete('cascade');
        });
    }
```
```
$ php artisan migrate
```

## 2-5. Model
### 2-5-1. User.php
```php:app/Models/User.php
    protected $fillable = [
        'twitter_id', 'name', 'age', 'gender', 'avatar', 'introduction', 'email', 'password',
    ];
```

### 2-5-2. Article.php
```php:app/Models/Article.php
    protected $fillable = [
        'pref_id', 'title', 'body', 'image',
    ];
```

## 2-6. Controller
```
$ php artisan make:controller UserController
$ php artisan make:controller ArticleController
```

### 2-6-1. UserController
- `FormRequest`の`validated`メソッド
- `Storage`ファサードの`putFile`メソッド
- `Eloquent`の`fill`メソッド

```php:app/Http/Controllers/UserController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Auth;
use App\Http\Requests\UserRequest;
use App\Models\User;

    // プロフィール編集処理
    public function update(UserRequest $request, string $name)
    {
        $validated = $request->validated();

        $user = User::where('name', $name)->first();

        // 画像アップロード
        if (isset($validated['avatar'])) {
            $image = $request->file('avatar');
            $path = Storage::disk('s3')->putFile('avatar', $image, 'public');
            $validated['avatar'] = Storage::disk('s3')->url($path);
        }

        // UserPolicyのupdateメソッドでアクセス制限
        $this->authorize('update', $user);

        // バリデーションにかけた値だけをDBに保存
        $user->fill($validated)->save();

        $data = [
            "name" => $user->name
        ];

        return redirect()->route('users.show', $data);
    }
```

### 2-6-2. ArticleController
```php:app/Http/Controllers/ArticleController.php
<?php

namespace App\Http\Controllers;

use App\Models\Article;
use App\Models\Tag;
use App\Http\Requests\ArticleRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;

    // 投稿処理
    public function store(ArticleRequest $request, Article $article)
    {
        $article->user_id = $request->user()->id;
        $all_request = $request->all();
        $article->pref_id = $request->pref;

        // 画像アップロード
        if (isset($all_request['image'])) {
            $image = $request->file('image');
            $path = Storage::disk('s3')->putFile('image', $image, 'public');
            $all_request['image'] = Storage::disk('s3')->url($path);
        }

        $article->fill($all_request)->save();

        $request->tags->each(function ($tagName) use ($article) {
            $tag = Tag::firstOrCreate(['name' => $tagName]);
            $article->tags()->attach($tag);
        });

        return redirect()->route('articles.index');
    }

    // 編集処理
    public function update(ArticleRequest $request, Article $article)
    {
        $article->user_id = $request->user()->id;
        $all_request = $request->all();
        $article->pref_id = $request->pref;

        // 画像アップロード
        if (isset($all_request['image'])) {
            $image = $request->file('image');
            $path = Storage::disk('s3')->putFile('image', $image, 'public');
            $all_request['image'] = Storage::disk('s3')->url($path);
        }

        $article->fill($all_request)->save();

        $article->tags()->detach();
        $request->tags->each(function ($tagName) use ($article) {
            $tag = Tag::firstOrCreate(['name' => $tagName]);
            $article->tags()->attach($tag);
        });

        return redirect()->route('articles.index');
    }
```

## 2-7. Request
```
$ php artisan make:request UserRequest
$ php artisan make:request ArticleRequest
```

### 2-7-1. UserRequest
```php:app\Http\Requests\UserRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;

class UserRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        // ゲストユーザーログイン時に、ユーザー名とメールアドレスを変更できないよう対策
        if (Auth::id() == config('user.guest_user.id')) {
        return [
            'age' => ['numeric', 'min:1', 'max:100', 'nullable'],
            'avatar' => ['image', 'nullable'],
            'introduction' => ['max:200', 'nullable'],
            ];
        }

        return [
            'name' => ['required','regex:/^(?!.*\s).+$/u', 'regex:/^(?!.*\/).*$/u', 'max:15', Rule::unique('users')->ignore(Auth::id())],
            'age' => ['numeric', 'min:1', 'max:100', 'nullable'],
            'introduction' => ['max:200', 'nullable'],
            'avatar' => ['image', 'nullable'],
            'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore(Auth::id())],
            // 'password' => ['required', 'string', 'min:8', 'confirmed'],
        ];
    }

    public function attributes()
    {
        return [
            'name' => 'ユーザー名',
            'age' => '年齢',
            'introduction' => '自己紹介',
            'avatar' => 'プロフィール画像',
            'email' => 'メールアドレス',
            // 'password' => 'パスワード',
        ];
    }

    public function messages()
    {
        return [
            'name.regex' => ':attributeに「/」と半角スペースは使用できません。'
        ];
    }
}
```

### 2-7-2. ArticleRequest
```php:app\Http\Requests\ArticleRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ArticleRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'title' => 'required|max:50|not_regex:/<\/*script>/u',
            'body' => 'required|max:500|not_regex:/<\/*script>/u',
            'image' =>'image',
            'tags' => 'json|regex:/^(?!.*\s).+$/u|regex:/^(?!.*\/).*$/u',
        ];
    }

    public function attributes()
    {
        return [
            'title' => 'タイトル',
            'body' => '本文',
            'image' => '画像',
            'tags' => 'タグ',
        ];
    }

    public function messages()
    {
        return [
            'tags.regex' => ':attributeに「/」と半角スペースは使用できません。'
        ];
    }

    public function passedValidation()
    {
        $this->tags = collect(json_decode($this->tags))
            ->slice(0, 5)
            ->map(function ($requestTag) {
                return $requestTag->text;
            });
    }
}
```

## 2-8. View
### 2-8-1. app.blade.php
```php:resources/views/app.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <meta http-equiv="x-ua-compatible" content="ie=edge">
  <title>
    @yield('title')
  </title>

  <link rel="shortcut icon" href="{{ asset('images/favicon.png') }}">

  <!-- Font Awesome -->
  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css">
  <!-- Bootstrap core CSS -->
  <link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
  <!-- Material Design Bootstrap -->
  <link href="https://cdnjs.cloudflare.com/ajax/libs/mdbootstrap/4.8.11/css/mdb.min.css" rel="stylesheet">
</head>

<body>
    <div id="app">
        @yield('content')
    </div>

  <script src="{{ mix('js/app.js') }}"></script>
  <!-- JQuery -->
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
  <!-- Bootstrap tooltips -->
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.4/umd/popper.min.js"></script>
  <!-- Bootstrap core JavaScript -->
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/js/bootstrap.min.js"></script>
  <!-- MDB core JavaScript -->
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mdbootstrap/4.8.11/js/mdb.min.js"></script>
</body>

</html>
```

### 2-8-2. users
```php:resources/views/users/edit.blade.php
<form action="{{ route('users.update', ['name' => $user->name]) }}" method="POST" enctype="multipart/form-data">
    @method('PATCH')
    @csrf
    @if(Auth::id() != config('user.guest_user.id'))
    <label class="form-label d-block" for="avatar">
        <img src="{{ $user->avatar }}" id="img" class="img-fuild rounded-circle" width="80" height="80">
        <input type="file" name="avatar" id="avatar" class="d-none" onchange="previewImage(this);">
    </label>
    @endif
    <button type="submit" class="btn btn-block cyan darken-3 text-white col-lg-6 col-md-7 col-sm-8 col-xs-10 mx-auto mt-5 waves-effect">更新する</button>
</form>

// 略

@include('footer')
@endsection

<script>
    function previewImage(obj) {
        var fileReader = new FileReader();
        fileReader.onload = (function() {
            document.querySelector('#img').src = fileReader.result;
        });
        fileReader.readAsDataURL(obj.files[0]);
    }
</script>
```
```php:resources/views/users/user.blade.php
<a href="{{ route('users.show', ['name' => $user->name]) }}" class="text-dark">
    <img src="{{ $user->avatar }}" class="img-fuild rounded-circle" width="60" height="60">
</a>
```

### 2-8-3. articles
```php:resources/views/articles/form.blade.php
<form method="POST" action="{{ route('articles.update', ['article' => $article]) }}" enctype="multipart/form-data">
    @method('PATCH')
    @csrf
    <input id="image" type="file" name="image" accept="image/*" onchange="previewImage(this);">
    <button type="submit" class="btn blue-gradient btn-block">更新する</button>
</form>
```
```php:resources/views/articles/card.blade.php
<img src="{{ $article->image }}" width="200px" class="mt-3 mb-1">
```

# 補足
## 開発環境（FW/ツールのバージョンなど）
|ツール|バージョン|
|:--|:--|
|Vue.js|2.6.14|
|jQuery|3.4.1|
|PHP|7.4.1|
|Laravel|6.20.43|
|MySQL|5.7.36|
|Nginx|1.18.0|
|Composer|2.0.14|
|npm|6.14.6|
|Git|2.33.1|
|Docker|20.10.11|
|docker-compose|v2.2.1|
|PHPUnit|8.0|
|CircleCI|2.1|
|heroku|7.59.4|
|MacBook Air|M1,2020|
|macOS|Monterey 12.3|
|Homebrew|3.3.8|

## ディレクトリ構造
```
【ルートディレクトリ】
├─ .circleci
│   └─ config.yml
├─ aws / CloudFormation
│   └─ ec2.yml
├─ docker
│   └─ mysql
│   └─ nginx
│   └─ php
│   └─ phpmyadmin
├─ src
│   └─ 【Laravelのパッケージ】
└─ docker-compose.yml
```

# Reference
https://www.techpit.jp/courses/11

https://www.techpit.jp/courses/78

https://qiita.com/yuuichimizuta/items/899251a9cb2dd61dabb0

https://qiita.com/yyy752/items/b9d7674fc349f669e801

https://qiita.com/NULL_000000/items/01ae1ce3c473c1f85c45

https://qiita.com/nobu0717/items/4425c02157bc5e88d7b6

https://qiita.com/nobu0717/items/51dfcecda90d3c5958b8

https://qiita.com/takahashi-kazuki/items/ef409aa4b523469f6551

https://teratail.com/questions/375208

https://qiita.com/Yuhei_K/items/56d1b67601c1b5ec3e18#5aws%E3%81%AE%E7%92%B0%E5%A2%83%E5%A4%89%E6%95%B0%E3%82%92%E8%A8%AD%E5%AE%9A

https://qiita.com/ohmoto_2020/items/8b0f813fcca15553d34f


# 次回記事
- 【AWS】LaravelアプリをEC2デプロイ⑦【API編】

https://qiita.com/kazumakishimoto/items/fd4afc02f9490ddb89e5

- 【AWS】お役立ちリンク集【随時更新】

https://qiita.com/kazumakishimoto/items/02e53cdf7b9887252040