UtilsQiniuOSS.mjs

/**
 * 七牛云接口文档:https://developer.qiniu.com/kodo/1289/nodejs#rs-delete
 */
import path from "path";
import { glob } from "glob";
import qiniu from "qiniu";
import { logError } from "#lib/UtilsLog";
import { getError } from "#lib/UtilsType";

/**
 * Utility functions for qiniu-oss
 * @module OSS-Qiniu
 *
 * @example
 * import {
 *   getConfigFromQiniuOSS,
 *   getMacFromQiniuOSS,
 *   joinPath,
 *   refreshUrlsFromQiniuOSS,
 *   uploadDirToQiniuOSS,
 * } from "nsuite";
 *
 * process.env.QINIU_HTTP_CLIENT_TIMEOUT = "120000";
 *
 * const mac = getMacFromQiniuOSS({
 *   accessKey: QINIU_ACCESS_KEY,
 *   secretKey: QINIU_SECRET_KEY,
 * });
 * const config = getConfigFromQiniuOSS({});
 * const { uploadedList } = await uploadDirToQiniuOSS({
 *   config,
 *   mac,
 *   bucket: QINIU_BUCKET_NAME,
 *   baseUrl: QINIU_PUBLIC_BUCKET_DOMAIN,
 *   keyPrefix: CDN_PATH_PREFIX,
 *   putPolicyOptions: {
 *     scope: QINIU_BUCKET_NAME,
 *     expires: 7200,
 *   },
 *   localPath: PATH_PUBLIC,
 *   ignorePathList: ["node_modules/**"],
 *   refresh: false,
 *   recursive: true,
 *   dryRun: false,
 *   uploadCallback: (curIdx, totalCount, fileInfo) => {
 *     logger.info(`Uploaded ${curIdx + 1}/${totalCount} ${fileInfo.key}`);
 *   },
 * });
 *
 * const urlsToRefresh = uploadedList
 *   .filter((item) => {
 *     return item.key.endsWith(".css") || item.key.endsWith(".js");
 *   })
 *   .map((item) => item.url);
 *
 * logger.info(`Start refreshing CDN: ${urlsToRefresh.join(", ")}.`);
 * const refreshedUrls = await refreshUrlsFromQiniuOSS({
 *   urls: urlsToRefresh,
 *   mac,
 * });
 *
 * logger.info(`Refreshed urls: ${refreshedUrls.join(", ")}.`);
 */

/**
 * @typedef {qiniu.httpc.HttpClientOptions} QiniuHttpClientRawOptions
 */

/**
 * @typedef {QiniuHttpClientRawOptions} QiniuHttpClientOptions
 * @property {number} [timeout]
 */

const getQiniuOssTimeout = () => {
  const { QINIU_HTTP_CLIENT_TIMEOUT } = process.env;
  if (QINIU_HTTP_CLIENT_TIMEOUT) {
    return Number(QINIU_HTTP_CLIENT_TIMEOUT);
  }
  return 0;
};

/**
 * 获取七牛云自定义错误码的错误信息
 *
 * reference: https://developer.qiniu.com/fusion/1229/cache-refresh
 * 200  success  成功
 * 400031  invalid url  请求中存在无效的 url,请确保 url 格式正确
 * 400032  invalid host  请求中存在无效的域名,请确保域名格式正确
 * 400034  refresh url limit error  请求次数超出当日刷新限额
 * 400036  invalid request id  无效的请求 id
 * 400037  url has existed  url 正在刷新中
 * 400038  refresh dir authority error  没有刷新目录的权限, 如果需要请联系技术支持
 * 403024  single user QPS Rate limited  请求达到单用户QPS限制,请重试或联系我们
 * 403022  server QPS Rate limited  请求达到全局QPS限制,请联系我们
 * 500000  internal error  服务端内部错误,请联系技术支持
 * @param {number} code
 * @returns {string}
 */
const getQiniuCacheRefreshCodeMessage = (code) => {
  switch (code) {
    case 200:
      return "成功";
    case 400031:
      return "请求中存在无效的 url,请确保 url 格式正确";
    case 400032:
      return "请求中存在无效的域名,请确保域名格式正确";
    case 400034:
      return "请求次数超出当日刷新限额";
    case 400036:
      return "无效的请求 id";
    case 400037:
      return "url 正在刷新中";
    case 400038:
      return "没有刷新目录的权限, 如果需要请联系技术支持";
    case 403024:
      return "请求达到单用户QPS限制,请重试或联系我们";
    case 403022:
      return "请求达到全局QPS限制,请联系我们";
    case 500000:
      return "服务端内部错误,请联系技术支持";
    default:
      return "未知错误";
  }
};

/**
 * @typedef {'Zone_z0' | 'Zone_z1' | 'Zone_z2' | 'Zone_na0' | 'Zone_as0'} QiniuZoneName
 * @typedef {import('qiniu').conf.Config} QiniuConfig
 * @typedef {import('qiniu').rs.BucketManager} QiniuBucketManager
 * @typedef {import('qiniu').auth.digest.Mac} QiniuMac
 * @typedef {import('qiniu').auth.digest.MacOptions} QiniuMacOptions
 * @typedef {import('qiniu').rs.PutPolicyOptions} QiniuPutPolicyOptions
 * @typedef {import('qiniu/StorageResponseInterface.d.ts').ListedObjectEntry} QiniuListedObjectEntry
 * @typedef {import('qiniu').rs.ListPrefixOptions} QiniuListPrefixOptions
 * @typedef {import('qiniu').httpc.ResponseWrapper} QiniuHttpcResponseWrapper
 * @typedef {import('qiniu/StorageResponseInterface.d.ts').OperationResponse} QiniuOperationResponse
 */

/**
 * @typedef {Object} ParamsQiniuOSSGetMac
 * @property {string} accessKey
 * @property {string} secretKey
 * @property {QiniuMacOptions} [options]
 */

/**
 * Get mac from qiniu
 * @param {ParamsQiniuOSSGetMac} payload
 * @returns {QiniuMac}
 */
export function getMacFromQiniuOSS(payload) {
  const { accessKey, secretKey, options } = payload;
  return new qiniu.auth.digest.Mac(accessKey, secretKey, options);
}

/**
 * Get
 * @param {import('qiniu').conf.ConfigOptions} options
 * @returns {QiniuConfig}
 */
export function getConfigFromQiniuOSS(options) {
  return new qiniu.conf.Config(options);
}

/**
 * @typedef {Object} ParamsQiniuOSSGetBucketManager
 * @property {QiniuMac} mac
 * @property {QiniuConfig} config
 */

/**
 * Get bucket manager from qiniu
 * @param {ParamsQiniuOSSGetBucketManager} payload
 * @returns {QiniuBucketManager}
 */
export function getBucketManagerFromQiniuOSS(payload) {
  const { mac, config } = payload;
  const bm = new qiniu.rs.BucketManager(mac, config);
  const qiniuTimeout = getQiniuOssTimeout();
  if (qiniuTimeout) {
    // @ts-ignore
    bm._httpClient.timeout = qiniuTimeout;
  }
  return bm;
}

/**
 * @typedef {Object} ParamsQiniuOSSGetPublicDownloadUrl
 * @property {QiniuBucketManager} bucketManager
 * @property {string} key
 * @property {string} [baseUrl]
 */

/**
 * Get public download url
 * @param {ParamsQiniuOSSGetPublicDownloadUrl} payload
 * @returns {string}
 */
export function getPublicDownloadUrlFromQiniuOSS(payload) {
  const { bucketManager, key, baseUrl = "" } = payload;
  return bucketManager.publicDownloadUrl(baseUrl, key);
}

/**
 * @typedef {Object} ParamsQiniuOSSRefreshUrls
 * @property {string[]} urls
 * @property {QiniuMac} mac
 */

/**
 * Refresh cdn urls
 * @param {ParamsQiniuOSSRefreshUrls} payload
 * @returns {Promise<string[]>}
 */
export async function refreshUrlsFromQiniuOSS(payload) {
  const { urls, mac } = payload;
  if (urls.length === 0) {
    return [];
  }
  const cdnManager = new qiniu.cdn.CdnManager(mac);

  /**
   * Promise function
   * @param {string[]} someUrls
   * @returns {Promise<string[]>}
   */
  const promiseFunc = (someUrls) => {
    /** @type {Promise<string[]>} */
    return new Promise((resolve, reject) => {
      /**
       * @typedef {Object} QiniuRefreshUrlsRespBody
       * @property {number} code 200 if success
       * @property {string} error 'success' is success
       * @property {string} requestId
       * @property {null | Record<string, unknown>} taskIds
       */
      /**
       * @typedef {Object} QiniuRefreshUrlsRespInfo
       * @property {number} status 200 if success
       * @property {number} statusCode 200 if success
       * @property {string} statusMessage 'OK' if success
       * @property {Record<string, string>} headers
       * @property {number} size
       * @property {boolean} aborted
       * @property {number} rt
       * @property {QiniuRefreshUrlsRespBody} data
       * @property {string[]} requestUrls
       * @property {number | null} timing
       * @property {string} remoteAddress
       * @property {number} remotePort
       * @property {number} socketHandledRequests
       * @property {number} socketHandledResponses
       */
      /**
       * Callback function
       * @param {Error | undefined} err
       * @param {QiniuRefreshUrlsRespBody} respBody
       * @param {QiniuRefreshUrlsRespInfo} respInfo
       */
      const refreshCallback = (err, respBody, respInfo) => {
        if (err) {
          reject(err);
          return;
        }
        if (respInfo.statusCode !== 200) {
          logError("Failed in refreshUrlsFromQiniuOSS", respInfo);
          reject(new Error(`Abnormal statusCode: ${respInfo.statusCode}`));
          return;
        }
        if (respInfo.data && respInfo.data.code !== 200) {
          const resCode = respInfo.data.code;
          const resError = respInfo.data.error;
          const resErrMsg = getQiniuCacheRefreshCodeMessage(resCode);
          let reason = `[${resCode}]: ${resError}`;
          if (resErrMsg) {
            reason += `, ${resErrMsg}`;
          }
          reject(new Error(reason));
          return;
        }
        if (!respBody.taskIds) {
          reject(new Error("Empty respBody.taskIds"));
          return;
        }
        try {
          resolve(Object.keys(respBody.taskIds));
        } catch (err) {
          logError("Failed in Object.keys(respBody.taskIds)", err, respInfo);
          resolve([]);
        }
      };

      cdnManager.refreshUrls(someUrls, refreshCallback);
    });
  };

  /** @type {string[][]} */
  const groups = [];
  const groupSize = 100;
  for (let i = 0; i < urls.length; i += groupSize) {
    groups.push(urls.slice(i, i + groupSize));
  }
  // 未避免并发太大,此处串行处理
  /** @type {string[]} */
  let returnUrls = [];
  for (const group of groups) {
    const tempUrls = await promiseFunc(group);
    returnUrls = returnUrls.concat(tempUrls);
  }
  return returnUrls;
}

/**
 * @typedef {Object} ParamsQiniuOSSListFiles
 * @property {QiniuBucketManager} bucketManager
 * @property {string} bucket
 * @property {QiniuListPrefixOptions} options
 */

/***
 * List all files under a remote directory
 * @param {ParamsQiniuOSSListFiles} payload
 * @return {Promise<QiniuListedObjectEntry[]>}
 */
// 查询某个远程目录下的文件列表
const listFilesFromQiniuOSS = async (payload) => {
  const { bucketManager, bucket, options } = payload;
  const { limit = 100 } = options;

  const prefix = options.prefix || "";
  if (prefix) {
    if (prefix.startsWith("http")) {
      throw new Error(
        `prefix should not start with http, your invalid prefix is ${prefix}`,
      );
    }
    if (prefix.startsWith("/")) {
      throw new Error(
        `prefix should not start with /, your invalid prefix is ${prefix}`,
      );
    }
  }

  /** @type {QiniuListedObjectEntry[]} */
  let returnItems = [];
  /** @type {string | undefined} */
  let nextMarker = undefined;
  do {
    const res = await bucketManager.listPrefix(bucket, {
      ...options,
      limit: limit || 100,
      marker: nextMarker,
    });
    nextMarker = res.data.marker;
    returnItems = returnItems.concat(res.data.items || []);
  } while (nextMarker && !limit);
  return returnItems;
};

/**
 * @typedef {Object} ParamsQiniuOSSDeleteRemotePathList
 * @property {QiniuBucketManager} bucketManager
 * @property {string[]} remotePathList
 * @property {string} bucket
 */

/**
 * @typedef {Object} ReturnQiniuOSSDeleteRemotePathList
 * @property {string[]} successItems
 * @property {string[]} failItems
 */

/**
 * Delete files
 * @param {ParamsQiniuOSSDeleteRemotePathList} payload
 * @returns {Promise<ReturnQiniuOSSDeleteRemotePathList>}
 */
export async function deleteRemotePathListFromQiniuOSS(payload) {
  const { bucketManager, remotePathList, bucket } = payload;
  /** @type {string[]} */
  const successItems = [];
  /** @type {string[]} */
  const failItems = [];

  if (remotePathList.length === 0) {
    return {
      successItems: [],
      failItems: [],
    };
  }

  /** @type {string[]} */
  let allKeysToDelete = [];

  // 有目录需要清空的话,清空对应目录下的文件
  for (const prefix of remotePathList) {
    const fileList = await listFilesFromQiniuOSS({
      bucketManager,
      bucket,
      options: {
        prefix,
        limit: 0,
      },
    });
    const keysToDelete = fileList.map((item) => item.key);
    allKeysToDelete = allKeysToDelete.concat(keysToDelete);
  }

  /** @type {string[][]} */
  const deleteKeysGroups = [];
  const maxOperationSize = 100;
  for (let i = 0; i < allKeysToDelete.length; i += maxOperationSize) {
    deleteKeysGroups.push(allKeysToDelete.slice(i, i + maxOperationSize));
  }

  // 避免并发过高,此处串行执行
  for (const deleteKeysGroup of deleteKeysGroups) {
    const res = await bucketManager.batch(
      deleteKeysGroup.map((key) => {
        return qiniu.rs.deleteOp(bucket, key);
      }),
    );
    /** @type {QiniuOperationResponse[]} */
    const listRes = res.data || [];
    listRes.forEach((item, idx) => {
      if (item.code === 200) {
        successItems.push(deleteKeysGroup[idx]);
      } else {
        failItems.push(deleteKeysGroup[idx]);
      }
    });
  }

  return {
    successItems,
    failItems,
  };
}

/**
 * @typedef {Object} ParamsQiniuOSSUploadLocalFile
 * @property {QiniuConfig} config
 * @property {QiniuMac} mac
 * @property {string} localPath
 * @property {string} key
 * @property {string} baseUrl
 * @property {string} bucket
 * @property {QiniuPutPolicyOptions} [putPolicyOptions]
 */

/**
 * @typedef {Object} ReturnQiniuOSSUploadLocalFile
 * @property {string} key
 * @property {string} etag
 * @property {number} fileSize
 * @property {string} bucket
 * @property {string} name
 * @property {string} url
 */

/**
 * Upload local file to Qiniu
 * @param {ParamsQiniuOSSUploadLocalFile} payload
 * @returns {Promise<ReturnQiniuOSSUploadLocalFile>}
 */
export async function uploadLocalFileToQiniuOSS(payload) {
  const { config, mac, localPath, key, bucket, putPolicyOptions, baseUrl } =
    payload;
  const formUploader = new qiniu.form_up.FormUploader(config);
  const putExtra = new qiniu.form_up.PutExtra();

  /** @type {QiniuPutPolicyOptions} */
  const options = {
    // 指定了key,就可以支持覆盖上传
    scope: `${bucket}:${key}`,
    // .html文件缓存30秒,其他文件缓存10小时
    expires: key.endsWith(".html") ? 30 : 36000,
    ...(putPolicyOptions || {}),
    returnBody:
      '{"key":"$(key)","etag":"$(etag)","fileSize":$(fsize),"bucket":"$(bucket)","name":"$(fname)"}',
  };
  const putPolicy = new qiniu.rs.PutPolicy(options);
  const uploadToken = putPolicy.uploadToken(mac);
  // 文件上传
  const res = await formUploader.putFile(uploadToken, key, localPath, putExtra);
  if (!res.data || !res.data.key) {
    logError("Failed uploadLocalFileToQiniuOSS", res);
    throw new Error(`Failed to upload ${localPath} to ${bucket}:${key}`);
  }
  /** @type {ReturnQiniuOSSUploadLocalFile} */
  const returnData = res.data;
  return {
    ...returnData,
    url: `${baseUrl}/${returnData.key}`,
  };
}

/**
 * Normalize path
 * @param {string} filePath
 * @returns {string}
 *
 * @ignore
 */
const normalizePath = (filePath) => {
  return filePath.replace(/\\/g, "/");
};

/**
 * @typedef {Object} ParamsQiniuOSSUploadFileCallbackWithoutError
 * @property {null} err
 * @property {number} curIdx - current index, starting from 0, ranging from 0 to (total - 1)
 * @property {number} total - total count
 * @property {ReturnQiniuOSSUploadLocalFile} file - file info
 */

/**
 * @typedef {Object} ParamsQiniuOSSUploadFileCallbackWithError
 * @property {Error} err
 * @property {number} curIdx - current index, starting from 0, ranging from 0 to (total - 1)
 * @property {number} total - total count
 * @property {null} file - file info
 */

/**
 * @callback FuncQiniuOSSUploadFileCallback
 * @param {ParamsQiniuOSSUploadFileCallbackWithoutError | ParamsQiniuOSSUploadFileCallbackWithError} payload
 * @returns {void}
 */

/**
 * @typedef {Object} ParamsQiniuOSSUploadDir
 * @property {QiniuConfig} config
 * @property {QiniuMac} mac
 * @property {string} bucket
 * @property {string} [baseUrl] - needed if refresh is set true
 * @property {string} [keyPrefix = '']
 * @property {QiniuPutPolicyOptions} [putPolicyOptions = {}]
 * @property {string} localPath
 * @property {string[]} [ignorePathList = []]
 * @property {boolean} [refresh = false]
 * @property {boolean} [recursive = false]
 * @property {boolean} [dryRun = false] - set to true if you want to check which files will be deployed before real deployment
 * @property {FuncQiniuOSSUploadFileCallback} [uploadCallback] - callback function for upload progress
 */

/**
 * @typedef {Object} QiniuOSSLocalPathAndKey
 * @property {string} localPath
 * @property {string} key
 */

/**
 * @typedef {Object} ReturnQiniuOSSUploadDir
 * @property {ReturnQiniuOSSUploadLocalFile[]} uploadedList
 * @property {string[]} refreshedUrlList
 * @property {QiniuOSSLocalPathAndKey[]} allPaths
 */
/**
 * Upload directory to Qiniu OSS
 * @param {ParamsQiniuOSSUploadDir} payload
 * @returns {Promise<ReturnQiniuOSSUploadDir>}
 */
export async function uploadDirToQiniuOSS(payload) {
  const {
    config,
    mac,
    bucket,
    baseUrl = "",
    keyPrefix = "",
    putPolicyOptions = {},
    localPath,
    ignorePathList = [],
    refresh = false,
    recursive = false,
    dryRun = false,
    uploadCallback,
  } = payload;
  const globPath = recursive
    ? path.resolve(localPath, "**/*")
    : path.resolve(localPath, "*");
  const finalIgnorePathList = Array.from(
    new Set([
      "node_modules/**",
      ...(ignorePathList || []).map((tempPath) => tempPath.replace(/\\/g, "/")),
    ]),
  );
  /** @type {import('glob').GlobOptionsWithFileTypesUnset} */
  const globConfig = {
    windowsPathsNoEscape: true,
    // only want the files, not the dirs
    nodir: true,
    ignore: finalIgnorePathList,
  };
  const allFiles = await glob(globPath, globConfig);
  const rootPath = `${normalizePath(path.resolve(localPath))}/`;
  const allPaths = allFiles.map((filePath) => {
    return {
      localPath: normalizePath(filePath),
      key: normalizePath(
        path.join(keyPrefix, normalizePath(filePath).replace(rootPath, "")),
      ),
    };
  });
  if (dryRun) {
    return {
      allPaths,
      uploadedList: [],
      refreshedUrlList: [],
    };
  }

  // 未避免并发数量过大,这里限制并发数量
  const groups = [];
  const maxGroupSize = 500;
  for (let i = 0; i < allPaths.length; i += maxGroupSize) {
    groups.push(allPaths.slice(i, i + maxGroupSize));
  }
  /** @type {ReturnQiniuOSSUploadLocalFile[]} */
  const uploadedList = [];
  const totalCount = allPaths.length;
  let curIdx = 0;
  for (const group of groups) {
    /** @type {Array<ReturnQiniuOSSUploadLocalFile | void>} */
    const list = await Promise.all(
      group.map(({ localPath, key }) => {
        /**
         * @callback FuncReturnPromiseQiniuOSSUploadLocalFile
         * @returns {Promise<ReturnQiniuOSSUploadLocalFile | void>}
         */

        /** @type {FuncReturnPromiseQiniuOSSUploadLocalFile} */
        const funcPromise = async () => {
          let tryTimes = 0;
          const maxTryTimes = 3;
          let tempErr = null;
          while (tryTimes < maxTryTimes) {
            tryTimes++;
            try {
              const fileInfo = await uploadLocalFileToQiniuOSS({
                config,
                mac,
                localPath,
                key,
                baseUrl,
                bucket,
                putPolicyOptions,
              });
              tempErr = null;
              if (typeof uploadCallback === "function") {
                uploadCallback({
                  err: null,
                  curIdx,
                  total: totalCount,
                  file: fileInfo,
                });
              }
              curIdx++;
              return fileInfo;
            } catch (err) {
              tempErr = err;
            }
            await new Promise((resolve) => {
              setTimeout(() => {
                resolve(undefined);
              }, tryTimes * 1000);
            });
          }
          if (typeof uploadCallback === "function") {
            uploadCallback({
              err: getError(tempErr),
              curIdx,
              total: totalCount,
              file: null,
            });
          }
          curIdx++;
        };
        return funcPromise();
      }),
    );
    const successList = list.filter((item) => item !== undefined);
    uploadedList.push(...successList);
  }

  /** @type {string[]} */
  let refreshedUrlList = [];
  if (refresh) {
    const bucketManager = getBucketManagerFromQiniuOSS({
      config,
      mac,
    });
    /** @type {string[]} */
    const downloadUrlList = uploadedList.map((item) => {
      return getPublicDownloadUrlFromQiniuOSS({
        bucketManager,
        key: item.key,
        baseUrl,
      });
    });
    refreshedUrlList = await refreshUrlsFromQiniuOSS({
      mac,
      urls: downloadUrlList,
    });
  }
  return {
    allPaths,
    uploadedList,
    refreshedUrlList,
  };
}