using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PowderCoating.Application.Configuration; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Enums; namespace PowderCoating.Application.Services; /// /// Manages job progress/completion photos stored in Azure Blob Storage. /// Photos are stored in the jobimages container under the path /// {companyId}/job-photos/{jobId}/{guid}{ext}. /// /// A random GUID is used in the blob name (instead of a sequential ID or the /// original filename) to prevent direct enumeration of photos across jobs by /// guessing predictable URLs — a common IDOR (Insecure Direct Object Reference) /// vulnerability pattern. /// /// public class JobPhotoService : IJobPhotoService { private readonly IAzureBlobStorageService _blobService; private readonly StorageSettings _settings; private readonly ILogger _logger; /// Image extensions accepted for job photos. private static readonly string[] AllowedImageTypes = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; /// Maximum photo size accepted on upload (10 MB). private const long MaxPhotoSize = 10 * 1024 * 1024; // 10 MB /// /// Initialises the service with the blob storage provider, storage /// configuration, and a logger for upload audit messages. /// public JobPhotoService( IAzureBlobStorageService blobService, IOptions settings, ILogger logger) { _blobService = blobService; _settings = settings.Value; _logger = logger; } /// /// Validates and uploads a job photo to Azure Blob Storage. /// A GUID is generated for the blob name to prevent IDOR enumeration attacks — /// without it, an attacker could iterate /job-photos/42/1.jpg, /// /job-photos/42/2.jpg, etc. to access another company's photos. /// The and parameters /// are accepted for API symmetry but are persisted by the caller (controller) /// in the JobPhoto entity, not by this service. /// /// The uploaded image file from the HTTP request. /// The job record's database ID. /// The tenant company's database ID (for path scoping). /// Optional display caption stored by the caller. /// Photo classification (Before, After, Progress, etc.). /// /// A tuple with a success flag, the stored blob path (on success), and a /// human-readable error message (on failure). /// public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveJobPhotoAsync( IFormFile file, int jobId, int companyId, string? caption = null, JobPhotoType photoType = JobPhotoType.Progress) { var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize); if (!isValid) return (false, string.Empty, error); // SECURITY: Use GUID for blob name to prevent enumeration var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}"; var contentType = BlobFileHelper.GetContentType(extension); using var stream = file.OpenReadStream(); var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType); if (!result.Success) return (false, string.Empty, result.ErrorMessage); _logger.LogInformation("Job photo saved: {BlobName} for job {JobId}", blobName, jobId); return (true, blobName, string.Empty); } /// /// Deletes the job photo blob at the given path from Azure Blob Storage. /// Called when a user removes a photo from the Job Details view. /// /// Blob-relative path previously returned by . /// Success flag and an error message on failure. public async Task<(bool Success, string ErrorMessage)> DeleteJobPhotoAsync(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) return (false, "File path is required."); return await _blobService.DeleteAsync(_settings.Containers.JobImages, filePath); } /// /// Downloads the raw bytes of a job photo for serving through the controller's /// photo-proxy endpoint (which enforces tenant authorization before streaming). /// /// Blob-relative path of the photo. /// /// A tuple with a success flag, the raw file bytes, the MIME content type, /// and an error message on failure. /// public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetJobPhotoAsync(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) return (false, Array.Empty(), string.Empty, "File path is required."); return await _blobService.DownloadAsync(_settings.Containers.JobImages, filePath); } /// /// Checks whether a photo blob exists without downloading its content. /// /// Blob-relative path to check. /// true if the blob exists; otherwise false. public async Task JobPhotoExistsAsync(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) return false; return await _blobService.ExistsAsync(_settings.Containers.JobImages, filePath); } }