Zmodyfikuj poniższy kod aplikacji w Vue.js poprzez dodanie pobierania pliku z wykorzystywaniem FileService. W tym celu powinno się wygenereować 2 sas-linki (z innym content-disposition w metodzie AzureBlobStorageAdapter::GetSasLink). Backend powinien pozostać bez zmian.

/// edit-medical-staff-profile.vue

<template>
  <loading-container :is-loaded="isLoaded" fluid class="mx-0 px-1 pb-12">
    <v-row>
      <v-col>
        <v-form ref="itemForm">
          <v-row justify="space-between">
            <v-col cols="auto">
              <staff-title-input
                v-model="model.title"
                class="mt-2"
                :readonly="readonly"
                :filled="readonly"
                style="max-width: 256px" />
            </v-col>
            <v-col cols="auto">
              <v-checkbox
                v-model="model.refundedPrescriptions"
                label="Wystawia recepty refundowane" />
            </v-col>
          </v-row>

          <markup-input
            v-model="model.summary"
            :readonly="readonly"
            :rows="2"
            label="Opis skrócony" />

          <markup-input
            v-model="model.longSummary"
            class="mt-8"
            :readonly="readonly"
            :rows="6"
            label="Opis pełny" />
        </v-form>
        <single-file-upload
          v-model="model.attachmentData.model"
          :callback="onUpdate"
          accept="image/png, image/jpeg, image/svg+xml"
          :max-file-size="64 * 1024 * 1024"
          class="mt-2"
          :readonly="readonly" />
      </v-col>
      <v-col cols="auto">
        <v-card
          flat
          outlined
          width="300"
          height="350"
          class="d-flex flex-column align-center p-4">
          <v-row class="flex-grow-1" align="center" justify="center">
            <v-col cols="auto">
              <v-img
                v-if="model.avatarUrl"
                :src="model.avatarUrl"
                max-width="250"
                max-height="250"
                contain />
              <span v-else class="text-body-2">Brak zdjęcia profilowego</span>
            </v-col>
          </v-row>
          <v-btn
            v-if="model.avatarUrl"
            color="primary"
            class="mt-4"
            @click="downloadImage">
            Pobierz
          </v-btn>
        </v-card>
      </v-col>
    </v-row>

    <v-row justify="end">
      <v-col cols="auto">
        <submit-discard-async-buttons
          :disabled="!model.isModified"
          :submit="submit"
          :submit-disabled="readonly"
          @discard="discard" />
      </v-col>
    </v-row>
  </loading-container>
</template>

<script lang="ts">
import SubmitDiscardButtons from "@/components/action-buttons/submit-discard-buttons.vue";
import EditDeleteButtons from "@/components/action-buttons/edit-delete-buttons.vue";
import type { PropType } from "@vue/composition-api";
import { computed, defineComponent, inject, onBeforeUpdate, ref, watch } from "@vue/composition-api";
import axios from "@/lib/axios";
import LoadingContainer from "@/components/loading/loading-container.vue";
import type { MedicalStaffData } from "@/views/commerce/medical-staff/medical-staff-data";
import TextLabel from "@/components/simple/text-label.vue";
import SubmitDiscardAsyncButtons from "@/components/action-buttons/submit-discard-async-buttons.vue";
import MarkupInput from "@/components/markup/markup-input.vue";
import SingleFileUpload from "@/components/file-upload/single-file-upload.vue";
import StaffTitleInput from "@/views/commerce/common/staff-title-input.vue";
import Notifier from "@/views/main-wrap/notifier";

const EditMedicalStaffProfile = defineComponent({
  components: {
    StaffTitleInput,
    SingleFileUpload,
    MarkupInput,
    SubmitDiscardAsyncButtons,
    TextLabel,
    LoadingContainer,
    SubmitDiscardButtons,
    EditDeleteButtons
  },
  props: {
    medicalStaffData: {
      type: Object as PropType<MedicalStaffData>,
      required: true
    },
    readonly: {
      type: Boolean,
      required: true
    }
  },

  setup(props, context) {
    const notifier = inject(Notifier.Key) as Notifier;

    const isLoaded = ref(true);
    const itemForm = ref(null);

    const model = ref(props.medicalStaffData.profile);

    onBeforeUpdate(() => {
      itemForm.value = null;
    });

    watch(
      () => props.medicalStaffData,
      (nv, _) => (model.value = nv.profile)
    );

    const canEdit = computed(() => {
      return !props.readonly;
    });

    const discard = () => {
      model.value.discard();
    };

    const onUpdate = async _ => {
      const dto = {
        ...model.value.dto,
        id: props.medicalStaffData.id
      };
      await axios.put("/api/services/app/MedicalStaff/UpdateProfile", dto);
      context.emit("update");
    };

    const submit = async () => {
      if (!(itemForm.value as any).validate()) return;
      const dto = {
        ...model.value.dto,
        id: props.medicalStaffData.id
      };
      await axios.put("/api/services/app/MedicalStaff/UpdateProfile", dto);
      notifier.successToast("Dane zapisane");
      context.emit("update");
    };

    const downloadImage = async () => {
    };

    return {
      submit,
      discard,
      model,
      canEdit,
      isLoaded,
      onUpdate,
      itemForm,
      downloadImage
    };
  }
});
export default EditMedicalStaffProfile;
</script>

<style scoped>
.duration-input {
  min-width: 60px;
  max-width: 60px;
}
</style>

/// FileService.cs

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Mail;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Abp.Collections.Extensions;
using Abp.Configuration;
using Abp.Domain.Repositories;
using Abp.Domain.Uow;
using Abp.Runtime.Caching;
using Abp.Runtime.Session;
using Abp.Timing;
using Hangfire.Console;
using Hangfire.Server;
using Medica.Commerce.DateTimeEnumerator;
using Medica.Common.Extensions;
using Medica.Communication;
using Medica.Configuration;
using Medica.Extensions;
using Medica.Files.Notification;
using Medica.Helpers.Cache;
using Medica.ImageProcessing;
using Medica.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Net.Http.Headers;

namespace Medica.Files;

public class FileService : BaseMedicaService, IFileService
{
    private readonly Lazy<IRepository<FileBucketBase, Guid>> _bucketRepository;
    private readonly Lazy<IRepository<FileEntity, Guid>> _filesRepository;
    private readonly Lazy<ISettingManager> _settingManager;
    private readonly Lazy<IEventHelperService> _eventHelperService;
    private readonly Lazy<IAzureBlobStorageAdapter> _azureBlobStorageAdapter;
    private readonly Lazy<ITypedCache<Guid, (byte[], FileEntityDto)>> _tempCache;
    private readonly Lazy<ICurrentUnitOfWorkProvider> _currentUnitOfWorkProvider;
    private readonly Lazy<IBucketNotificationService> _fileNotificationService;

    public FileService(
        Lazy<IRepository<FileBucketBase, Guid>> bucketRepository,
        Lazy<IRepository<FileEntity, Guid>> filesRepository,
        Lazy<ISettingManager> settingManager,
        Lazy<ICacheManager> cacheManager,
        Lazy<IEventHelperService> eventHelperService,
        Lazy<IAzureBlobStorageAdapter> azureBlobStorageAdapter,
        Lazy<ICurrentUnitOfWorkProvider> currentUnitOfWorkProvider,
        Lazy<IBucketNotificationService> fileNotificationService)
    {
        _bucketRepository = bucketRepository;
        _filesRepository = filesRepository;
        _settingManager = settingManager;
        _eventHelperService = eventHelperService;
        _azureBlobStorageAdapter = azureBlobStorageAdapter;
        _fileNotificationService = fileNotificationService;
        _currentUnitOfWorkProvider = currentUnitOfWorkProvider;
        _tempCache = new Lazy<ITypedCache<Guid, (byte[], FileEntityDto)>>(() => cacheManager.Value.GetCache<Guid, (byte[], FileEntityDto)>("FileServiceTempCache"));
    }

    public Task<FileBucketBase> CreateBucketAsync(FileBucketBase bucket)
    {
        return CreateBucketAsync(bucket, default, BucketProps.General());
    }

    public Task<FileBucketBase> CreateBucketAsync(FileBucketBase bucket, IFormFile file)
    {
        if (file == default)
            throw new ArgumentNullException(nameof(file));

        return CreateBucketAsync(bucket, new[] { file }, BucketProps.General());
    }

    public async Task<FileBucketBase> CreateBucketAsync(FileBucketBase bucket, ICollection<IFormFile> files, BucketProps props, bool compress = true)
    {
        if (bucket.Id != default)
            throw new InvalidOperationException($"Bucket id={bucket.Id} is not default");

        var fileBucket = await _bucketRepository.Value.InsertAsync(bucket);

        if (files.IsNullOrEmpty())
            return fileBucket;

        await _currentUnitOfWorkProvider.Value.Current.SaveChangesAsync();

        return await AddFilesToBucketAsync(fileBucket, files, props, compress);
    }

    public async Task<FileBucketBase> DuplicateBucketAsync(FileBucketBase sourceBucket, FileBucketBase destinationBucket, BucketProps props)
    {
        if (destinationBucket.Id != default)
            throw new InvalidOperationException($"Bucket id={destinationBucket.Id} is not default");

        var fileBucket = await _bucketRepository.Value.InsertAsync(destinationBucket);

        if (sourceBucket.Files.IsNullOrEmpty())
            return fileBucket;

        await _currentUnitOfWorkProvider.Value.Current.SaveChangesAsync();

        return await CopyFilesBetweenBucketsAsync(sourceBucket, fileBucket, props);
    }

    public virtual async Task MigrateAsync(int limit, PerformContext context)
    {
        if (limit < 1 || limit > 1000)
            throw new ArgumentNullException(nameof(limit));

        var files = await _filesRepository
            .Value
            .GetAll()
            .AsNoTracking()
            .Where(v => v.StorageType == StorageType.Local)
            .OrderByDescending(v => v.CreationTime)
            .Take(limit)
            .ToListAsync();

        context.WriteLine($"Migrating {files.Count} files");

        foreach (var file in files)
        {
            var filePath = GetFilePath(file);

            context.WriteLine($"Migrating {filePath}");
            try
            {
                using (var inputStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
                {
                    await UploadFileAsync(inputStream, true, false, file, default);
                }

                file.StorageType = StorageType.Azure;
                await _filesRepository.Value.UpdateAsync(file);
            }
            catch (Exception e)
            {
                context.WriteLine($"Exception while migrating {filePath}. {e.Message}");
                continue;
            }
        }
    }

    public virtual async Task DeleteFileAsync(FileEntity file)
    {
        if (file == null)
            throw new ArgumentNullException(nameof(file));

        await _filesRepository.Value.DeleteAsync(file);

        if (file.StorageType == StorageType.Azure)
        {
            await _azureBlobStorageAdapter.Value.DeleteFileAsync(file.Id);
        }
        else
        {
            var filePath = GetFilePath(file);

            if (File.Exists(filePath))
                File.Delete(filePath);
        }
    }

    public virtual async Task DeleteFileAsync(Guid bucketId, Guid id)
    {
        var file = await _filesRepository.Value.GetAsNoTrackingAsync(id);
        if (file.FileBucketId != bucketId) throw new ArgumentException($"File {id} bucket {file.FileBucketId} does not match argument bucketId: {bucketId}");
        await DeleteFileAsync(file);
        await _currentUnitOfWorkProvider.Value.Current.SaveChangesAsync();
    }

    public virtual async Task DeleteFileBucketAsync(Guid bucketId)
    {
        var bucket = await _bucketRepository
            .Value
            .GetAll()
            .Include(v => v.Files)
            .Where(v => v.Id == bucketId)
            .SingleAsync();

        await DeleteFileBucketAsync(bucket);
    }

    public virtual async Task ClearFileBucketAsync(Guid bucketId)
    {
        var bucket = await _bucketRepository
            .Value
            .GetAll()
            .Include(v => v.Files)
            .Where(v => v.Id == bucketId)
            .SingleAsync();

        await ClearFileBucketAsync(bucket);
    }

    public virtual async Task DeleteFileBucketAsync(FileBucketBase fileBucket)
    {
        if (fileBucket == null)
            throw new ArgumentNullException(nameof(fileBucket));

        await ClearFileBucketAsync(fileBucket);
        await _bucketRepository.Value.DeleteAsync(fileBucket);
    }

    private async Task ClearFileBucketAsync(FileBucketBase fileBucket)
    {
        if (fileBucket == null)
            throw new ArgumentNullException(nameof(fileBucket));

        await _bucketRepository.Value.EnsureCollectionLoadedAsync(fileBucket, v => v.Files);

        foreach (var item in fileBucket.Files)
        {
            await DeleteFileAsync(item);
        }

    }

    private async Task<FileBucketBase> AddFileBiImageToBucketAsync(FileBucketBase bucket, IFormFile file, BucketProps props)
    {
        var isCloudStorageEnabled = await _settingManager.Value.GetSettingValueAsync<bool>(AppSettingsNames.FileStorage.CloudStorageEnabled);
        if (!isCloudStorageEnabled) throw new ArgumentException("Bi images can be uploaded only to azure blob storage");
        if (bucket == null) throw new ArgumentNullException(nameof(bucket));
        if (file == null) throw new ArgumentNullException(nameof(file));
        const StorageType storageType = StorageType.Azure;

        var jpgEntity = new FileEntity()
        {
            ContentType = ContentTypes.Jpg,
            FileBucketId = bucket.Id,
            FileName = Path.ChangeExtension(file.FileName, ".jpg"),
            Length = 0,
            StorageType = storageType
        };
        var webpEntity = new FileEntity()
        {
            ContentType = ContentTypes.Webp,
            FileBucketId = bucket.Id,
            FileName = Path.ChangeExtension(file.FileName, ".webp"),
            Length = 0,
            StorageType = storageType
        };

        await _filesRepository.Value.InsertAndGetIdAsync(jpgEntity);
        await _filesRepository.Value.InsertAndGetIdAsync(webpEntity);

        await using (var inputStream = file.OpenReadStream())
        {
            await using var jpgStream = await ImageConverter.ConvertAsync(inputStream, ImageConversionProps.BiImage(props.Target, ImageFormat.Jpg));
            {
                (await _azureBlobStorageAdapter.Value.SaveFileAsync(jpgStream, jpgEntity.Id, props.IsHot)).OrThrow();
            }
        }

        await using (var inputStream = file.OpenReadStream())
        {
            await using var webpStream = await ImageConverter.ConvertAsync(inputStream, ImageConversionProps.BiImage(props.Target, ImageFormat.WebP));
            {
                (await _azureBlobStorageAdapter.Value.SaveFileAsync(webpStream, webpEntity.Id, props.IsHot)).OrThrow();
            }
        }

        return await HandleBucketUpdate(bucket.Id);
    }

    private async Task<FileBucketBase> AddFileToBucketAsync(FileBucketBase bucket, IFormFile file, bool encrypt, bool isHot, bool compress = true)
    {
        var isCloudStorageEnabled = await _settingManager.Value.GetSettingValueAsync<bool>(AppSettingsNames.FileStorage.CloudStorageEnabled);
        if (bucket == null)
            throw new ArgumentNullException(nameof(bucket));

        if (file == null)
            throw new ArgumentNullException(nameof(file));

        var aesCredentialsOpt = encrypt ? AesCredentials.Generate() : null;

        var fileEntity = new FileEntity()
        {
            ContentType = file.ContentType,
            FileBucketId = bucket.Id,
            FileName = file.FileName,
            Length = file.Length,
            AesCredentials = aesCredentialsOpt?.ToString(),
            StorageType = isCloudStorageEnabled ? StorageType.Azure : StorageType.Local
        };

        var fileEntityId = await _filesRepository.Value.InsertAndGetIdAsync(fileEntity);
        await using (var inputStream = file.OpenReadStream())
        {
            if (aesCredentialsOpt is { } aesCredentials)
            {
                using var aesAlg = aesCredentials.AesInstance();
                var encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);

                using var msEncrypt = new MemoryStream();
                await using var csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write);

                await inputStream.CopyToAsync(csEncrypt);
                csEncrypt.Close();
                using var eStream = new MemoryStream(msEncrypt.ToArray());
                await UploadFileAsync(eStream, isCloudStorageEnabled, isHot, fileEntity, bucket.Id, compress);
            }
            else
            {
                await UploadFileAsync(inputStream, isCloudStorageEnabled, isHot, fileEntity, bucket.Id, compress);
            }
        }

        return await HandleBucketUpdate(bucket.Id);
    }

    private async Task<FileBucketBase> CopyFileToBucketAsync(FileBucketBase bucket, FileEntity sourceFile)
    {
        var isCloudStorageEnabled = await _settingManager.Value.GetSettingValueAsync<bool>(AppSettingsNames.FileStorage.CloudStorageEnabled);

        if (bucket == null)
            throw new ArgumentNullException(nameof(bucket));

        if (sourceFile == null)
            throw new ArgumentNullException(nameof(sourceFile));

        var storageType = isCloudStorageEnabled && sourceFile.StorageType == StorageType.Azure ? StorageType.Azure : StorageType.Local;

        var fileEntity = new FileEntity()
        {
            ContentType = sourceFile.ContentType,
            FileBucketId = bucket.Id,
            FileName = sourceFile.FileName,
            Length = sourceFile.Length,
            AesCredentials = sourceFile.AesCredentials,
            StorageType = storageType
        };

        var fileEntityId = await _filesRepository.Value.InsertAndGetIdAsync(fileEntity);

        return await CopyFileAsync(isCloudStorageEnabled, sourceFile, fileEntity, bucket.Id);
    }

    private async Task<FileBucketBase> HandleBucketUpdate(Guid bucketId)
    {
        await _currentUnitOfWorkProvider.Value.Current.SaveChangesAsync();
        var bucket = await _bucketRepository
            .Value
            .GetAll()
            .AsNoTracking()
            .Where(v => v.Id == bucketId)
            .Include(v => v.Files)
            .FirstAsync();


        var dto = GetFileBucketDto(bucket);
        await _eventHelperService.Value.NewFileBucketChangedEventAsync(MedicaSession.GetTenantId(), dto);

        if (MedicaSession.UserId.HasValue)
            await _fileNotificationService.Value.NewFileBucketEventAsync(bucketId, MedicaSession.GetUserId(), bucket.Variant);

        return bucket;
    }

    public async Task<FileEntity> UploadFile(byte[] content, string contentType, string name, Guid bucketId, bool isHot)
    {
        var isCloudStorageEnabled = await _settingManager.Value.GetSettingValueAsync<bool>(AppSettingsNames.FileStorage.CloudStorageEnabled);

        if (content == null) throw new ArgumentNullException(nameof(content));
        var fileEntity = new FileEntity()
        {
            ContentType = contentType,
            FileBucketId = bucketId,
            FileName = name,
            Length = content.Length,
            StorageType = isCloudStorageEnabled ? StorageType.Azure : StorageType.Local
        };
        await _filesRepository.Value.InsertAndGetIdAsync(fileEntity);
        await using var inputStream = new MemoryStream(content);
        await UploadFileAsync(inputStream, isCloudStorageEnabled, isHot, fileEntity, bucketId);
        await _currentUnitOfWorkProvider.Value.Current.SaveChangesAsync();
        return fileEntity;
    }

    private async Task<FileBucketBase> CopyFileAsync(bool isCloudStorageEnabled, FileEntity sourceFileEntity, FileEntity destinationFileEntity, Guid bucketId)
    {
        if (isCloudStorageEnabled)
        {
            (await _azureBlobStorageAdapter.Value.CopyAsync(sourceFileEntity.Id, destinationFileEntity.Id)).OrThrow();
        }
        else
        {
            var sourceFilePath = GetFilePath(sourceFileEntity);
            await using var sourceFileStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read);

            var destinationDirectoryPath = GetBucketDirectoryPath(bucketId);
            var destinationFilePath = GetFilePath(destinationFileEntity);
            Directory.CreateDirectory(destinationDirectoryPath);
            await using var destinationFileStream = new FileStream(destinationFilePath, FileMode.Create, FileAccess.Write);

            await sourceFileStream.CopyToAsync(destinationFileStream);
        }

        return await HandleBucketUpdate(bucketId);
    }

    private async Task UploadFileAsync(Stream inputStream, bool isCloudStorageEnabled, bool isHot, FileEntity fileEntity, Guid bucketId, bool compress = true)
    {
        var shouldOptimize = ContentTypes.IsImage(fileEntity.ContentType);

        if (shouldOptimize)
        {
            fileEntity.ContentType = ContentTypes.Jpg;
            fileEntity.FileName = Path.ChangeExtension(fileEntity.FileName, ".jpg");
        }


        const int maxSize200dpi = 2339;
        var optimizedStream = shouldOptimize && compress
            ? await ImageConverter.ConvertAsync(inputStream, ImageConversionProps.Attachment(maxSize200dpi))
            : inputStream;

        try
        {
            if (isCloudStorageEnabled)
            {
                (await _azureBlobStorageAdapter.Value.SaveFileAsync(optimizedStream, fileEntity.Id, isHot)).OrThrow();
            }
            else
            {
                var dirPath = GetBucketDirectoryPath(bucketId);
                var filePath = GetFilePath(fileEntity);
                Directory.CreateDirectory(dirPath);
                using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write);
                await optimizedStream.CopyToAsync(fileStream);
            }
        }
        finally
        {
            if (shouldOptimize)
                optimizedStream?.Dispose();
        }
    }

    public virtual async Task<FileBucketBase> AddFilesToBucketAsync(FileBucketBase bucket, ICollection<IFormFile> files, BucketProps props, bool compress = true)
    {
        if (bucket == null)
            throw new ArgumentNullException(nameof(bucket));

        if (props.BiImage)
        {
            if (files.FirstIfSingle() is not { } imageFile) throw new ArgumentException("Bi image input must contain single file");
            var contentType = imageFile.ContentType;
            if (contentType is not (ContentTypes.Jpg or ContentTypes.Png or ContentTypes.Webp)) throw new ArgumentException($"Bi image input must be of image content type: {contentType} ");
            bucket = await AddFileBiImageToBucketAsync(bucket, imageFile, props);
        }
        else
        {
            foreach (var file in files)
            {
                bucket = await AddFileToBucketAsync(bucket, file, props.Encrypt, props.IsHot, compress);
            }
        }


        await _currentUnitOfWorkProvider.Value.Current.SaveChangesAsync();

        if (bucket is SurveyFileBucket surveyFileBucket)
        {
            var tenantId = MedicaSession.GetTenantId();
            await _eventHelperService.Value.NewSurveyFileBucketsChangedEventAsync(tenantId, surveyFileBucket.EntityType, surveyFileBucket.EntityId);
        }

        return bucket;
    }


    private async Task<FileBucketBase> CopyFilesBetweenBucketsAsync(FileBucketBase sourceBucket, FileBucketBase destinationBucket, BucketProps props)
    {
        if (sourceBucket == null)
            throw new ArgumentNullException(nameof(sourceBucket));

        if (props.BiImage)
        {
            throw new NotImplementedException("Copying BiImage not yet supported");
        }
        else
        {
            foreach (var file in sourceBucket.Files)
            {
                destinationBucket = await CopyFileToBucketAsync(destinationBucket, file);
            }
        }

        await _currentUnitOfWorkProvider.Value.Current.SaveChangesAsync();

        if (destinationBucket is SurveyFileBucket surveyFileBucket)
        {
            var tenantId = MedicaSession.GetTenantId();
            await _eventHelperService.Value.NewSurveyFileBucketsChangedEventAsync(tenantId, surveyFileBucket.EntityType, surveyFileBucket.EntityId);
        }

        return destinationBucket;
    }

    public Task<FileBucketBase> GetFileBucketAsync(Guid bucketId)
    {
        return _bucketRepository
            .Value
            .GetAll()
            .AsNoTracking()
            .Where(v => v.Id == bucketId)
            .Include(v => v.Files)
            .SingleOrDefaultAsync();
    }

    public async Task<(Stream, FileEntity)> GetFileAsync(Guid id, DateTime exp, string key)
    {
        var file = await GetFileEntityAsync(id);

        var hash = MakeExpiryHash(file.FileBucketId, exp);

        if (key != hash)
            throw new AuthenticationException("Invalid key");

        if (file.StorageType != StorageType.Local)
            throw new ArgumentException("Requested file is not stored locally");

        var filePath = Path.Combine(Directory.GetCurrentDirectory(), "files", file.FileBucketId.ToString(), id.ToString());

        var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);

        return (stream, file);
    }

    public async Task<Uri> GetFileSasLinkAsync(Guid id)
    {
        var file = await GetFileEntityAsync(id);

        if (file.StorageType != StorageType.Azure)
            throw new ArgumentException("Requested file is not stored locally");

        return (_azureBlobStorageAdapter.Value.GetSasLink(file)).OrThrow();
    }

    public async Task<DisposableCollection<Attachment>> GetAsAttachments(IEnumerable<Guid> ids)
    {
        var list = new List<Attachment>();
        foreach (var id in ids)
        {
            if (_tempCache.Value.TryGetValue(id, out var cached))
            {
                var cachedStream = new MemoryStream(cached.Item1);
                var dto = cached.Item2;
                list.Add(new Attachment(cachedStream, dto.FileName, dto.ContentType) { ContentId = id.ToString() });
                continue;
            }

            var entity = await GetFileEntityAsync(id);
            if (entity.StorageType != StorageType.Azure) throw new ArgumentException("Only Azure blobs can be used as attachments");
            var stream = new MemoryStream();
            await _azureBlobStorageAdapter.Value.DownloadAsync(id, stream);
            list.Add(new Attachment(stream, entity.FileName, entity.ContentType) { ContentId = id.ToString() });
        }

        return DisposableCollection.Wrap(list);
    }

    public async Task<Stream> GetStream(Guid id)
    {
        var entity = await GetFileEntityAsync(id);

        var stream = new MemoryStream();
        await _azureBlobStorageAdapter.Value.DownloadAsync(id, stream);
        stream.Position = 0;

        if (entity.AesCredentials is { } aesCredentialsStr)
        {
            using var aesAlg = AesCredentials.FromString(aesCredentialsStr).AesInstance();
            var decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);
            return new CryptoStream(stream, decryptor, CryptoStreamMode.Read);
        }

        return stream;
    }

    private Task<FileEntity> GetFileEntityAsync(Guid id)
    {
        return _filesRepository
            .Value
            .GetAll()
            .AsNoTracking()
            .SingleAsync(v => v.Id == id);
    }

    public FileBucketDto GetFileBucketDto(FileBucketBase bucket, TimeSpan? validity = null)
    {
        if (bucket == null)
            return null;

        var bucketDto = new FileBucketDto()
        {
            Name = bucket.Name,
            CreationTime = bucket.CreationTime,
            Details = bucket.Details,
            Id = bucket.Id,
            Files = new List<FileEntityDto>()
        };

        var domain = _settingManager.Value.GetSettingValue(AppSettingsNames.App.Domain);

        if (!validity.HasValue)
            validity = TimeSpan.FromHours(1);

        var expires = validity.Value == TimeSpan.MaxValue ? DateTime.MaxValue : Clock.Now + validity.Value;
        var hash = MakeExpiryHash(bucket.Id, expires);
        var qExp = expires.ToString("s");

        if (!bucket.Files.IsNullOrEmpty())
        {
            foreach (var file in bucket.Files.OrderBy(v => v.CreationTime))
            {
                var query = new QueryBuilder
                {
                    { "id", file.Id.ToString() },
                    { "exp", qExp },
                    { "key", hash }
                };

                var fileDto = new FileEntityDto()
                {
                    ContentType = file.ContentType,
                    CreationTime = file.CreationTime,
                    FileName = file.FileName,
                    Id = file.Id,
                    Length = file.Length,
                    IsEncrypted = file.AesCredentials != null,
                    Url = file.StorageType == StorageType.Azure
                        ? _azureBlobStorageAdapter.Value.GetSasLink(file, validity).OrThrow().ToString()
                        : $"{domain}/api/Files/Get{query.ToQueryString()}"
                };

                bucketDto.Files.Add(fileDto);
            }
        }

        return bucketDto;
    }


    public FileBucketDto GetFileBucketDto(IMayHaveFiles entity, TimeSpan? validity = null)
    {
        if (!entity.FileBucketId.HasValue)
            return null;

        var bucket = entity.FileBucket;

        if (entity.FileBucket == null || entity.FileBucket.Files == null)
        {
            bucket = _bucketRepository
                .Value
                .GetAll()
                .OfType<FileBucket>()
                .AsNoTracking()
                .Where(v => v.Id == entity.FileBucketId.Value)
                .Include(v => v.Files)
                .SingleOrDefault();
        }

        return GetFileBucketDto(bucket);
    }

    public virtual async Task<FileBucketDto> GetFileBucketDto(Guid bucketId, TimeSpan? validity = null)
    {
        var bucket = await _bucketRepository
            .Value
            .GetAll()
            .AsNoTracking()
            .Where(v => v.Id == bucketId)
            .Include(v => v.Files)
            .SingleOrDefaultAsync();

        if (bucket == null)
            return null;

        return GetFileBucketDto(bucket, validity);
    }

    private static string MakeExpiryHash(Guid bucketId, DateTime expiry)
    {
        return Base64UrlTextEncoder.Encode(SHA1.HashData(
            Encoding.UTF8.GetBytes($"{bucketId}:{expiry:s}:{bucketId}")));
    }

    public virtual async Task AssignFileToEntity(IMayHaveFiles entity, Guid? id)
    {
        //TODO is it a bug? (entity.FileBucketId == id)
        if (entity.FileBucketId == id || !id.HasValue)
            return;

        if (entity.FileBucketId.HasValue)
        {
            var bucket = await _bucketRepository
                .Value
                .GetAll()
                .AsNoTracking()
                .SingleAsync(v => v.Id == entity.FileBucketId.Value);

            var tempBucket = await _bucketRepository
                .Value
                .GetAll()
                .Include(v => v.Files)
                .SingleAsync(v => v.Id == id.Value);

            foreach (var file in tempBucket.Files)
            {
                file.FileBucketId = bucket.Id;
            }

            tempBucket.Orphaned = true;
        }
        else if (id.HasValue)
        {
            entity.FileBucketId = id;

            await _bucketRepository
                .Value
                .GetAll()
                .Where(v => v.Id == id.Value)
                .ForEachAsync(v => v.Orphaned = false);
        }
    }

    public async Task SetOrphanedAsync(Guid? fileBucketId, bool orphaned)
    {
        await _bucketRepository
            .Value
            .GetAll()
            .Where(v => v.Id == fileBucketId.Value)
            .ForEachAsync(v => v.Orphaned = orphaned);
    }

    public async Task<FileEntityDto> SetTemporaryFileAsync(byte[] bytes, string contentType, int validityTimeSeconds)
    {
        var guid = Guid.NewGuid();
        var domain = _settingManager.Value.GetSettingValue(AppSettingsNames.App.Domain);
        var creationTime = Clock.Now;
        var dto = new FileEntityDto()
        {
            Id = guid,
            ContentType = contentType,
            CreationTime = creationTime,
            Length = bytes.Length,
            Url = $"{domain}/api/Files/GetTemporary?id={guid}",
            FileName = $"{creationTime:yyyyMMddHHmmss}_{guid}.{ContentTypeToExtension(contentType)}",
        };

        await _tempCache.Value.SetAsync(guid, (bytes, dto), absoluteExpireTime: new DateTimeOffset(Clock.Now.AddSeconds(validityTimeSeconds)));

        return dto;
    }

    private class InlineFileContentResult : FileContentResult
    {
        public InlineFileContentResult(byte[] fileContents, string contentType)
            : base(fileContents, contentType)
        {
        }

        public override Task ExecuteResultAsync(ActionContext context)
        {
            var contentDispositionHeader = new ContentDispositionHeaderValue("inline");
            contentDispositionHeader.SetHttpFileName(FileDownloadName);
            context.HttpContext.Response.Headers.Add(HeaderNames.ContentDisposition, contentDispositionHeader.ToString());
            FileDownloadName = string.Empty;
            return base.ExecuteResultAsync(context);
        }
    }

    public async Task<FileContentResult> GetTemporaryFileAsync(Guid guid, bool inline)
    {
        var found = await _tempCache.Value.GetOrDefaultAsync(guid);

        if (found == default)
            throw new KeyNotFoundException(guid.ToString());

        var dto = found.Item2;

        return inline
            ? new InlineFileContentResult(found.Item1, dto.ContentType)
            : new FileContentResult(found.Item1, dto.ContentType)
            {
                FileDownloadName = dto.FileName,
                LastModified = dto.CreationTime
            };
    }

    private static string GetFilePath(FileEntity file)
    {
        return Path.Combine(GetBucketDirectoryPath(file.FileBucketId), file.Id.ToString());
    }

    private static string GetBucketDirectoryPath(Guid bucketId)
    {
        return Path.Combine(Directory.GetCurrentDirectory(), "files", bucketId.ToString());
    }

    private static string ContentTypeToExtension(string contentType)
    {
        return contentType switch
        {
            ContentTypes.Pdf => "pdf",
            ContentTypes.Png => "",
            ContentTypes.Jpg => "jpg",
            ContentTypes.Doc => "doc",
            ContentTypes.Docx => "docx",
            ContentTypes.Xls => "xls",
            ContentTypes.Xlsx => "xlsx",
            ContentTypes.Csv => "csv",
            ContentTypes.Rtf => "rtf",
            ContentTypes.Txt => "txt",
            ContentTypes.Odt => "odt",
            ContentTypes.Ods => "ods",
            _ => "download",
        };
    }

    public async Task<Either<T>> SetBucketForNewEntity<T>(T targetEntity) where T : IMayHaveFiles
    {
        try
        {
            var bucketId = targetEntity.FileBucketId;
            targetEntity.FileBucketId = null;
            await AssignFileToEntity(targetEntity, bucketId);
            return Either.Success(targetEntity);
        }
        catch (Exception e)
        {
            return Either<T>.InternalError(e);
        }
    }

    public async Task<Either<T>> SetBucketUponUpdate<T>(T targetEntity, Guid? id, bool allowDelete = false) where T : IMayHaveFiles
    {
        if (targetEntity.FileBucketId == id)
        {
            return targetEntity.AsSuccess();
        }

        if (targetEntity.FileBucketId != null && id != null)
        {
            return Either<T>.InternalError($"Attempt to change bucket id {targetEntity.FileBucketId} -> {id}. {typeof(T)}");
        }

        try
        {
            if (targetEntity.FileBucketId != null && id == null)
            {
                if (!allowDelete)
                    return Either<T>.InternalError($"Attempt to delete bucket id {targetEntity.FileBucketId}. {typeof(T)}");

                var bucketEntity = await _bucketRepository
                    .Value
                    .GetAll()
                    .Include(v => v.Files)
                    .Where(v => v.Id == targetEntity.FileBucketId)
                    .SingleAsync();

                await DeleteFileBucketAsync(bucketEntity);
                targetEntity.FileBucketId = null;
                return Either.Success(targetEntity);
            }

            await AssignFileToEntity(targetEntity, id);
            return Either.Success(targetEntity);
        }
        catch (Exception e)
        {
            return Either<T>.InternalError(e);
        }
    }
}

/// AzureBlobStorageAdapter.cs

using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using System.Web;
using Abp.Configuration;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Blobs.Specialized;
using Azure.Storage.Sas;
using Medica.Configuration;
using ServiceStack;

namespace Medica.Files;

public class AzureBlobStorageAdapter : IAzureBlobStorageAdapter
{
    private readonly ISettingManager _settingManager;
    private readonly BlobServiceClient _blobServiceClient;

    public AzureBlobStorageAdapter(ISettingManager settingManager, BlobServiceClient blobServiceClient)
    {
        _settingManager = settingManager;
        _blobServiceClient = blobServiceClient;
    }

    public async Task<Either> SaveFileAsync(Stream stream, Guid id, bool isHot)
    {
        try
        {
            var blobClient = GetBlobClient(id);
            await using (stream)
            {
                await blobClient.UploadAsync(stream, new BlobUploadOptions()
                {
                    AccessTier = isHot ? AccessTier.Hot : AccessTier.Cold,
                    // https://gist.github.com/xiaomi7732/ab1d403ef497047fcb4d419e1c64f5cb
                    // By default, there will be condition to prevent overwrite.
                    // Set the conditions to null so that overwrite will be allowed.
                    Conditions = null,
                });
            }

            return Either.Success();
        }
        catch (Exception e)
        {
            return Either.InternalError(e);
        }
    }

    public async Task DownloadAsync(Guid id, Stream target)
    {
        var blobClient = GetBlobClient(id);
        await blobClient.DownloadToAsync(target);
    }

    public async Task<Either> CopyAsync(Guid sourceId, Guid targetId)
    {
        var sourceBlobClient = GetBlobClient(sourceId);
        var targetBlobClient = GetBlobClient(targetId);
        var operation = await targetBlobClient.StartCopyFromUriAsync(sourceBlobClient.Uri);
        var response = await operation.WaitForCompletionResponseAsync();
        if (response.Status != (int)HttpStatusCode.OK)
        {
            return Either.InternalError("Copying failed: " + response.Status);
        }
        return Either.Success();
    }

    public async Task<Either> DeleteFileAsync(Guid id)
    {
        try
        {
            var blobClient = GetBlobClient(id);
            await blobClient.DeleteIfExistsAsync(DeleteSnapshotsOption.IncludeSnapshots);
            return Either.Success();
        }
        catch (Exception e)
        {
            return Either.InternalError(e);
        }
    }

    private BlobClient GetBlobClient(Guid id)
    {
        var containerName = GetContainerName();
        var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
        var blobClient = containerClient.GetBlobClient(id.ToString());
        return blobClient;
    }

    private string GetContainerName()
    {
        return _settingManager.GetSettingValue(AppSettingsNames.FileStorage.CloudStorageContainerName);
    }

    public Either<Uri> GetSasLink(FileEntity fileEntity, TimeSpan? validity = null)
    {
        try
        {
            var validitySeconds = validity?.TotalSeconds ?? 600;
            var blobClient = GetBlobClient(fileEntity.Id);
            if (blobClient.CanGenerateSasUri)
            {
                var sanitizedFileName = fileEntity.ContentType == ContentTypes.Pdf &&
                                        fileEntity.FileName is { } rawFileName &&
                                        !rawFileName.EndsWithIgnoreCase(".pdf")
                    ? $"{rawFileName}.pdf"
                    : fileEntity.FileName;
                var sasBuilder = new BlobSasBuilder
                {
                    BlobContainerName = blobClient.GetParentBlobContainerClient().Name,
                    BlobName = blobClient.Name,
                    Resource = "b",
                    ContentDisposition = "inline; filename=" + HttpUtility.UrlEncode(sanitizedFileName),
                    ContentType = fileEntity.ContentType,
                    ExpiresOn = DateTimeOffset.UtcNow.AddSeconds(validitySeconds),
                };

                sasBuilder.SetPermissions(BlobSasPermissions.Read);

                return blobClient.GenerateSasUri(sasBuilder).AsSuccess();
            }

            return Either<Uri>.InternalError("BlobClient must be authorized with Shared Key credentials to create a service SAS.");
        }
        catch (Exception e)
        {
            return Either<Uri>.InternalError(e);
        }
    }
}

/// medical-staff-profile.vue

import { GenericEntity } from "@/views/commerce/common/named-item";
import type { IFileBucket } from "@/store/entities/file";
import AttachmentData from "@/views/commerce/common/attachment-data";

export default class MedicalStaffProfile extends GenericEntity {
  title: number | null = null;
  summary: string | null = null;
  longSummary: string | null = null;
  refundedPrescriptions = false;
  attachmentData: AttachmentData = AttachmentData.empty();
  fileBucket: IFileBucket | null;
  avatarUrl: string | null = null;

  public static fromDto(dto: any): MedicalStaffProfile {
    const entity = new MedicalStaffProfile();
    entity.readDtoWrap(dto);
    return entity;
  }

  public static empty(): MedicalStaffProfile {
    return new MedicalStaffProfile();
  }

  protected readDto(dto: any) {
    super.readDto(dto);
    this.title = dto.title;
    this.avatarUrl = dto.avatarUrl;
    this.summary = dto.summary;
    this.longSummary = dto.longSummary;
    this.refundedPrescriptions = dto.refundedPrescriptions;
    this.attachmentData = AttachmentData.fromDto(dto);
  }

  get dto(): any {
    return {
      title: this.title,
      summary: this.summary,
      longSummary: this.longSummary,
      refundedPrescriptions: this.refundedPrescriptions,
      ...this.attachmentData.requestDto
    };
  }
}

/// attachment-data.ts

import type { IFileBucket, IFileEntity } from "@/store/entities/file";
import { GenericEntity } from "@/views/commerce/common/named-item";

export default class AttachmentData extends GenericEntity {
  removeAttachments = false;
  fileBucketId: string | null;
  fileBucket: IFileBucket | null;

  public static fromDto(dto: any): AttachmentData {
    const entity = new AttachmentData();
    entity.readDtoWrap(dto);
    return entity;
  }

  public static empty(): AttachmentData {
    return new AttachmentData();
  }

  private static toFileBucketDto(val: IFileBucket) {
    return {
      files: val.files ? val.files.map(AttachmentData.toFileBucketFileDto) : null,
      creationTime: val.creationTime,
      id: val.id
    };
  }

  private static toFileBucketFileDto(val: IFileEntity) {
    return {
      url: val.url,
      contentType: val.contentType,
      length: val.length,
      fileName: val.fileName,
      isEncrypted: val.isEncrypted,
      creationTime: val.creationTime,
      id: val.id
    };
  }

  private static fromFileBucketFileDto(dto: any): IFileEntity {
    return {
      url: dto.url,
      contentType: dto.contentType,
      length: dto.length,
      fileName: dto.fileName,
      isEncrypted: dto.isEncrypted,
      creationTime: dto.creationTime,
      id: dto.id
    } as IFileEntity;
  }

  private static fromFileBucketDto(dto: any): IFileBucket {
    return {
      files: dto.files ? dto.files.map(AttachmentData.fromFileBucketFileDto) : null,
      creationTime: dto.creationTime,
      id: dto.id
    } as IFileBucket;
  }

  protected readDto(dto: any) {
    super.readDto(dto);
    if (dto.removeAttachments === true || dto.removeAttachments === false)
      this.removeAttachments = dto.removeAttachments;
    this.fileBucketId = dto.fileBucketId;
    if (dto.fileBucket) this.fileBucket = AttachmentData.fromFileBucketDto(dto.fileBucket);
  }

  get dto(): any {
    return {
      removeAttachments: this.removeAttachments,
      fileBucketId: this.fileBucketId,
      fileBucket: this.fileBucket ? AttachmentData.toFileBucketDto(this.fileBucket) : null
    };
  }

  get requestDto() {
    return {
      fileBucketId: this.removeAttachments ? null : this.fileBucketId
    };
  }
}


/// file.ts

export interface IFileBucket {
  category?: string;
  description?: string;
  files?: IFileEntity[] | null;
  creationTime: string;
  name: string | null;
  id: string | null;
}

export interface IFileEntity {
  url: string;
  contentType?: string;
  length: number;
  fileName: string;
  isEncrypted: boolean;
  creationTime: string;
  id: string | null;
}

export interface IMayHaveFileBucketId {
  fileBucketId: string | null;
}

export enum BucketVariant {
  Default = 0,
  Task = 5
}