GoでS3にPostPolicyで画像をアップロードしてみる
S3へファイルをアップロードする方法はいくつかありますが、PostPolicyによるアップロードってご存知でしたか。
お恥ずかしながら私は知りませんでした。
今回はGoでPostPolicyを試してみたので、その記録を残したいと思います。
そもそもなんでPost Policyを使うのか
アップロードするファイルに制限をかけたいから
以上です。
そこに至るまでのかんたんな経緯
- 元々はサーバから画像をアップロードする仕組みを考えていた
- サーバからS3へファイルアップロードをするとその間で帯域を食ってお金がかかりそうだった
- 十分に短いExpiresを設定したPresignedURLを発行していフロントから画像アップロードをしたらいいかと考えた
- それによりサーバでファイルを受け取ったときにかけていた制限を書ける場所がなくなってしまった
- フロントからアップするけど制限はかけたい
- PresignedURLにそれっぽい機能が見つけられない
- Bucket Policyというのを調べるがアクセスコントロールは出来そうだが、アップロードの制限は見つけられない
- 調べてたらPostPolicyを見つける (参考: カミナシさんのブログ)
こんな感じです。
「見つけられない」と書いてある部分は文字通り僕が見つけられなかったので、「できない」わけじゃないかもしれません。
もしやり方を知ってる方がいたら教えてほしいです。
最終的な実装
具体的な実装を以下に記載していきます。
前述したカミナシさんのブログを参考にしている部分があるので、一部コードが同じ部分があります。ご了承ください。
またコードが長いので、要所要所については後で補足しようとおもいます。
上から読んでも理解しづらい可能性が高いので、補足部分から見ていただいたほうが情報を取りやすいかもしれません。
バックエンド
// core/domain/repository
package repository
type SignedFileRepository interface {
GetSignedFileRequest(context.Context, model.StorageFile) (*model.SignedFileRequest, error)
}
package repositoryimpl
var _ repository.SignedFileRepository = (*SignedFileRepository)(nil)
var service = "s3"
type policy struct {
Expiration string `json:"expiration"`
Conditions []any `json:"conditions"`
}
type encodedPolicy struct {
Base64Policy string
SignedPolicy string
}
type SignedFileRepository struct {
handler *myS3.Handler
}
func NewSignedFileRepository(
handler *myS3.Handler,
) *SignedFileRepository {
return &SignedFileRepository{
handler: handler,
}
}
func (r *SignedFileRepository) GetSignedFileRequest(ctx context.Context, file model.StorageFile) (*model.SignedFileRequest, error) {
credentials, err := r.handler.Config().Credentials.Retrieve(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
now := localtime.Now()
// 順番を担保して処理しないとハッシュ値が毎回変わってテスト不可になるため順番を担保させている
params := []map[string]string{
{"bucket": file.Bucket()},
{"acl": "private"},
{"X-Amz-Algorithm": "AWS4-HMAC-SHA256"},
{"X-Amz-Credential": strings.Join([]string{credentials.AccessKeyID, now.Format("20060102"), r.handler.Config().Region, service, "aws4_request"}, "/")},
{"X-Amz-Date": now.Format(localtime.ISO8601)},
}
policy := policy{
Expiration: now.Add(1 * time.Hour).Format("2006-01-02T15:04:05Z"),
Conditions: mergeCondition(
[]any{
[]string{"eq", "$key", file.Key()},
[]string{"starts-with", "$Content-Type", "image/"},
[]any{"content-length-range", 0, 10 * 1024 * 1024}, // 0-10MB
},
params,
),
}
signingKey := createSigningKey(r.handler.Config().Region, credentials, now)
encodedPolicy, err := sign(policy, signingKey)
if err != nil {
return nil, errors.WithStack(err)
}
awsOptions := r.handler.Client().Options()
endpoint, err := awsOptions.EndpointResolverV2.ResolveEndpoint(ctx, bindEndpointParams(awsOptions, file.Bucket()))
if err != nil {
return nil, errors.WithStack(err)
}
// Policy自体がrequest時のパラメータに必要なため追加
params = append(params, map[string]string{"Policy": encodedPolicy.Base64Policy})
return convert.ToP(model.NewSignedFileRequest(
endpoint.URI.String(),
encodedPolicy.SignedPolicy,
paramsToMap(params),
)), nil
}
func paramsToMap(params []map[string]string) map[string]string {
tmp := make(map[string]string)
for _, param := range params {
for k, v := range param {
tmp[k] = v
}
}
return tmp
}
func mergeCondition(arr []any, params []map[string]string) []any {
for _, v := range params {
arr = append(arr, v)
}
return arr
}
func sign(policy policy, signingKey []byte) (*encodedPolicy, error) {
policyJSON, err := json.Marshal(policy)
if err != nil {
return nil, errors.WithStack(err)
}
base := base64.StdEncoding.EncodeToString(policyJSON)
signature := hash(signingKey, base)
return &encodedPolicy{
Base64Policy: base,
SignedPolicy: fmt.Sprintf("%x", signature),
}, nil
}
func createSigningKey(region string, credentials aws.Credentials, date time.Time) []byte {
// DateKey = HMAC-SHA256("AWS4" + "<SecretAccessKey>", "<yyyymmdd>")
dateKey := hash([]byte("AWS4"+credentials.SecretAccessKey), date.Format("20060102"))
// DateRegionKey = HMAC-SHA256(DateKey, "<aws-region>")
dateRegionKey := hash(dateKey, region)
// DateRegionServiceKey = HMAC-SHA256(DateRegionKey, "<aws-service>")
dateRegionServiceKey := hash(dateRegionKey, service)
// SigningKey = HMAC-SHA256(DateRegionServiceKey, "aws4_request")
signingKey := hash(dateRegionServiceKey, "aws4_request")
return signingKey
}
func hash(secretKey []byte, message string) []byte {
key := []byte(secretKey)
h := hmac.New(sha256.New, key)
h.Write([]byte(message))
hashed := h.Sum(nil)
return hashed
}
// INFO: 以下のコードはaws sdk go v2から必要な部分だけ引用
func bindEndpointParams(options s3.Options, bucket string) s3.EndpointParameters {
params := s3.EndpointParameters{}
params.Region = bindRegion(options.Region)
params.UseFIPS = aws.Bool(options.EndpointOptions.UseFIPSEndpoint == aws.FIPSEndpointStateEnabled)
params.UseDualStack = aws.Bool(options.EndpointOptions.UseDualStackEndpoint == aws.DualStackEndpointStateEnabled)
params.Endpoint = options.BaseEndpoint
params.ForcePathStyle = aws.Bool(options.UsePathStyle)
params.Accelerate = aws.Bool(options.UseAccelerate)
params.DisableMultiRegionAccessPoints = aws.Bool(options.DisableMultiRegionAccessPoints)
params.UseArnRegion = aws.Bool(options.UseARNRegion)
params.DisableS3ExpressSessionAuth = options.DisableS3ExpressSessionAuth
params.Bucket = &bucket
return params
}
func bindRegion(region string) *string {
if region == "" {
return nil
}
return aws.String(mapFIPSRegion(region))
}
func mapFIPSRegion(region string) string {
const fipsInfix = "-fips-"
const fipsPrefix = "fips-"
const fipsSuffix = "-fips"
if strings.Contains(region, fipsInfix) ||
strings.Contains(region, fipsPrefix) ||
strings.Contains(region, fipsSuffix) {
region = strings.ReplaceAll(region, fipsInfix, "-")
region = strings.ReplaceAll(region, fipsPrefix, "")
region = strings.ReplaceAll(region, fipsSuffix, "")
}
return region
}
package model
type SignedFileRequest struct {
url string
signature string
params map[string]string
}
func NewSignedFileRequest(
url string,
signature string,
params map[string]string,
) SignedFileRequest {
return SignedFileRequest{
url: url,
signature: signature,
params: params,
}
}
func (m SignedFileRequest) URL() string { return m.url }
func (m SignedFileRequest) Signature() string { return m.signature }
func (m SignedFileRequest) Params() map[string]string { return m.params }
package s3
var client *s3.Client
var c aws.Config
var once sync.Once
type Handler struct {
client *s3.Client
cfg aws.Config
}
func NewS3Client(ctx context.Context, cfg config.AWS) (*Handler, error) {
once.Do(func() {
var err error
var opts []func(*awsConfig.LoadOptions) error
if cfg.S3Endpoint != "" {
opts = append(opts, awsConfig.WithSharedConfigProfile("localstack"))
}
c, err = awsConfig.LoadDefaultConfig(ctx, opts...)
if err != nil {
panic(err)
}
client = s3.NewFromConfig(c, func(o *s3.Options) {
o.Region = cfg.Region
o.EndpointResolverV2 = &resolverV2{
cfg: cfg,
}
})
})
return &Handler{
client: client,
cfg: c,
}, nil
}
func (h *Handler) Client() *s3.Client {
return h.client
}
func (h *Handler) Config() aws.Config {
return h.cfg
}
type resolverV2 struct {
cfg config.AWS
}
func (r *resolverV2) ResolveEndpoint(
ctx context.Context,
params s3.EndpointParameters,
) (smithyendpoints.Endpoint, error) {
if r.cfg.S3Endpoint != "" {
params.Endpoint = aws.String(r.cfg.S3Endpoint)
params.ForcePathStyle = aws.Bool(true)
}
// delegate back to the default v2 resolver otherwise
return s3.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
}
フロントエンド
export const ImageUploadUrlFetcher = {
getImageUploadUrl: async (): Promise<PageImageUploadUrlResponse> => {
const res = await fetch(APIRoute.PAGE_IMAGE_UPLOAD_URL.URL, {
method: APIRoute.PAGE_IMAGE_UPLOAD_URL.METHOD,
credentials: "include",
cache: "no-cache",
});
return v.parse(PageImageUploadUrlResponseSchema, await res.json());
},
put: async (data: PageImageUploadUrlResponse, file: File): Promise<void> => {
const body = new FormData();
body.append("key", data.key);
body.append("Content-Type", file.type);
body.append("X-Amz-Signature", data.signature);
data.params.forEach((v, k) => {
body.append(k, v);
});
// file以後はfieldが無視されるので末尾
body.append("file", file);
await fetch(data.url, {
method: "POST",
body: body,
mode: "no-cors",
});
},
};
import * as v from "valibot";
import { ObjectToMapSchema } from "../definition/image";
export const PageImageUploadUrlResponseSchema = v.object({
url: v.string(),
key: v.string(),
signature: v.string(),
params: ObjectToMapSchema,
});
export const ObjectToMapSchema = v.pipe(
v.unknown(),
v.transform((v) => {
return new Map<string, string>(Object.entries(v as Object));
}),
);
コードの補足
以下にやろうとしていたことを書きつつ、コードを補足していこうと思います。
PostPolicy生成部分のInterface定義
type SignedFileRepository interface {
GetSignedFileRequest(context.Context, model.StorageFile) (*model.SignedFileRequest, error)
}
type SignedFileRequest struct {
url string
signature string
params map[string]string
}
type StorageFile struct {
bucket string
key string
}
こちらの定義は以下の考えから出てきたものです。
- AWSというサービスをドメイン層に持ち込みたくない
- せめてGCPに差し替え可能な定義にしたい
AWSでのPostPolicyを利用した画像アップロードと同等の機能がGCP(GoogleCloud)にも存在するか調べてみたところ、SignedURLというものを見つけました。
名前的にはPresignedURLに近いのですが、機能としてはPostPolicyに近そうでした。
inputにあたるStorageFile
は、各サービスのどちらの機能もbucket
とkey(filename)
で保存するファイルを構成するため、それを指定しています。
outputにあたるSignedFileRequest
は各サービスで以下が必要になるため、それぞれ定義しています。
- ファイルの配置先の
url
- 署名したデータの
signature
- Signatureの元になった情報群になる
params
paramsの部分だけ少しズルをして具体的なstructではなくmapにして抽象度を上げているのですが、これは仕方ないですね。
ともあれ、これで一応AWSでもGoogleCloudでも対応できる型になったような気がします。
GoogleCloudでは実装試してませんが...
PostPolicy生成の具体的な実装
PostPolicyのパラメータの仕様などは公式のこちらとか、前述のカミナシさんの記事を見たほうがわかりやすいので説明しません。
Policyの作成
では先程のrepositoryの具体的な実装を見ていきます。
まずはPostPolicyで利用するPolicy Document
を生成していきます。
var service = "s3"
func (r *SignedFileRepository) GetSignedFileRequest(ctx context.Context, file model.StorageFile) (*model.SignedFileRequest, error) {
credentials, err := r.handler.Config().Credentials.Retrieve(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
now := localtime.Now()
// 順番を担保して処理しないとハッシュ値が毎回変わってテスト不可になるため順番を担保させている
params := []map[string]string{
{"bucket": file.Bucket()},
{"acl": "private"},
{"X-Amz-Algorithm": "AWS4-HMAC-SHA256"},
{"X-Amz-Credential": strings.Join([]string{credentials.AccessKeyID, now.Format("20060102"), r.handler.Config().Region, service, "aws4_request"}, "/")},
{"X-Amz-Date": now.Format(localtime.ISO8601)},
}
policy := policy{
Expiration: now.Add(1 * time.Hour).Format("2006-01-02T15:04:05Z"),
Conditions: mergeCondition(
[]any{
[]string{"eq", "$key", file.Key()},
[]string{"starts-with", "$Content-Type", "image/"},
[]any{"content-length-range", 0, 10 * 1024 * 1024}, // 0-10MB
},
params,
),
}
....
}
paramsとConditions周りが少し変なことをしています。特にparams
Conditionが[]anyのため、それをマッチする形でmapを渡そうとすると自然に[]mapの形になります。
また本題とズレてコメントにもあるようにテストコードの話になってしまうのですが、Goのmapは順番を保持せずランダムに値を返します。
Conditionを作成するときにmapからランダムにconditionへ値が渡るようにすると、実行ごとに生成される署名の値が変わってしまいます。
それだとテスト時に都合が悪いんですね。
そのため、ちょっと変な感じがするのですがsliceを経由することで順番を担保するようにしています。
他は愚直にデータを集めてPolicyを作成しているだけです。
ほかに一点あるとすると以下の部分ですかね。
credentials, err := r.handler.Config().Credentials.Retrieve(ctx)
repositoryのコンストラクタ引数にconfigを追加して、それを利用してもいいのですがHandlerのaws-sdk-v2のclientを一度生成しているのに再びCredentialを取得する処理を別に用意するのは冗長なので、以下のようにしてconfigを取り出してそこからretrieveするようにしました。
func NewS3Client(ctx context.Context, cfg config.AWS) (*Handler, error) {
once.Do(func() {
...
c, err = awsConfig.LoadDefaultConfig(ctx, opts...)
if err != nil {
panic(err)
}
...
})
return &Handler{
client: client,
cfg: c,
}, nil
}
func (h *Handler) Config() aws.Config {
return h.cfg
}
SigningKeyを作成する
これはカミナシさんの記事にあるのをそのまま拝借しています。
func (r *SignedFileRepository) GetSignedFileRequest(ctx context.Context, file model.StorageFile) (*model.SignedFileRequest, error) {
...
signingKey := createSigningKey(r.handler.Config().Region, credentials, now)
...
}
func createSigningKey(region string, credentials aws.Credentials, date time.Time) []byte {
// DateKey = HMAC-SHA256("AWS4" + "<SecretAccessKey>", "<yyyymmdd>")
dateKey := hash([]byte("AWS4"+credentials.SecretAccessKey), date.Format("20060102"))
// DateRegionKey = HMAC-SHA256(DateKey, "<aws-region>")
dateRegionKey := hash(dateKey, region)
// DateRegionServiceKey = HMAC-SHA256(DateRegionKey, "<aws-service>")
dateRegionServiceKey := hash(dateRegionKey, service)
// SigningKey = HMAC-SHA256(DateRegionServiceKey, "aws4_request")
signingKey := hash(dateRegionServiceKey, "aws4_request")
return signingKey
}
func hash(secretKey []byte, message string) []byte {
key := []byte(secretKey)
h := hmac.New(sha256.New, key)
h.Write([]byte(message))
hashed := h.Sum(nil)
return hashed
}
AWS公式だとこのあたりとかのコードになります。
AWSのGithub上のサンプルコードだとここでしょうか。
(でもこれsdk v1かな...?)
PolicyにSigningKeyを利用してSignする
作成した鍵で署名していきます。
func (r *SignedFileRepository) GetSignedFileRequest(ctx context.Context, file model.StorageFile) (*model.SignedFileRequest, error) {
...
encodedPolicy, err := sign(policy, signingKey)
if err != nil {
return nil, errors.WithStack(err)
}
...
}
func sign(policy policy, signingKey []byte) (*encodedPolicy, error) {
policyJSON, err := json.Marshal(policy)
if err != nil {
return nil, errors.WithStack(err)
}
base := base64.StdEncoding.EncodeToString(policyJSON)
signature := hash(signingKey, base)
return &encodedPolicy{
Base64Policy: base,
SignedPolicy: fmt.Sprintf("%x", signature),
}, nil
}
type encodedPolicy struct {
Base64Policy string
SignedPolicy string
}
署名したSignatureの他にBase64のPolicy stringも返却しています。
これはフロントから画像をアップロードする際にこのBase64StringをPolicyとしてS3に渡す必要があるからです。
画像アップロード先のURLを作成する
実はここが一番(無駄に)苦労しました。
ここで必要なのが(たぶん)リージョン、バケットが指定されたURLの生成です。
それだけを愚直に生成するのであればそんなに大変ではないんですが、AWS SDKを利用しているのだから必要なURL解決はSDKに任せたかったんですね。
理由は大きく2つで以下です。
- EndpointResolverのオプションがいっぱいあるので本当にリージョン、バケットだけ含めたURL作ればいいだけなのかもわからなかった
- Resolverを利用して環境ごとに接続先を変えたかった
で、問題のコードは以下です。
func (r *SignedFileRepository) GetSignedFileRequest(ctx context.Context, file model.StorageFile) (*model.SignedFileRequest, error) {
...
awsOptions := r.handler.Client().Options()
endpoint, err := awsOptions.EndpointResolverV2.ResolveEndpoint(ctx, bindEndpointParams(awsOptions, file.Bucket()))
if err != nil {
return nil, errors.WithStack(err)
}
...
}
// INFO: 以下のコードはaws sdk go v2から必要な部分だけ引用
func bindEndpointParams(options s3.Options, bucket string) s3.EndpointParameters {
params := s3.EndpointParameters{}
params.Region = bindRegion(options.Region)
params.UseFIPS = aws.Bool(options.EndpointOptions.UseFIPSEndpoint == aws.FIPSEndpointStateEnabled)
params.UseDualStack = aws.Bool(options.EndpointOptions.UseDualStackEndpoint == aws.DualStackEndpointStateEnabled)
params.Endpoint = options.BaseEndpoint
params.ForcePathStyle = aws.Bool(options.UsePathStyle)
params.Accelerate = aws.Bool(options.UseAccelerate)
params.DisableMultiRegionAccessPoints = aws.Bool(options.DisableMultiRegionAccessPoints)
params.UseArnRegion = aws.Bool(options.UseARNRegion)
params.DisableS3ExpressSessionAuth = options.DisableS3ExpressSessionAuth
params.Bucket = &bucket
return params
}
func bindRegion(region string) *string {
if region == "" {
return nil
}
return aws.String(mapFIPSRegion(region))
}
func mapFIPSRegion(region string) string {
const fipsInfix = "-fips-"
const fipsPrefix = "fips-"
const fipsSuffix = "-fips"
if strings.Contains(region, fipsInfix) ||
strings.Contains(region, fipsPrefix) ||
strings.Contains(region, fipsSuffix) {
region = strings.ReplaceAll(region, fipsInfix, "-")
region = strings.ReplaceAll(region, fipsPrefix, "")
region = strings.ReplaceAll(region, fipsSuffix, "")
}
return region
}
SDKに任せたいと言いつつ、SDKのコードを自分のコード側に落とし込んでいるだけです。
下3つの関数はSDK
これには以下のデメリットがあるので、ご注意ください。
- SDKのバージョンアップに追従できない
SDKから何かしらの方法で自身がアクセスすべきAWSのURLのベースを取得できないかと探してみたんですけど、見つからなかったんですよね。
これもご存知の方がいたら教えてほしいです。
AWS SDK Go V2のコードは Apache License Version 2.0なので拝借するのは大丈夫なはず...
License
ただこれでrepository側の実装で環境ごとの接続先切り替えを意識しなくてよくなりました。
処理した結果を返す
最後に値を返却する型につめて返します。
func (r *SignedFileRepository) GetSignedFileRequest(ctx context.Context, file model.StorageFile) (*model.SignedFileRequest, error) {
...
// Policy自体がrequest時のパラメータに必要なため追加
params = append(params, map[string]string{"Policy": encodedPolicy.Base64Policy})
return convert.ToP(model.NewSignedFileRequest(
endpoint.URI.String(),
encodedPolicy.SignedPolicy,
paramsToMap(params),
)), nil
}
func paramsToMap(params []map[string]string) map[string]string {
tmp := make(map[string]string)
for _, param := range params {
for k, v := range param {
tmp[k] = v
}
}
return tmp
}
paramsToMapはparamsが[]mapと変な形なのを素直なmapに直してるだけです。
これでPostPolicyで利用する値を生成して返却することが出来ました。
フロントで生成した値を受け取る
ここまで書かなくていいと思ったのですが一応
またフロント周りはまだエラーハンドリングや例外処理に対するガードが全然まともに書かれてないので参考程度に。
getImageUploadUrl: async (): Promise<PageImageUploadUrlResponse> => {
const res = await fetch(APIRoute.PAGE_IMAGE_UPLOAD_URL.URL, {
method: APIRoute.PAGE_IMAGE_UPLOAD_URL.METHOD,
credentials: "include",
cache: "no-cache",
});
return v.parse(PageImageUploadUrlResponseSchema, await res.json());
},
export const PageImageUploadUrlResponseSchema = v.object({
url: v.string(),
key: v.string(),
signature: v.string(),
params: ObjectToMapSchema,
});
export const ObjectToMapSchema = v.pipe(
v.unknown(),
v.transform((v) => {
return new Map<string, string>(Object.entries(v as Object));
}),
);
私はvalibot
というzod
を軽量にしました、みたいなライブラリを利用しています。
バックエンドからmapをjsonにして返すとObjectとして返ってくるので、key, valueが不定な場合扱いづらいです。
ここでは仕方がないので、ObjectToMap
を利用して無理やりtransform
してHashMapの形式に落とし込んでいます。
フロントからS3へデータを送る
ここもアホみたいにハマりました。
とりあえずコードは以下です。
put: async (data: PageImageUploadUrlResponse, file: File): Promise<void> => {
const body = new FormData();
body.append("key", data.key);
body.append("Content-Type", file.type);
body.append("X-Amz-Signature", data.signature);
data.params.forEach((v, k) => {
body.append(k, v);
});
// file以後はfieldが無視されるので末尾
body.append("file", file);
await fetch(data.url, {
method: "POST",
body: body,
mode: "no-cors",
});
},
コメントにも書いていますが、FormDataに値を詰めるときに順番に注意してください。
file
以後のFieldはS3側で無視されてしまいます。
そのためこのコードでfileを先頭に記載するとBadRequestで以下のようなエラーになります。
Bucket POST must contain a field named 'key'. If it is specified, please check the order of the fields.
なんやねんOrderって...
なんかよくわからないですが、たぶん効率的にファイルを配置するためにfileを受け取り次第順次サーバに送るとかしてて、その前にsignatureの検証が終わってる前提みたいな作りになってるんじゃないでしょうか。
それも意味わからないのでホント謎なんですけど、順番変えると動かないのは仕様みたいです。
AWSの公式サイトにも以下のように書いてありました。
<input type="file" name="file" /> <br />
<!-- The elements after this will be ignored -->
file要素以後のものは無視されるよ〜、とのことです。
疑問
ちなみに疑問に思ったのですが、なんでPostPolicyに関しての記事ってこんなに少ないのでしょうか?
みんなPresignedURLしか使ってないんでしょうか。
アップロードされるファイルに対する制限はどこでかけてるんですかね?
s3に上げるまではある程度自由にしておいて、配置をhookにlambdaを起動してリサイズついでにチェックをしてるんでしょうか。
それとももっと簡単なやり方があるんですかね...
SDK側にもPostPolicy用のコードがなさそうなので、あんまり使われないからなのかなとか思ったり...
jsのsdkにはありそうなんですけどね
誰か教えてくだせぇ。
まとめ
ちょっとコードが長いのと、補足でコード2回書いてるので無駄に長くなってしまいました。
自分でやってみると、何から何までハマってしまって色々考えたり試行錯誤しないと画像アップロード機能一つも作れないのかとつらい気持ちになりました。
ちょっと冗長な記事になってしまったのですが、これも誰かの救いになったらいいなぁと思います。
後誰かスマートなやり方教えてくれないかなぁって気持ちでいつも記事を書いてます。
Discussion