azure.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import { ClientSecretCredential, TokenCredential } from '@azure/identity';
  2. import {
  3. generateBlobSASQueryParameters,
  4. BlobServiceClient,
  5. BlobClient,
  6. BlockBlobClient,
  7. BlobDeleteOptions,
  8. ContainerClient,
  9. ContainerSASPermissions,
  10. SASProtocol,
  11. type BlobDeleteIfExistsResponse,
  12. type BlockBlobUploadResponse,
  13. type BlockBlobParallelUploadOptions,
  14. } from '@azure/storage-blob';
  15. import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
  16. import type { IAttachmentDocument } from '~/server/models';
  17. import loggerFactory from '~/utils/logger';
  18. import { configManager } from '../config-manager';
  19. import {
  20. AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
  21. } from './file-uploader';
  22. import { ContentHeaders } from './utils';
  23. const urljoin = require('url-join');
  24. const logger = loggerFactory('growi:service:fileUploaderAzure');
  25. interface FileMeta {
  26. name: string;
  27. size: number;
  28. }
  29. type AzureConfig = {
  30. accountName: string,
  31. containerName: string,
  32. }
  33. function getAzureConfig(): AzureConfig {
  34. return {
  35. accountName: configManager.getConfig('crowi', 'azure:storageAccountName'),
  36. containerName: configManager.getConfig('crowi', 'azure:storageContainerName'),
  37. };
  38. }
  39. function getCredential(): TokenCredential {
  40. const tenantId = configManager.getConfig('crowi', 'azure:tenantId');
  41. const clientId = configManager.getConfig('crowi', 'azure:clientId');
  42. const clientSecret = configManager.getConfig('crowi', 'azure:clientSecret');
  43. return new ClientSecretCredential(tenantId, clientId, clientSecret);
  44. }
  45. async function getContainerClient(): Promise<ContainerClient> {
  46. const { accountName, containerName } = getAzureConfig();
  47. const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
  48. return blobServiceClient.getContainerClient(containerName);
  49. }
  50. function getFilePathOnStorage(attachment) {
  51. const dirName = (attachment.page != null) ? 'attachment' : 'user';
  52. return urljoin(dirName, attachment.fileName);
  53. }
  54. class AzureFileUploader extends AbstractFileUploader {
  55. /**
  56. * @inheritdoc
  57. */
  58. override isValidUploadSettings(): boolean {
  59. throw new Error('Method not implemented.');
  60. }
  61. /**
  62. * @inheritdoc
  63. */
  64. override listFiles() {
  65. throw new Error('Method not implemented.');
  66. }
  67. /**
  68. * @inheritdoc
  69. */
  70. override saveFile(param: SaveFileParam) {
  71. throw new Error('Method not implemented.');
  72. }
  73. /**
  74. * @inheritdoc
  75. */
  76. override deleteFiles() {
  77. throw new Error('Method not implemented.');
  78. }
  79. /**
  80. * @inheritdoc
  81. */
  82. override determineResponseMode() {
  83. return configManager.getConfig('crowi', 'azure:referenceFileWithRelayMode')
  84. ? ResponseMode.RELAY
  85. : ResponseMode.REDIRECT;
  86. }
  87. /**
  88. * @inheritdoc
  89. */
  90. override respond(): void {
  91. throw new Error('AzureFileUploader does not support ResponseMode.DELEGATE.');
  92. }
  93. /**
  94. * @inheritdoc
  95. */
  96. override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
  97. if (!this.getIsReadable()) {
  98. throw new Error('Azure is not configured.');
  99. }
  100. const filePath = getFilePathOnStorage(attachment);
  101. const containerClient = await getContainerClient();
  102. const blobClient: BlobClient = containerClient.getBlobClient(filePath);
  103. const downloadResponse = await blobClient.download();
  104. if (downloadResponse.errorCode) {
  105. logger.error(downloadResponse.errorCode);
  106. throw new Error(downloadResponse.errorCode);
  107. }
  108. if (!downloadResponse?.readableStreamBody) {
  109. throw new Error(`Coudn't get file from Azure for the Attachment (${filePath})`);
  110. }
  111. return downloadResponse.readableStreamBody;
  112. }
  113. /**
  114. * @inheritDoc
  115. * @see https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
  116. */
  117. override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
  118. if (!this.getIsUploadable()) {
  119. throw new Error('Azure Blob is not configured.');
  120. }
  121. const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'azure:lifetimeSecForTemporaryUrl');
  122. const url = await (async() => {
  123. const containerClient = await getContainerClient();
  124. const filePath = getFilePathOnStorage(attachment);
  125. const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
  126. return blockBlobClient.url;
  127. })();
  128. const sasToken = await (async() => {
  129. const { accountName, containerName } = getAzureConfig();
  130. const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
  131. const now = Date.now();
  132. const startsOn = new Date(now - 30 * 1000);
  133. const expiresOn = new Date(now + lifetimeSecForTemporaryUrl * 1000);
  134. const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
  135. const isDownload = opts?.download ?? false;
  136. const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
  137. // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
  138. // r:read, a:add, c:create, w:write, d:delete, l:list
  139. const containerPermissionsForAnonymousUser = 'rl';
  140. const sasOptions = {
  141. containerName,
  142. permissions: ContainerSASPermissions.parse(containerPermissionsForAnonymousUser),
  143. protocol: SASProtocol.HttpsAndHttp,
  144. startsOn,
  145. expiresOn,
  146. contentType: contentHeaders.contentType?.value.toString(),
  147. contentDisposition: contentHeaders.contentDisposition?.value.toString(),
  148. };
  149. return generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
  150. })();
  151. const signedUrl = `${url}?${sasToken}`;
  152. return {
  153. url: signedUrl,
  154. lifetimeSec: lifetimeSecForTemporaryUrl,
  155. };
  156. }
  157. }
  158. module.exports = (crowi) => {
  159. const lib = new AzureFileUploader(crowi);
  160. lib.isValidUploadSettings = function() {
  161. return configManager.getConfig('crowi', 'azure:storageAccountName') != null
  162. && configManager.getConfig('crowi', 'azure:storageContainerName') != null;
  163. };
  164. (lib as any).deleteFile = async function(attachment) {
  165. const filePath = getFilePathOnStorage(attachment);
  166. const containerClient = await getContainerClient();
  167. const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
  168. const options: BlobDeleteOptions = { deleteSnapshots: 'include' };
  169. const blobDeleteIfExistsResponse: BlobDeleteIfExistsResponse = await blockBlobClient.deleteIfExists(options);
  170. if (!blobDeleteIfExistsResponse.errorCode) {
  171. logger.info(`deleted blob ${filePath}`);
  172. }
  173. };
  174. (lib as any).deleteFiles = async function(attachments) {
  175. if (!lib.getIsUploadable()) {
  176. throw new Error('Azure is not configured.');
  177. }
  178. for await (const attachment of attachments) {
  179. (lib as any).deleteFile(attachment);
  180. }
  181. };
  182. (lib as any).uploadAttachment = async function(readStream, attachment) {
  183. if (!lib.getIsUploadable()) {
  184. throw new Error('Azure is not configured.');
  185. }
  186. logger.debug(`File uploading: fileName=${attachment.fileName}`);
  187. const filePath = getFilePathOnStorage(attachment);
  188. const containerClient = await getContainerClient();
  189. const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
  190. const contentHeaders = new ContentHeaders(attachment);
  191. return blockBlobClient.uploadStream(readStream, undefined, undefined, {
  192. blobHTTPHeaders: {
  193. // put type and the file name for reference information when uploading
  194. blobContentType: contentHeaders.contentType?.value.toString(),
  195. blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
  196. },
  197. });
  198. };
  199. lib.saveFile = async function({ filePath, contentType, data }) {
  200. const containerClient = await getContainerClient();
  201. const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
  202. const options: BlockBlobParallelUploadOptions = {
  203. blobHTTPHeaders: {
  204. blobContentType: contentType,
  205. },
  206. };
  207. const blockBlobUploadResponse: BlockBlobUploadResponse = await blockBlobClient.upload(data, data.length, options);
  208. if (blockBlobUploadResponse.errorCode) { throw new Error(blockBlobUploadResponse.errorCode) }
  209. return;
  210. };
  211. (lib as any).checkLimit = async function(uploadFileSize) {
  212. const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
  213. const gcsTotalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
  214. return lib.doCheckLimit(uploadFileSize, maxFileSize, gcsTotalLimit);
  215. };
  216. (lib as any).listFiles = async function() {
  217. if (!lib.getIsReadable()) {
  218. throw new Error('Azure is not configured.');
  219. }
  220. const files: FileMeta[] = [];
  221. const containerClient = await getContainerClient();
  222. for await (const blob of containerClient.listBlobsFlat({
  223. includeMetadata: false,
  224. includeSnapshots: false,
  225. includeTags: false,
  226. includeVersions: false,
  227. prefix: '',
  228. })) {
  229. files.push(
  230. { name: blob.name, size: blob.properties.contentLength || 0 },
  231. );
  232. }
  233. return files;
  234. };
  235. return lib;
  236. };