feat: protect feedback screenshots

This commit is contained in:
2026-04-30 13:15:19 -04:00
parent cb6948aa14
commit 4873f39192
24 changed files with 1900 additions and 0 deletions

View File

@@ -19,6 +19,14 @@ public record FeedbackMetadataDto(
int? ViewportHeight,
string? AppVersion);
public record FeedbackScreenshotDto(
Guid Id,
string FileName,
string ContentType,
long SizeBytes,
string DownloadPath,
DateTimeOffset CreatedAt);
public record FeedbackReportDto(
Guid Id,
string Type,
@@ -29,6 +37,7 @@ public record FeedbackReportDto(
string ReporterEmail,
FeedbackMetadataDto Metadata,
FeedbackContextDto Context,
FeedbackScreenshotDto? Screenshot,
IReadOnlyCollection<string> Tags,
DateTimeOffset CreatedAt,
DateTimeOffset LastActivityAt,
@@ -62,6 +71,15 @@ public static class FeedbackDtoMapper
report.ProjectName,
report.ContentItemId,
report.ContentItemTitle),
report.Screenshot is null
? null
: new FeedbackScreenshotDto(
report.Screenshot.Id,
report.Screenshot.FileName,
report.Screenshot.ContentType,
report.Screenshot.SizeBytes,
$"/api/feedback/{report.Id}/screenshot",
report.Screenshot.CreatedAt),
report.Tags.OrderBy(tag => tag.Name).Select(tag => tag.Name).ToArray(),
report.CreatedAt,
report.LastActivityAt,

View File

@@ -45,6 +45,22 @@ public static class FeedbackModelConfiguration
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<FeedbackScreenshot>(screenshot =>
{
screenshot.ToTable("FeedbackScreenshots");
screenshot.HasKey(x => x.Id);
screenshot.Property(x => x.FileName).HasMaxLength(256).IsRequired();
screenshot.Property(x => x.ContentType).HasMaxLength(128).IsRequired();
screenshot.Property(x => x.BlobContainerName).HasMaxLength(128).IsRequired();
screenshot.Property(x => x.BlobName).HasMaxLength(512).IsRequired();
screenshot.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
screenshot.HasIndex(x => x.FeedbackReportId).IsUnique();
screenshot.HasOne(x => x.FeedbackReport)
.WithOne(x => x.Screenshot)
.HasForeignKey<FeedbackScreenshot>(x => x.FeedbackReportId)
.OnDelete(DeleteBehavior.Cascade);
});
return modelBuilder;
}
}

View File

@@ -28,4 +28,5 @@ public class FeedbackReport
public Guid? CancelledByUserId { get; set; }
public string? CancellationReason { get; set; }
public ICollection<FeedbackTag> Tags { get; } = new List<FeedbackTag>();
public FeedbackScreenshot? Screenshot { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace Socialize.Api.Modules.Feedback.Data;
public class FeedbackScreenshot
{
public Guid Id { get; set; }
public Guid FeedbackReportId { get; set; }
public string FileName { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public string BlobContainerName { get; set; } = string.Empty;
public string BlobName { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public FeedbackReport? FeedbackReport { get; set; }
}

View File

@@ -0,0 +1,124 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Feedback.Services;
namespace Socialize.Api.Modules.Feedback.Handlers;
public record AttachMyFeedbackScreenshotRequest(IFormFile File);
public class AttachMyFeedbackScreenshotRequestValidator
: Validator<AttachMyFeedbackScreenshotRequest>
{
public AttachMyFeedbackScreenshotRequestValidator()
{
RuleFor(x => x.File).NotNull().NotEmpty();
}
}
public class AttachMyFeedbackScreenshotHandler(
AppDbContext dbContext,
IBlobStorage blobStorage)
: Endpoint<AttachMyFeedbackScreenshotRequest, FeedbackReportDto>
{
public override void Configure()
{
Post("/api/my-feedback/{id}/screenshot");
Options(o => o.WithTags("Feedback"));
AllowFileUploads();
}
public override async Task HandleAsync(AttachMyFeedbackScreenshotRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Guid reporterUserId = User.GetUserId();
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.SingleOrDefaultAsync(
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
ct);
if (report is null)
{
await SendNotFoundAsync(ct);
return;
}
if (report.Screenshot is not null)
{
AddError("A screenshot is already attached to this feedback report.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
if (!FeedbackScreenshotRules.IsAllowedSize(request.File.Length))
{
AddError(
request => request.File,
$"The screenshot must be greater than 0 bytes and no larger than {FeedbackScreenshotRules.MaxScreenshotBytes} bytes.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!FeedbackScreenshotRules.IsAllowedContentType(request.File.ContentType))
{
AddError(request => request.File, "The screenshot must be a PNG or JPEG image.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
Guid screenshotId = Guid.NewGuid();
string extension = FeedbackScreenshotRules.GetFileExtension(request.File.ContentType);
string blobName = $"{SubDirectoryNames.FeedbackScreenshots}/{report.Id}/{screenshotId}{extension}";
try
{
await blobStorage.UploadFileAsync(
ContainerNames.Feedback,
blobName,
request.File.OpenReadStream(),
request.File.ContentType,
ct);
}
catch (InvalidOperationException)
{
AddError(request => request.File, "The screenshot file is invalid or unsupported.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
report.Screenshot = new FeedbackScreenshot
{
Id = screenshotId,
FeedbackReportId = report.Id,
FileName = NormalizeFileName(request.File.FileName, extension),
ContentType = request.File.ContentType,
SizeBytes = request.File.Length,
BlobContainerName = ContainerNames.Feedback,
BlobName = blobName,
CreatedAt = now,
};
report.LastActivityAt = now;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(report.ToDto(), ct);
}
private static string NormalizeFileName(string? fileName, string extension)
{
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return $"feedback-screenshot{extension}";
}
return normalized.Length > 256 ? normalized[..256] : normalized;
}
}

View File

@@ -35,6 +35,7 @@ public class CancelMyFeedbackHandler(AppDbContext dbContext)
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.SingleOrDefaultAsync(
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
ct);

View File

@@ -21,6 +21,7 @@ public class GetDeveloperFeedbackHandler(AppDbContext dbContext)
Guid id = Route<Guid>("id");
FeedbackReportDto? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.Where(candidate => candidate.Id == id)
.Select(candidate => candidate.ToDto())
.SingleOrDefaultAsync(ct);

View File

@@ -0,0 +1,65 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Feedback.Services;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Feedback.Handlers;
public class GetFeedbackScreenshotHandler(
AppDbContext dbContext,
IBlobStorage blobStorage)
: EndpointWithoutRequest<Stream>
{
public override void Configure()
{
Get("/api/feedback/{id}/screenshot");
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
Guid userId = User.GetUserId();
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Screenshot)
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (report is null || report.Screenshot is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!FeedbackAccessRules.CanAccessScreenshot(report, userId, User.IsInRole(KnownRoles.Developer)))
{
await SendForbiddenAsync(ct);
return;
}
MemoryStream stream;
try
{
stream = await blobStorage.DownloadFileAsync(
report.Screenshot.BlobContainerName,
report.Screenshot.BlobName,
ct);
}
catch (FileNotFoundException)
{
await SendNotFoundAsync(ct);
return;
}
await SendStreamAsync(
stream,
fileName: report.Screenshot.FileName,
fileLengthBytes: report.Screenshot.SizeBytes,
contentType: report.Screenshot.ContentType,
cancellation: ct);
}
}

View File

@@ -22,6 +22,7 @@ public class GetMyFeedbackHandler(AppDbContext dbContext)
FeedbackReportDto? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.Where(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId)
.Select(candidate => candidate.ToDto())
.SingleOrDefaultAsync(ct);

View File

@@ -20,6 +20,7 @@ public class ListDeveloperFeedbackHandler(AppDbContext dbContext)
{
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
.Include(report => report.Tags)
.Include(report => report.Screenshot)
.OrderByDescending(report => report.LastActivityAt)
.Select(report => report.ToDto())
.ToListAsync(ct);

View File

@@ -20,6 +20,7 @@ public class ListMyFeedbackHandler(AppDbContext dbContext)
Guid reporterUserId = User.GetUserId();
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
.Include(report => report.Tags)
.Include(report => report.Screenshot)
.Where(report => report.ReporterUserId == reporterUserId)
.OrderByDescending(report => report.LastActivityAt)
.Select(report => report.ToDto())

View File

@@ -39,6 +39,7 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
Guid id = Route<Guid>("id");
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (report is null)

View File

@@ -13,4 +13,9 @@ public static class FeedbackAccessRules
{
return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status);
}
public static bool CanAccessScreenshot(FeedbackReport report, Guid userId, bool isDeveloper)
{
return isDeveloper || CanReporterAccess(report, userId);
}
}

View File

@@ -0,0 +1,31 @@
namespace Socialize.Api.Modules.Feedback.Services;
public static class FeedbackScreenshotRules
{
public const long MaxScreenshotBytes = 5 * 1024 * 1024;
private static readonly HashSet<string> AllowedContentTypes =
[
"image/png",
"image/jpeg",
"image/jpg",
];
public static bool IsAllowedContentType(string? contentType)
{
return !string.IsNullOrWhiteSpace(contentType) &&
AllowedContentTypes.Contains(contentType.Trim(), StringComparer.OrdinalIgnoreCase);
}
public static bool IsAllowedSize(long sizeBytes)
{
return sizeBytes is > 0 and <= MaxScreenshotBytes;
}
public static string GetFileExtension(string contentType)
{
return contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)
? ".png"
: ".jpg";
}
}