Amplify Storageを使った、管理者から各ユーザへのファイル共有


AWS Amplify Advent Calendar 2021 15日目、[ゲバラ](https://twitter.com/POL53297840)です。
[LabBase](https://compass.labbase.jp/)を運営している[POL](https://pol.co.jp/)という会社でエンジニアをやっています。

今回はAmplify Storageを使った、管理者から各ユーザへのファイル共有する機能を解説してみます。
以前Amplify Boostup#1でお話ししたので、ざっくりとだけ知れればいいという方はこちらからどうぞ

https://speakerdeck.com/guevara001/amplifydeironnaji-neng-kuo-zhang-wositeitutaohua

#Amplify Storageでできること
Amplify StorageではS3バケットを扱えるライブラリです。具体的には以下のようなことができます。

* ユーザーによるファイルアップロード、ダウンロード
* ファイルの公開範囲は３つ。パブリック、プロテクテッド、プライベート。
* パブリックは全ユーザーに公開することができ編集削除も全ユーザが可能
* プロテクテッドは全ユーザーに公開することができるが、編集削除はアップロードしたユーザーのみ可能
* プライベートはアップロードしたユーザーのみがアクセス編集削除が可能

ここでいうユーザはAmplify Authenticationで作成されたユーザーになります。

#満たしたい要件
Storageは有用な機能なのですが、特定のユーザー間でのみファイルをやり取りしたい場合などは、この機能だと要件を満たすことができません。他ユーザに公開する場合、特定のユーザを指定できず全ユーザに公開になってしまうためです。今回私が満たしたかった要件としては、以下のようにシステム上の管理者⇨各ユーザにファイル共有したかったため、その実現方法を書いてみます。

* ファイル共有を管理者から各ユーザーごとにできるようにしたい
* もちろんセキュアに管理したい
* 共有したファイルは共有対象のアカウントのみアクセスできるようにしたい

#Amplify Storageのライブラリとファイルパス
フロントエンドのライブラリが提供されていますが、アクセスできる範囲が限られています。
プライベートレベルでアップロードする際は以下のコードのようになりますが

```javascript:private_level
const result = await Storage.put("test.txt", "Private Content", {
  level: "private",
  contentType: "text/plain",
});
```
実際は以下のパスの配下に配置されるため、上位のパスにアクセスできないようになっています。

```
private/{user_identity_id}/
```
※user_identity_idはCognitoのフェデレーティッドID
当然このパス自体も割り当てられているユーザーしかアクセスできないため、管理者からユーザーへのファイル共有ができません。
そこで直接S3を操作するためのfunctionを作る必要があります。


#functionを作る
##CLIでアクセス可能なリソースを設定
CLIでfunctionを作る際にfunctionからアクセス可能なリソースを選択することができます。
設定する際は以下のような感じになります。適宜使用に合わせて設定してください。

```terminal
$ amplify add function
? Select which capability you want to add: (Use arrow keys)
❯ Lambda function (serverless function)
  Lambda layer (shared code & resource used across functions)
? Select which capability you want to add: Lambda function (serverless function)
? Provide an AWS Lambda function name: ManageStorageFile
? Choose the runtime that you want to use: NodeJS
? Choose the function template that you want to use: Hello World

Available advanced settings:
- Resource access permissions
- Scheduled recurring invocation
- Lambda layers configuration
- Environment variables configuration
- Secret values configuration

? Do you want to configure advanced settings? Yes
? Do you want to access other resources in this project from your Lambda function? Yes
? Select the categories you want this function to have access to.
 ◯ auth
 ◯ hosting
 ◯ api
 ◯ function
❯◉ storage
? Storage has 3 resources in this project. Select the one you would like your Lambda to access
❯◉ PrivateStorageResource
 ◯ Hoge:@model(appsync)
 ◯ Huga:@model(appsync)
? Select the operations you want to permit on PrivateStorageResource
 ◉ create
 ◉ read
 ◉ update
❯◉ delete
? Do you want to invoke this function on a recurring schedule? No
? Do you want to enable Lambda layers for this function? No
? Do you want to configure environment variables for this function? No
? Do you want to configure secret values this function can access? No
```

##functionはAPIのスキーマ定義する
作ったfunctionはAPIでお手軽に呼び出したいので、スキーマに定義します。必要に応じてアクセスできるユーザーグループを設定しておきます。

```typescript
type Mutation {
  requestManageEventFile(json: String): String
    @auth(rules: [{ allow: groups, groups: ["admin"] }])
    @function(name: "ManageStorageFile-${env}")
}
```
##user_identity_idを取っておく
ファイルパスにはuser_identity_id含まれていますが、このID自体はユーザー側のみがアクセスできる情報のため、フロントエンドで一度取得して、管理者が指定できるようにどこかに保存しておく必要があります。アクセスの仕方はAmplifyライブラリにある`Auth.currentUserCredentials()`を使うと簡単に取得できます。

```typescript
import { Auth } from 'aws-amplify'
const userCredential = await Auth.currentUserCredentials()
const identityId = userCredential.identityId　//←このuser_identity_idをどこかに持っておく
```
:::note warn
user_identity_idはCongnitoのコンソールで確認できますが一度ログインしないと作成されないかつ、ユーザー特定が難しい。
:::

##functionの中身
functionの機能はこちら。S3のSDKを使って実装します。

* 署名付きURLの発行。これでアップロードもダウンロードも
* ファイル一覧
* ファイル削除

functionの中で実装したモジュールです。適宜要件に合わせてください。

```javascript
const AWS = require('aws-sdk')
const S3 = new AWS.S3({ region: "ap-northeast-1", signatureVersion: 'v4',})
const S3_BUCKETNAME = process.env.STORAGE_PRIVATERESOURCE_BUCKETNAME　//StorageのS3バケット名です。

const createObjectKey = (identityId, fileName) => {
    return 'private/' + identityId + '/' + fileName
}

const createPrefix = (identityId) => {
    return 'private/' + identityId
}

module.exports = {
    /**
     * アップロード用署名付きURLの取得
     * @param param リクエストパラメタ
     */
    getUploadPresignedUrl: async (param) => {
        const objectKey = createObjectKey(param.identityId, param.fileName)
        const url = await S3.getSignedUrl('putObject', {
            Bucket: S3_BUCKETNAME,
            Key: objectKey,
            Expires: 600, //秒=10分
            ACL: "private",
        });

        return  JSON.stringify({
            statusCode: 200,
            headers: { "Content-type": "application/json" },
            body: {
                signedUrl: url
            }
          });
    },

    /**
     * ダウンロード用署名付きURLの取得
     * @param param リクエストパラメタ
     */
     getDownloadPresignedUrl: async (param) => {

        const objectKey = createObjectKey(param.identityId, param.fileName)
        const url = await S3.getSignedUrl('getObject', {
            Bucket: S3_BUCKETNAME,
            Key: objectKey,
            Expires: 300, //秒=5分
        });

        return  JSON.stringify({
            statusCode: 200,
            headers: { "Content-type": "application/json" },
            body: {
                signedUrl: url
            }
          });
    },

    /**
     * ファイル一覧取得
     * @param param リクエストパラメタ
     * @returns 
     */
    getFilesList: async (param) => {
        const result = await Promise.all(
          param.identityIdList.map(async (identityId) => {
            const prefix = createPrefix(identityId, param.eventId)
            const res = await S3.listObjectsV2({
              Bucket: S3_BUCKETNAME,
              Prefix: prefix,
            }).promise()
            return extractFileNameList(res.Contents, identityId)
          })
        )
        const fileList = result.reduce((l,v) =>l.concat(v))
        return  JSON.stringify({
            statusCode: 200,
            headers: { "Content-type": "application/json" },
            body: {
                fileList: fileList
            }
          });
    },
    /**
     * ファイル削除
     * @param param リクエストパラメタ
     * @returns 
     */
    deleteFile: async (param) => {
        const objectKey = createObjectKey(param.identityId, param.fileName)
        const res = await S3.deleteObject({
            Bucket: S3_BUCKETNAME,
            Key: objectKey,
        }).promise();

        if(!res) console.error('ファイル削除失敗', res)
        const result = !res ? 'success': 'failure' 
        return  JSON.stringify({
            statusCode: 200,
            body: {
                result: result
            }
          });
    }
} 

/**
 * ファイル名抽出
 * @param {*} originList 
 * @param {*} identityId 
 * @returns 
 */
const extractFileNameList = (originList, identityId) => {
    return originList.filter(origin => origin.Size != 0).map(origin => {
        return {
            identityId: identityId,
            fileName: origin.Key.slice(origin.Key.lastIndexOf('/')+1),
            lastModifiedTime: origin.LastModified
        }
    })
}
```

#終わりと宣伝
拡張性が高く困ったらfunctionでゴリ押しできる感じがAmplify好きですね。Amplify Studioを発表され、まだまだ進化するAmplifyに注目していきたいと思います。
弊社POLでもアドベントカレンダーやっているのでよかったら覗いてみてください！

https://qiita.com/advent-calendar/2021/pol