import { app, InvocationContext, Timer } from "@azure/functions"; import * as dotenv from "dotenv"; import { BlobstorageService } from "../blobstorage/blobstorage.service"; import { ADMIN_ROLES, IMPORT_USERS_MAX_DURATION_MINUTES, IMPORT_USERS_STAGE_FILE_NAME, IMPORT_USERS_STAGES, RoleNumberMap, SYSTEM_IMPORT_USERS, TIERS, } from "../constants"; import { ErrorRow, ImportData } from "../blobstorage/types/types"; import { Configuration, UsersApi } from "../api"; import { createErrorObject } from "../common/errors/utils"; import { sign, getJwtKey } from "../common/jwt"; import { AccessToken, SystemAccessToken } from "../common/jwt/types"; import { isImportJson, isStageJson } from "../blobstorage/types/guards"; import https from "https"; export async function importUsersProcessing( context: InvocationContext, blobstorageService: BlobstorageService, userApi: UsersApi ): Promise { context.log(`[IN] importUsersProcessing`); try { dotenv.config({ path: ".env" }); dotenv.config({ path: ".env.local", override: true }); const startUnixTime = getCurrentUnixTime(); context.log(`importUsersProcessing start: ${startUnixTime}`); // ファイルが存在する間ループ while (true) { // Blobストレージからファイル名の一覧を取得(stage.json以外) const bloblist = await blobstorageService.listBlobs(context); context.log(bloblist); // stage.json以外のファイルが存在しない場合は処理中断 if (bloblist.length === 0) { break; } // ファイルのうち、日付が最も古いファイルを取得 let targetFileName = bloblist.sort().at(0); if (targetFileName === undefined) { throw new Error("targetFileName is undefined"); } let row = 1; // stage.jsonを取得(ダウンロード)して読み込む let stageData = await blobstorageService.downloadFileData( context, IMPORT_USERS_STAGE_FILE_NAME ); // stage.jsonが存在しない場合は、新規作成する if (stageData === undefined) { stageData = JSON.stringify({ update: getCurrentUnixTime(), state: IMPORT_USERS_STAGES.CREATED, }); const updateSuccess = await blobstorageService.updateFile( context, IMPORT_USERS_STAGE_FILE_NAME, stageData ); if (!updateSuccess) { throw new Error( `update stage.json failed. state: ${IMPORT_USERS_STAGES.CREATED} filename: ${targetFileName}` ); } } const stage = JSON.parse(stageData); if (!isStageJson(stage)) { throw new Error("stage.json is invalid"); } // 作業中のstage.jsonが存在する場合は、処理を再開する if ( stage.state !== IMPORT_USERS_STAGES.CREATED && stage.state !== IMPORT_USERS_STAGES.DONE ) { // stage.jsonが存在し、内部状態が処理中で、最終更新日時が10分以上前だった場合は処理中断とみなして途中から再開 const nowUnixTime = getCurrentUnixTime(); if (nowUnixTime - stage.update > 10 * 60) { // stage.jsonの内容から処理対象のfilepathを特定する context.log(stage.filename); if (stage.filename === undefined) { context.log("stage.filename is undefined"); break; } targetFileName = stage.filename; // 処理開始行をstage.jsonを元に復元する row = stage.row ?? 1; } else { // 内部状態が処理中であれば処理中断(処理が終わる前にTimerから再度起動されてしまったケース) context.log("stage is processing"); break; } } { const updateSuccess = await blobstorageService.updateFile( context, IMPORT_USERS_STAGE_FILE_NAME, JSON.stringify({ update: getCurrentUnixTime(), state: IMPORT_USERS_STAGES.PRAPARE, filename: targetFileName, }) ); if (!updateSuccess) { throw new Error( `update stage.json failed. state: ${IMPORT_USERS_STAGES.PRAPARE} filename: ${targetFileName}` ); } } // 対象ファイルをダウンロードして読み込む const importsData = await blobstorageService.downloadFileData( context, targetFileName ); // 一括登録ユーザー一覧をメモリ上に展開 const imports = importsData === undefined ? undefined : JSON.parse(importsData); if (!isImportJson(context, imports)) { throw new Error(`json: ${targetFileName} is invalid`); } if (imports === undefined) { break; } // 代行操作トークンを発行する const accsessToken = await generateDelegationAccessToken( context, imports.external_id, imports.user_role ); // 一括登録ユーザー一覧をループして、一括登録ユーザーを一括登録する const errors: ErrorRow[] = []; for (const user of imports.data) { { // stage.jsonを更新(ユーザー追加開始) const updateSuccess = await blobstorageService.updateFile( context, IMPORT_USERS_STAGE_FILE_NAME, JSON.stringify({ update: getCurrentUnixTime(), state: IMPORT_USERS_STAGES.START, filename: targetFileName, row: row, }) ); if (!updateSuccess) { throw new Error( `update stage.json failed. state: ${IMPORT_USERS_STAGES.START} filename: ${targetFileName} row: ${row}` ); } } try { if (!checkUser(context, user, targetFileName, row)) { throw new Error( `Invalid user data. filename: ${targetFileName} row: ${row}` ); } // ユーザーを追加する await addUser(context, userApi, user, accsessToken); } catch (e) { const error = createErrorObject(e); context.log(error); // エラーが発生したらエラーコードを控えておく errors.push({ row: row, error: error.code, name: user.name }); } { // stage.jsonを更新(ユーザー追加完了) const updateSuccess = await blobstorageService.updateFile( context, IMPORT_USERS_STAGE_FILE_NAME, JSON.stringify({ update: getCurrentUnixTime(), state: IMPORT_USERS_STAGES.COMPLETE, filename: targetFileName, row: row, errors: errors, }) ); if (!updateSuccess) { throw new Error( `update stage.json failed. state: ${IMPORT_USERS_STAGES.COMPLETE} filename: ${targetFileName} row: ${row}` ); } } row++; // 500ms待機 await new Promise((resolve) => setTimeout(resolve, 500)); } // 処理対象のユーザー一覧ファイルを削除する await blobstorageService.deleteFile(context, targetFileName); // システムトークンを発行 const systemToken = await generateSystemToken(context); // 一括登録完了メールを送信する(ODMS Cloudの一括追加完了APIを呼び出す) await userApi.multipleImportsComplate( { accountId: imports.account_id, filename: imports.file_name, requestTime: getCurrentUnixTime(), errors: errors.map((error) => { return { name: error.name, line: error.row, errorCode: error.error, }; }), }, { headers: { authorization: `Bearer ${systemToken}` }, httpsAgent: new https.Agent({ rejectUnauthorized: false }), } ); { // stage.jsonを更新(処理完了) const updateSuccess = await blobstorageService.updateFile( context, IMPORT_USERS_STAGE_FILE_NAME, JSON.stringify({ update: getCurrentUnixTime(), state: IMPORT_USERS_STAGES.DONE, }) ); if (!updateSuccess) { throw new Error( `update stage.json failed. state: ${IMPORT_USERS_STAGES.DONE} filename: ${targetFileName}` ); } } // 経過時間を確認して、30分以上経過していたら処理を中断する { const currentUnixTime = getCurrentUnixTime(); // 時間の差分を計算(秒) const elapsedSec = currentUnixTime - startUnixTime; // 30分以上経過していたら処理を中断する if (elapsedSec > IMPORT_USERS_MAX_DURATION_MINUTES * 60) { context.log("timeout"); break; } } } } catch (e) { context.log("importUsers failed."); context.error(e); throw e; } finally { context.log(`[OUT] importUsersProcessing`); } } export async function importUsers( myTimer: Timer, context: InvocationContext ): Promise { context.log(`[IN] importUsers`); try { dotenv.config({ path: ".env" }); dotenv.config({ path: ".env.local", override: true }); const blobstorageService = new BlobstorageService(); const userApi = new UsersApi( new Configuration({ basePath: process.env.BASE_PATH, }) ); await importUsersProcessing(context, blobstorageService, userApi); } catch (e) { context.log("importUsers failed."); context.error(e); throw e; } finally { context.log(`[OUT] importUsers`); } } /** * ODMS CloudのAPIを呼び出してユーザーを追加する * @param context * @param user * @returns user */ export async function addUser( context: InvocationContext, userApi: UsersApi, user: ImportData, token: string ): Promise { context.log(`[IN] addUser`); try { await userApi.signup( { email: user.email, name: user.name, role: RoleNumberMap[user.role], autoRenew: user.auto_renew === 1, notification: user.notification === 1, authorId: user.role === 1 ? user.author_id : undefined, encryption: user.role === 1 ? user.encryption === 1 : undefined, encryptionPassword: user.encryption === 1 ? user.encryption_password : undefined, prompt: user.role === 1 ? user.prompt === 1 : undefined, }, { headers: { authorization: `Bearer ${token}` }, httpsAgent: new https.Agent({ rejectUnauthorized: false }), } ); } catch (e) { context.error(e); context.error(JSON.stringify(e.response?.data)); throw e; } finally { context.log(`[OUT] addUser`); } } /** * ユーザーのデータが正しいかどうかをチェック * @param context * @param user * @param fileName * @param row * @returns true if user */ function checkUser( context: InvocationContext, user: ImportData, fileName: string, row: number ): boolean { context.log( `[IN] checkUser | params: { fileName: ${fileName}, row: ${row} }` ); try { // 名前が255文字以内であること if (user.name.length > 255) { context.log(`name is too long. fileName: ${fileName}, row: ${row}`); return false; } const emailPattern = /^[a-zA-Z0-9!#$%&'_`/=~+\-?^{|}.]+@[a-zA-Z0-9!#$%&'_`/=~+\-?^{|}.]*\.[a-zA-Z0-9!#$%&'_`/=~+\-?^{|}.]*[a-zA-Z]$/; // メールアドレスが255文字以内であること if (user.email.length > 255) { context.log(`email is too long. fileName: ${fileName}, row: ${row}`); return false; } if (!emailPattern.test(user.email)) { context.log(`Invalid email. fileName: ${fileName}, row: ${row}`); return false; } // ロールが(0/1/2)のいずれかであること if (![0, 1, 2].includes(user.role)) { context.log(`Invalid role number. fileName: ${fileName}, row: ${row}`); return false; } // ロールがAuthorの場合 if (user.role === 1) { // author_idが必須 if (user.author_id === undefined) { context.log( `author_id is required. fileName: ${fileName}, row: ${row}` ); return false; } // author_idが16文字以内であること if (user.author_id.length > 16) { context.log( `author_id is too long. fileName: ${fileName}, row: ${row}` ); return false; } // author_idが半角大文字英数字とハイフンであること if (!/^[A-Z0-9_]*$/.test(user.author_id)) { context.log(`author_id is invalid. fileName: ${fileName}, row: ${row}`); return false; } // encryptionが必須 if (user.encryption === undefined) { context.log( `encryption is required. fileName: ${fileName}, row: ${row}` ); return false; } // encryptionが1の場合 if (user.encryption === 1) { // encryption_passwordが必須 if (user.encryption_password === undefined) { context.log( `encryption_password is required. fileName: ${fileName}, row: ${row}` ); return false; } // 4~16文字の半角英数字と記号のみであること if (!/^[!-~]{4,16}$/.test(user.encryption_password)) { context.log( `encryption_password is invalid. fileName: ${fileName}, row: ${row}` ); return false; } if (user.prompt === undefined) { context.log(`prompt is required. fileName: ${fileName}, row: ${row}`); return false; } } } return true; } catch (e) { context.error(e); throw e; } finally { context.log(`[OUT] checkUser`); } } /** * 代行操作用のアクセストークンを生成します * @param context * @param externalId * @returns delegation token */ async function generateDelegationAccessToken( context: InvocationContext, externalId: string, role: string ): Promise { context.log( `[IN] generateDelegationAccessToken | params: { externalId: ${externalId} }` ); try { // 要求されたトークンの寿命を決定 const tokenLifetime = Number(process.env.ACCESS_TOKEN_LIFETIME_WEB); const privateKey = getJwtKey(process.env.JWT_PRIVATE_KEY ?? ""); const token = sign( { role: `${role} ${ADMIN_ROLES.ADMIN}`, tier: TIERS.TIER5, userId: externalId, delegateUserId: SYSTEM_IMPORT_USERS, }, tokenLifetime, privateKey ); return token; } catch (e) { context.error(e); throw e; } finally { context.log(`[OUT] generateDelegationAccessToken`); } } /** * System用のアクセストークンを生成します * @param context * @returns system token */ async function generateSystemToken( context: InvocationContext ): Promise { context.log(`[IN] generateSystemToken`); try { // 要求されたトークンの寿命を決定 const tokenLifetime = Number(process.env.ACCESS_TOKEN_LIFETIME_WEB); const privateKey = getJwtKey(process.env.JWT_PRIVATE_KEY ?? ""); const token = sign( { systemName: SYSTEM_IMPORT_USERS, }, tokenLifetime, privateKey ); return token; } catch (e) { context.error(e); throw e; } finally { context.log(`[OUT] generateSystemToken`); } } const getCurrentUnixTime = () => Math.floor(new Date().getTime() / 1000); // 5分毎に実行 app.timer("importUsers", { schedule: "0 */5 * * * *", handler: importUsers, });