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);
}
}