Initial commit
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages user profile photos stored in Azure Blob Storage.
|
||||
/// Photos are stored in the <c>profileimages</c> container under the
|
||||
/// deterministic path <c>{companyId}/profile-photos/{userId}{ext}</c>
|
||||
/// (e.g. <c>7/profile-photos/abc-guid.png</c>).
|
||||
/// <para>
|
||||
/// A deterministic path (keyed by userId rather than a random GUID) is used
|
||||
/// here intentionally — unlike job photos, profile photos are accessed by
|
||||
/// user ID from multiple places (nav bar, worker cards) and a predictable
|
||||
/// path avoids the need to store it in an additional DB column. Access is
|
||||
/// still protected: the <c>/Profile/Photo</c> endpoint validates that the
|
||||
/// requesting user is authenticated before streaming the image.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ProfilePhotoService : IProfilePhotoService
|
||||
{
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
private readonly StorageSettings _settings;
|
||||
private readonly ILogger<ProfilePhotoService> _logger;
|
||||
|
||||
/// <summary>Image extensions accepted for profile photos.</summary>
|
||||
private static readonly string[] AllowedImageTypes = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||||
|
||||
/// <summary>Maximum profile photo size accepted on upload (10 MB).</summary>
|
||||
private const long MaxPhotoSize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the service with the blob storage provider, storage
|
||||
/// configuration, and a logger for upload audit messages.
|
||||
/// </summary>
|
||||
public ProfilePhotoService(
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> settings,
|
||||
ILogger<ProfilePhotoService> logger)
|
||||
{
|
||||
_blobService = blobService;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and uploads a user profile photo, replacing any existing photo
|
||||
/// regardless of its extension. Old blobs at alternative extensions are
|
||||
/// deleted first (via <see cref="DeleteOldPhotosForUserAsync"/>) so a user
|
||||
/// never has multiple active photos in the container.
|
||||
/// The blob path mirrors the former filesystem path to simplify the migration
|
||||
/// from local storage to Azure without requiring a database schema change.
|
||||
/// </summary>
|
||||
/// <param name="file">The uploaded image file from the HTTP request.</param>
|
||||
/// <param name="userId">The ASP.NET Identity user ID (GUID string).</param>
|
||||
/// <param name="companyId">The tenant company's database ID (for path scoping).</param>
|
||||
/// <returns>
|
||||
/// A tuple with a success flag, the stored blob path (on success), and a
|
||||
/// human-readable error message (on failure).
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveProfilePhotoAsync(
|
||||
IFormFile file,
|
||||
string userId,
|
||||
int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file was uploaded.");
|
||||
|
||||
if (file.Length > MaxPhotoSize)
|
||||
return (false, string.Empty, "Photo must be smaller than 10 MB.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
|
||||
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
|
||||
|
||||
// Delete old photos for this user with different extensions
|
||||
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
|
||||
|
||||
// Blob path mirrors former filesystem path
|
||||
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
|
||||
|
||||
if (!result.Success)
|
||||
return (false, string.Empty, result.ErrorMessage);
|
||||
|
||||
_logger.LogInformation("Profile photo saved: {BlobName} for user {UserId}", blobName, userId);
|
||||
return (true, blobName, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the profile photo blob at the given path from Azure Blob Storage.
|
||||
/// Called when a user removes their avatar from the Profile settings page.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path previously returned by <see cref="SaveProfilePhotoAsync"/>.</param>
|
||||
/// <returns>Success flag and an error message on failure.</returns>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteProfilePhotoAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return (false, "File path is required.");
|
||||
|
||||
return await _blobService.DeleteAsync(_settings.Containers.ProfileImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the raw bytes of a profile photo for serving through the
|
||||
/// <c>/Profile/Photo</c> controller endpoint, which enforces authentication
|
||||
/// before streaming the image to the client.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path of the photo.</param>
|
||||
/// <returns>
|
||||
/// A tuple with a success flag, the raw file bytes, the MIME content type,
|
||||
/// and an error message on failure.
|
||||
/// </returns>
|
||||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetProfilePhotoAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
|
||||
|
||||
return await _blobService.DownloadAsync(_settings.Containers.ProfileImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a profile photo blob exists without downloading its content.
|
||||
/// Used by the nav bar and worker card views to decide whether to show the
|
||||
/// user's avatar or a generic placeholder icon.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path to check.</param>
|
||||
/// <returns><c>true</c> if the blob exists; otherwise <c>false</c>.</returns>
|
||||
public async Task<bool> ProfilePhotoExistsAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return false;
|
||||
|
||||
return await _blobService.ExistsAsync(_settings.Containers.ProfileImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to delete any pre-existing profile photo blobs for the user at
|
||||
/// extensions other than <paramref name="currentExtension"/> so that uploading
|
||||
/// a new PNG replaces an old JPG (and vice versa).
|
||||
/// Errors are caught and logged as warnings rather than propagated because a
|
||||
/// missing old blob is not a blocking failure — the new photo should still be
|
||||
/// saved successfully.
|
||||
/// </summary>
|
||||
/// <param name="companyId">The tenant company's database ID.</param>
|
||||
/// <param name="userId">The ASP.NET Identity user ID.</param>
|
||||
/// <param name="currentExtension">
|
||||
/// Extension of the incoming upload; blobs with this extension are skipped
|
||||
/// because they will be overwritten immediately afterwards.
|
||||
/// </param>
|
||||
private async Task DeleteOldPhotosForUserAsync(int companyId, string userId, string currentExtension)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var ext in AllowedImageTypes)
|
||||
{
|
||||
if (ext == currentExtension) continue;
|
||||
|
||||
var oldBlobName = $"{companyId}/profile-photos/{userId}{ext}";
|
||||
await _blobService.DeleteAsync(_settings.Containers.ProfileImages, oldBlobName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error deleting old profile photos for user {UserId}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
|
||||
/// allowed extensions are image types and browsers will render them correctly.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user