バックエンド側 node.js アップロード/ダウンロード/テスト

2023-11-04

パクリ21発目!

パクリ元

内容

以下一覧を完了させて ECS リリースさせることでバックエンド側を終わりとする。順序は適宜入れ替える。

  • 開発環境構築
  • プロジェクト作成・最適なフォルダ構成
  • 認証チェック(含めた前処理) 
  • ログ処理
  • APIによってはIPチェックやHeaderチェック
  • バリデーション
  • 環境ファイルによる振り分け
  • DB参照・更新
  • 外部API呼び出し
  • 外部API呼び出し待ち合わせ
  • 認証・認可
  • SPA における CSRF CORS
  • 長時間処理を非同期処理で
  • メール送信
  • 画像アップロード    ←←← 今回ココから。
  • 画像ダウンロード
  • EXCEL/PDF作成
  • テスト
  • AWS環境へデプロイ

画像アップロード

CSV アップロードやって DB に1行ずつバリデーションかけながらバルクインサートみたいなのをしようと思っていたけどそんな要件はめったにないでしょう、ということでパクリ元1つ目を参考に画像アップロードに変更。

~/nodetest$ npm install --save multer
~/nodetest$ npm install --save-dev @types/multer
~/nodetest/src/middleware/upload.ts
import multer from "multer";
import path from 'path';

const uploadPath = process.env.UPLOAD_PATH || 'uploads/'
export const upload = multer({
    storage: multer.diskStorage({
        destination(req, file, callback) {
            callback(null, uploadPath);
        },
        filename(req, file, callback) {
          callback(null, Date.now() + file.originalname);
        },
    }),
    limits: { fileSize: Number(process.env.UPLOAD_MAX_FILE_SIZE) || 2000000 }, // In bytes: 2000000 bytes = 2 MB
    fileFilter(req, file, callback) {
        const filetypes = /jpeg|jpg|png|gif/;
        const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
        const mimetype = filetypes.test(file.mimetype);
        if (mimetype && extname) {
          return callback(null, true);
        } else {
            callback(new TypeError("Invalid File Type"));
        }
    },
});
~/nodetest/src/rotes/userRoute.ts
・・・
import {upload} from '../middleware/upload'
・・・
router.post("/upload",
  upload.single("file1"),
  (req, res) => {
    res.json(req.file)
  }
);
・・・
~/nodetest$ curl -X POST -F file1=@/home/ubuntu/skull.jpg -H "Content-Type: multipart/form-data" http://localhost:3001/user/upload
{"fieldname":"file1","originalname":"skull.jpg","encoding":"7bit","mimetype":"image/jpeg","destination":"uploads/","filename":"1698659638604skull.jpg","path":"uploads/1698659638604skull.jpg","size":53671}

ミドルウェアの段階でアップロード処理を行うとは思っていなかったが、アップロードの条件を指定できていて、ルーティングの段階でアップロード後のファイルパスが取得できているのでこれで良しとする。

画像アップロード(S3)

最終的に ECS リリースさせることを考慮すると S3 に入れておいた方がいい。ということでパクリ元2つ目を参考に対応させる。その後どうしてもエラーが出ていてハマっていたが、パクリ元3つ目のおかげで解決。aws sdk には v2 と v3 があって間違えちゃダメよ、ということ。周回遅れのパクリ学習的にはこういうのが一番厄介となる。

~/nodetest$ npm install --save multer-s3 @aws-sdk/client-s3
~/nodetest$ npm install --save-dev @types/multer-s3
~/nodetest/src/middleware/uploadS3.ts
import multer from "multer";
import multerS3 from "multer-s3";
import { S3Client } from '@aws-sdk/client-s3';
import path from 'path';

const s3 = new S3Client({
    region: 'ap-northeast-1',
    credentials: {
        accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID || '', 
        secretAccessKey: process.env.AWS_S3_SECRET_KEY || '',
    },
});

export const uploadS3 = multer({
    storage: multerS3({
        s3: s3,
        bucket: process.env.AWS_S3_BUCKET_NAME_UPLOAD || '',
        metadata: function (req, file, callback) {
            callback(null, {fieldName: file.fieldname});
        },
        key: function (req, file, callback) {
            callback(null, Date.now() + file.originalname);
        }
    }),
    limits: { fileSize: Number(process.env.UPLOAD_MAX_FILE_SIZE) || 2000000 }, // In bytes: 2000000 bytes = 2 MB
    fileFilter(req, file, callback) {
        const filetypes = /jpeg|jpg|png|gif/;
        const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
        const mimetype = filetypes.test(file.mimetype);
        if (mimetype && extname) {
          return callback(null, true);
        } else {
            callback(new TypeError("Invalid File Type"));
        }
    },
});
~/nodetest/src/rotes/userRoute.ts
・・・
import {uploadS3} from '../middleware/uploadS3'
・・・
router.post("/uploadS3",
  uploadS3.single("file1"),
  (req, res) => {
    res.json(req.file)
  }
);
・・・

AWS_S3_ACCESS_KEY_ID や AWS_S3_SECRET_KEY には AWS IAM のページから AmazonS3FullAccess のロールを持たせたユーザーを作成し、セキュリティ認証情報からアクセスキーを作成すれば取得できる。

~/nodetest$ curl -X POST -F file1=@/home/ubuntu/skull.jpg -H "Content-Type: multipart/form-data" http://localhost:3001/user/uploadS3
{"fieldname":"file1","originalname":"skull.jpg","encoding":"7bit","mimetype":"image/jpeg","size":53671,"bucket":"・・・","key":"1698661162954skull.jpg","acl":"private","contentType":"application/octet-stream","contentDisposition":null,"contentEncoding":null,"storageClass":"STANDARD","serverSideEncryption":null,"metadata":{"fieldName":"file1"},"location":"https://・・・.s3.ap-northeast-1.amazonaws.com/1698661162954skull.jpg","etag":"\"e324dbbfb116d4e3f03da56d80ff7b4e\""}

バージョンでハマる事さえなければ瞬殺モノ。

画像ダウンロード

パクリ元4つ目を参考に。

~/nodetest/src/rotes/userRoute.ts
・・・
router.get("/download",
  (req, res) => {
    res.download("/home/ubuntu/nodetest/uploads/1698664450133skull.jpg")
  }
);
・・・
~/nodetest$ curl -X GET http://localhost:3001/user/download --output output.png
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 53671  100 53671    0     0  12.7M      0 --:--:-- --:--:-- --:--:-- 17.0M

完了。本来はルーティングで返すことはなく、モデルでDBから取得したファイルパスを使ってコントローラーでダウンロードかな。実際にはパクリ元5つ目のように大量サイズの場合に問題が起きたりする。

DBから1件readする毎に、res.write(string)で少しずつレスポンス
最後にres.end()で閉じる

モデル側にレスポンスとか持ち込みたくないから500件取ってはレスポンスに吐き出し、をコントローラー側から回すようなこの手の問題を何回か相手にした気がするが詳しくは覚えていない。

画像ダウンロード(S3)

実際には画像ダウンロードでなく画像ダウンロードURL取得。

~/nodetest$ npm install --save @aws-sdk/s3-request-presigner
~/nodetest/src/core/s3.ts
import {GetObjectCommand, S3Client,} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({
    region: 'ap-northeast-1',
    credentials: {
        accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID || '', 
        secretAccessKey: process.env.AWS_S3_SECRET_KEY || '',
    },
});

export default {
    /**
     * 署名付きURLを発行する
     *
     * @param bucket
     * @param key
     * @param expiresIn (sec)
     * @returns 署名付きURL
     */
    getPresignedUrl: async (bucket: string, key: string, expiresIn: number): Promise<string> => {
        const objectParams = {
            Bucket: bucket,
            Key: key,
        };
        const url = await getSignedUrl(s3, new GetObjectCommand(objectParams), { expiresIn });
        console.log(url)
        return url
    }
}

~/nodetest/src/rotes/userRoute.ts
・・・
import s3 from '../core/s3'
・・・
router.get("/getDownloadS3Url",
  async (req, res) => {
    res.json(await s3.getPresignedUrl(
      (process.env.AWS_S3_BUCKET_NAME_UPLOAD || ''),
      "1698661162954skull.jpg",
      60
      )
    )
  }
);
・・・

これもめんどくさいからルーティングで。先ほど s3 にアップロードした画像をダウンロードする署名付き URL を取得する。

~/nodetest$ curl -X GET http://localhost:3001/user/getDownloadS3Url
"https://・・・.s3.ap-northeast-1.amazonaws.com/1698661162954skull.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=・・・%2F20231030%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20231030T122139Z&X-Amz-Expires=60&X-Amz-Signature=4b472be3e58246c1cf7ac15a35d634084088b408b06cb4b1f7416d9c07c10018&X-Amz-SignedHeaders=host&x-id=GetObject"

EXCEL/PDF作成

こんなんめんどくさいのでやっぱやらない。

テスト

いろんなページでそれぞれ主義主張があってよく分からんが、パクリ元7つ目を正統派として進める。

~/nodetest$ npm install --save-dev jest ts-jest supertest @types/jest @types/supertest

index.ts を app.js と server.js に分ける。めんどくさいからセッション認証もトークン認証も有効化させておく。

~/nodetest/src/app.ts
import express from 'express';
import type { Express, Request, Response, NextFunction } from 'express';
import cookieParser from 'cookie-parser';
import log4js from 'log4js';
import 'dotenv/config'

import router from './routes';
import {errorHandler} from './middleware/errorHandler';
import {addResponseHeader} from './middleware/addResponseHeader';

import passport from 'passport'// 両方の認証用 
import session from 'express-session'// セッション認証用 
import {localStrategyWithSessionAuthenticate} from './config/localStrategyWithSessionAuthenticate';// セッション認証用
import {localStrategyWithTokenAuthenticate} from './config/localStrategyWithTokenAuthenticate';// トークン認証用 

import cors from 'cors';

// express
const app: Express = express();

// cors
const allowedOrigins = process.env.ALLOWED_ORIGIN?.split(',') || ['http://localhost:3001'];
const options: cors.CorsOptions = {
  origin: allowedOrigins
};
app.use(cors(options));

// base
app.use(express.json());
app.use(cookieParser());

// session / local authentication
app.use(session({
    secret: process.env.SESSION_SECRET || 'session_secret',
    resave: false,
    saveUninitialized: false
}));// セッション認証用
app.use(passport.initialize());// 両方の認証用 
app.use(passport.session());// セッション認証用
localStrategyWithSessionAuthenticate();// セッション認証用
localStrategyWithTokenAuthenticate();// トークン認証用

// log
log4js.configure('./config/log4js.json');
app.use(log4js.connectLogger(log4js.getLogger('access'), {level: 'auto'}));

// response header
app.use(addResponseHeader);

// routing
app.use('/',  router);

// error handler
app.use(errorHandler)

export default app;
~/nodetest/src/server.ts
import app from './app'

// express
const port = process.env.PORT || 3001;
const environment = process.env.NODE_ENV || 'development';

app.listen(port, () => console.log(`environment:${environment} / listening on port:${port}!`));
~/nodetest/package.json
  "main": "server.js",   <--- index.ts から server.ts へ
  "scripts": {
    "test": "jest --silent=false --verbose false",
    "dev": "nodemon src/server.ts --ext 'ts'"   <--- index.ts から server.ts へ
  },
・・・
  "jest": {
    "transform": {
      ".(ts|tsx)": "ts-jest"
    },
    "testMatch": [
      "**/__tests__/**/*.test.(ts|tsx)"
    ]
  }

app.js では cors ぐらいかなーテストするの。src と同じ階層に __tests__ フォルダを作成しそこに入れることにした。

~/nodetest/__tests__/app.test.js
import request from 'supertest'
import app from '../src/app'

describe('/ CORS ', () => {
    test('Origin ヘッダがない', async () => {
        return request(app)
          .get('/user/list')
          .then((res) => {
            expect(res.header).not.toHaveProperty('access-control-allow-origin');
            expect(res.status).toBe(401);// Unauthorized
          })
      }),
      test('Origin が異なる', async () => {
        return request(app)
          .get('/user/list')
          .set('Origin', 'http://localhost:3002')
          .then((res) => {
            expect(res.header).not.toHaveProperty('access-control-allow-origin');
            expect(res.status).toBe(401);// Unauthorized
          })
      }),
      test('OK', async () => {
        return request(app)
          .get('/user/list')
          .set('Origin', 'http://localhost:3001')
          .then((res) => {
            expect(res.header).toHaveProperty('access-control-allow-origin');
            expect(res.status).toBe(401);// Unauthorized
          })
      })
})
~/nodetest$ npm test
・・・
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
・・・

後はルーティングにある認証系を少々やる。

~/nodetest/__tests__/routes/userRoute.test.js
import type { Express, Request, Response, NextFunction } from 'express';
import request from 'supertest'
import app from '../../src/app'
import userModel from '../../src/models/userModel'
import userController from '../../src/controllers/userController'
import { User } from '@prisma/client';
var bcrypt = require('bcrypt');

const email = 'test@test.test';
const password = 'password';
const wrongPassword = 'wrongPassword';
const name = 'hoge';
const userMockFn = () => {
  const saltRounds = 10;
  const user = {
    id: 1,
    email: email,
    password: bcrypt.hashSync(password, saltRounds),
    name: name,
  };
  const userModel_getByEmail = jest.spyOn(userModel, "getByEmail").mockImplementation((any) => Promise.resolve(user as User));
  const userModel_insert = jest.spyOn(userModel, "insert").mockImplementation((_any, __any, ___any, ____any) => Promise.resolve(user as User));
  const userModel_getList = jest.spyOn(userModel, "getList").mockImplementation(() => Promise.resolve( [user] as User[]));
}

describe('/loginSession セッション認証セッション作成 ', () => {
  test('email パラメータがない', async () => {
      return request(app)
        .post('/user/loginSession')
        .send({
          em: email,
          password: password,
        })
        .then((res) => {
          expect(res.statusCode).toBe(400);
        })
  }),
  test('password パラメータがない', async () => {
    return request(app)
      .post('/user/loginSession')
      .send({
        email: email,
        pa: password,
      })
      .then((res) => {
        expect(res.statusCode).toBe(400);
      })
  }),
  test('ログインNG', async () => {
    userMockFn();
    return request(app)
      .post('/user/loginSession')
      .send({
        email: email,
        password: wrongPassword,
      })
      .then((res) => {
        expect(res.statusCode).toBe(401);
      })
  }),
  test('ログインOK', async () => {
    userMockFn();
    return request(app)
      .post('/user/loginSession')
      .send({
        email: email,
        password: password,
      })
      .then((res) => {
        expect(res.statusCode).toBe(200);
      })
  }),
  test('ログインOK セッションクッキー付加', async () => {
    userMockFn();
    return request(app)
      .post('/user/loginSession')
      .send({
        email: email,
        password: password,
      })
      .then((res) => {
        expect(res.header).toHaveProperty('set-cookie');
        expect(res.headers['set-cookie'][0]).toMatch(new RegExp(`^connect.sid?`));
        expect(res.statusCode).toBe(200);
      })
  }),
  test('ログインOK後セッション認証NG', async () => {
    userMockFn();
    return request(app)
      .post('/user/loginSession')
      .send({
        email: email,
        password: password,
      })
      .then((res) => {
        const cookie: string = "aaa";
        
        return request(app)
          .get('/user/list')
          .set('Cookie', [cookie])
          .then((res) => {
            expect(res.status).toBe(401);
          })
      })
  }),
  test('ログインOK後セッション認証OK', async () => {
    userMockFn();
    return request(app)
      .post('/user/loginSession')
      .send({
        email: email,
        password: password,
      })
      .then((res) => {
        const cookie: string = res.headers['set-cookie'][0];
        
        return request(app)
          .get('/user/list')
          .set('Cookie', [cookie])
          .then((res) => {
            expect(res.status).toBe(200);
          })
      })
  })
  /* deserializeUserテストしたいがthenの段階でreq.userを取得できない
  test('ログインOK後 deserializeUser', async () => {
    userMockFn();
    return request(app)
      .post('/user/loginSession')
      .send({
        email: email,
        password: password,
      })
      .then((res) => {
        const cookie: string = res.headers['set-cookie'][0];
        return request(app)
          .get('/user/list')
          .set('Cookie', [cookie])
          .then((res) => {
            expect(req.user.email).toBe(email);
          })
      })
  })*/
})

describe('/loginToken トークン認証トークン作成 ', () => {
  // LocalStrategy 内の処理はセッションもトークンも同じなので省略
  test('ログインOK', async () => {
    userMockFn();
    return request(app)
      .post('/user/loginToken')
      .send({
        email: email,
        password: password,
      })
      .then((res) => {
        expect(JSON.parse(res.text)['token']).toBeDefined();
      })
  })
  test('ログインOK後トークン認証NG', async () => {
    userMockFn();
    return request(app)
      .post('/user/loginToken')
      .send({
        email: email,
        password: password,
      })
    .then((res) => {
      const token: string = "aaa";
      return request(app)
        .post('/user/insert')
        .set('Authorization', 'bearer ' + token)
        .then((res) => {
          expect(res.status).toBe(401);
        })
    })
  }),
  test('ログインOK後トークン認証OK', async () => {
    userMockFn();
    jest.mock("../../src/validators/userUpdateValidator", () => ({
      userUpdateValidator: (req: any, res: any, next: any) => next()
    }));
    return request(app)
      .post('/user/loginToken')
      .send({
        email: email,
        password: password,
      })
      .then((res) => {
        const token: string = JSON.parse(res.text)['token'];
        return request(app)
          .post('/user/insert')
          .set('Authorization', 'bearer ' + token)
          .then((res) => {
            expect(res.status).toBe(200);
          })
      })
  })
})
~/nodetest$ npm test
・・・
Test Suites: 2 passed, 2 total
Tests:       13 passed, 13 total
・・・

やはり全部テストするのは大変

現状のフォルダ構成

nodetest
┣━ __tests__
┃  ┣━ routes
┃  ┃   ┗━ userRoute.test.ts
┃  ┗━ app.test.ts
┣━ config
┃  ┗━ log4js.json
┣━ dist
┣━ log
┃  ┣━ access.yyyymmdd.log
┃  ┗━ app.yyyymmdd.log
┣━ node_modules
┣━ prisma
┃  ┣━ migrations
┃  ┃   ┣━ yyyymmddhhmiss_create_user
┃  ┃   ┃   ┗━ migration.sql
┃  ┃   ┗━ migration_lock.toml
┃  ┣━ dev.db
┃  ┣━ schema.prisma
┃  ┗━ seed.ts
┣━ src
┃  ┣━ config
┃  ┃   ┣━ localStrategyWithSessionAuthenticate.ts
┃  ┃   ┗━ localStrategyWithTokenAuthenticate.ts
┃  ┣━ const
┃  ┃   ┗━ url.ts
┃  ┣━ controllers
┃  ┃   ┗━ userController.ts
┃  ┣━ core
┃  ┃   ┣━ dbaccess.ts
┃  ┃   ┣━ fetch.ts
┃  ┃   ┣━ s3.ts
┃  ┃   ┗━ sendmail.ts
┃  ┣━ middleware
┃  ┃   ┣━ addResponseHeader.ts
┃  ┃   ┣━ authenticate.ts
┃  ┃   ┣━ checkIpHeader.ts
┃  ┃   ┣━ errorHandler.ts
┃  ┃   ┣━ upload.ts
┃  ┃   ┗━ uploadS3.ts
┃  ┣━ models
┃  ┃   ┣━ addressModel.ts
┃  ┃   ┗━ userModels.ts
┃  ┣━ routes
┃  ┃   ┣━ index.ts
┃  ┃   ┗━ userRoute.ts
┃  ┣━ types
┃  ┃   ┣━ authenticationError.ts
┃  ┃   ┗━ error.ts
┃  ┣━ validators
┃  ┃   ┗━ userUpdateValidator.ts
┃  ┣━ app.ts
┃  ┗━ server.ts
┣━ .env
┣━ .env.development
┣━ .gitignore
┣━ package-lock.json
┣━ package.json
┗━ tsconfig.json

雑感

疲れた。あとはコンテナ化して終わりとする。