Laravel・CloudRunで画像サーバーを設置

少し前に作っていた画像サーバーについて整理しました。

自前のサイトで画像を簡単に記事で使えるようにするために作りました。

フレームワークにLaravelを使い、GCPのCloudRunにデプロイし、画像自体はSFTPで外部のサーバーに保存します。

フロントエンドはnext.jsを利用しています。

フロントエンドからのリクエストに応じてCloudRun上のLaravelが外部ストレージに画像の保存、および、画像の取得・表示を行います。

##### 画像保存の流れ
1. 【クライアント（next.js）】記事入力欄で画像ペースト→サーバーに画像を送信
1. 【画像サーバー（laravel・CloudRun）】外部サストレージに画像を保存
1. 【画像サーバー（laravel・CloudRun）】レスポンスで画像URL返却
1. 【クライアント】 記事入力欄に画像URL挿入

##### 画像表示
1. 【クライアント（next.js）】記事を表示、記事内の画像URLの表示リクエスト
1. 【画像サーバー（laravel・CloudRun）】外部ストレージから画像取得してレスポンスを返す
1. 【クライアント】 記事内で画像を表示

# 画像サーバー
Dockerを利用しています。


```bash
 # php -v
PHP 8.1.5 (cli) (built: Apr 18 2022 23:52:55) (NTS)
```
## Laravelインストール
```bash
composer create-project --prefer-dist laravel/laravel:^9.0

## php artisan
Laravel Framework 9.17.0
```
## flysystem-sftp のインストールと設定
Laravelでsftpを利用するためにインストールします
```bash
composer require league/flysystem-sftp-v3 "^3.0"
```
config/filesystems.php に接続情報を設定します。
```php
return [
    'disks' => [
        // sftp用の設定を追記
        'sftp' => [
            'driver' => 'sftp',
            'host' => env('SFTP_HOST'),
            'username' => env('SFTP_USERNAME'),
            'password' => env('SFTP_PASSWORD'),
            'port' => env('SFTP_PORT', 22),
            'root' => env('SFTP_ROOT', ''),
            'visibility' => 'public',
            'permPublic' => 0755,
        ],
    ],
```

.envにsftpの接続情報を記入します。
※後半のCloudRunへのデプロイ時は、GCPのコンソール画面で環境変数として設定します

```php
SFTP_HOST=sftpのホスト
SFTP_USERNAME=sftpのユーザ名
SFTP_PASSWORD=sftpのパスワード
SFTP_ROOT=sftp接続時のルートディレクトリ
```

## 画像保存処理
画像保存と画像表示の処理のみを行うサーバーなのでroutes/api.phpに処理を書きました。

■メソッド
POST
■URL
/api/image
```php
Route::post('/image', function (Request $request) {

    // base64文字列（data:image/png;base64,iVBORw～～）を受取り
    $base64 = $request->input('base64');

    // カンマで分割
    $base64_ex = explode(',',$base64);

    $extension = explode('/',explode(';',$base64_ex[0])[0])[1];

    // カンマ以降の文字列
    $fileData =  base64_decode($base64_ex[1]);

    // ファイル名にUUIDを発行
    $filename_body = Str::orderedUuid()->toString();

    // base64の画像データを一時ファイルに保存
    $tmpFilePath = sys_get_temp_dir() . '/' . $filename_body.'.'.$extension;

    file_put_contents($tmpFilePath, $fileData);

    // sftpで画像保存サーバーにファイルを保存
    // Storage::disk('sftp')で、config/filesystems.php に定義したsftpサーバーをストレージとして扱います。
    Storage::disk('sftp')->putFileAs( 'public', $tmpFilePath, $filename_body.'.'.$extension);

    // アクセス用URLをレスポンスで返却
    return response()->json([asset('api/image/'.$filename_body.'.'.$extension)]);

});
```

## 保存した画像の表示処理
■メソッド
GET
■URL
/api/image/ファイル名
```php
Route::get('/image/{filename}', function (Request $request, string $filename) {

    $filedata = Storage::disk('sftp')->get('public/'.$filename);

    $extension = explode('.', $filename)[1];

    return response(($filedata))->header('content-Type', 'image/'. $extension);
});

```

# Cloud Run へのデプロイ

Laravel の画像サーバーを GCP の CloudRun にデプロイします。

1. GCPのコンソール画面でCloud Runの「サービス作成」を選択します
![サービス – Cloud Run – django-nuxt – Google Cloud コンソール 2023-02-26 09-19-28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/c67b6d21-c324-4135-ec20-68653fb125f1.png)

1. 「ソースリポジトリから・・・」>「CLOUD BUILDの設定」> 画面右側で利用するリポジトリを選択し「次へ」
![68747470733a2f2f71696974612d696d6167652d73746f7265.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/fc873842-e000-d450-9982-1b63cb432ee4.png)

1. 利用するブランチを選択、Buildで利用するDockerfileを入力し、「保存」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/c31044d5-85f4-afb5-b91a-b8280917ca9f.png)

1. その他必要な情報を入力して、「作成」
Laravelの.envで入力していた情報は環境変数で設定しています。
（認証情報はシークレットに設定する方がいいと思います）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/b7cf5e83-4b65-938b-4495-f96feb724dc0.png)


1. デプロイが開始します、CloudBuildも実行中の状態になります
- Cloud Run
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/9d3e49aa-0422-98ac-786a-98ccb97de342.png)
- Cloud Build
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/2756fdad-5459-a266-51e5-cc4d3cc2655c.png)


1. ビルドが完了後、以下の画面になります
- Cloud Run
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/c116e49c-7177-21e1-7041-5928535a1cb7.png)

- Cloud Build
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/15e58a26-aff8-550c-8632-096be752794f.png)

Cloud Run の画面に表示されているURLにアクセスするとLaravelの画面が表示されます。
Cloud Run上にLaravelが正常にデプロイされたことが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/298b6d12-b157-de4c-fe5a-b87e54ec70b6.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/215d980c-518d-0d72-d688-d5abefd1a14e.png)




# フロントエンドからの利用（next.js）
テキストエリアで画像ペーストすると画像保存リクエストが行われるように処理を組み込みます。

## テキストエリア
```typescript
<Textarea
    placeholder="記事を入力"
    value={body}
    onChange={(e) => setBody(e.target.value)}
    onPaste={(e) => imagePaste(e)}
/>
```
## 画像ペースト時の保存リクエスト
```typescript
   function imagePaste(event: React.ClipboardEvent<HTMLTextAreaElement>) {

        const _event = event


        // event からクリップボードのアイテムを取り出す
        const items = event.clipboardData.items; // ここがミソ

        for (let i = 0; i < items.length; i++) {

            let item = items[i];

            if (item.type.indexOf("image") != -1) {

                const file: any = item.getAsFile();

                let canvas_size = 900;
                const canvas = document.createElement("canvas"),
                  ctx = canvas.getContext("2d"),
                  image = new Image(),
                  size_max = canvas_size;

                canvas.width = canvas.height = 0;

                const reader = new FileReader();

                reader.onload = (event: ProgressEvent<FileReader>) => {

                    image.onload = async (event2: Event) => {

                        let w;
                        let h;
                        if (image.width >= image.height) {
                            w = (image.width >= size_max ? size_max : image.width);
                            h = image.height * (w / image.width);
                        } else {
                            h = (image.height >= size_max ? size_max : image.height);
                            w = image.width * (h / image.height);
                        }
                        canvas.width = w;
                        canvas.height = h;
                        (ctx as any).drawImage(event2.target, 0, 0, image.width, image.height, 0, 0, w, h);

                        const temp = (canvas.toDataURL((file as any).type, 0.5));

                        const response = await axios.post('https://[CloudRunのURL]/api/image',{
                            base64:temp
                        })

                        const pos = (_event as any).target.selectionStart;
                        const len = body.length;

                        // setBody(body.substr(0, pos) + '\n\n![](' + temp + ')\n\n' + body.substr(pos, len))
                        setBody(body.substr(0, pos) + '\n\n![](' + response.data[0] + ')\n\n' + body.substr(pos, len))
                    }
                    image.src = (event as any).target.result;
                }

                reader.readAsDataURL(file);

                break;
            }
        }
    }
```

## 画像アップロードの動作
next.jsサイトの入力欄テキストエリアです。
記事を入力すると左側に即時反映されるようにしています。（マークダウン対応）
![screencast 2023-02-26 09-36-30.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/a82d319d-9dfb-dcf2-8586-802bc4b069fd.gif)

以下の画像をコピーして入力欄にペーストしてみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/f8d0110e-39b9-4c7f-b9e1-ea516cc74dc4.png)

画像サーバーに画像をアップロード後、レスポンスで画像URLを受け取り、
テキストエリアに画像タグが挿入され記事に画像が挿入されます。
![screencast 2023-02-26 09-50-21.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274034/cf261e63-2cc1-b55d-c453-5ca3515a9db3.gif)