feat: protect feedback screenshots
This commit is contained in:
@@ -31,6 +31,7 @@ public class AppDbContext(
|
|||||||
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
|
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
|
||||||
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
|
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
|
||||||
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
|
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
|
||||||
|
public DbSet<FeedbackScreenshot> FeedbackScreenshots => Set<FeedbackScreenshot>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ internal static class ContainerNames
|
|||||||
public const string Clients = "clients";
|
public const string Clients = "clients";
|
||||||
public const string Workspaces = "workspaces";
|
public const string Workspaces = "workspaces";
|
||||||
public const string Creators = "creators";
|
public const string Creators = "creators";
|
||||||
|
public const string Feedback = "feedback";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ public static class SubDirectoryNames
|
|||||||
public const string Profile = "profile";
|
public const string Profile = "profile";
|
||||||
public const string Contents = "contents";
|
public const string Contents = "contents";
|
||||||
public const string Albums = "albums";
|
public const string Albums = "albums";
|
||||||
|
public const string FeedbackScreenshots = "screenshots";
|
||||||
}
|
}
|
||||||
|
|||||||
1163
backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.Designer.cs
generated
Normal file
1163
backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFeedbackScreenshots : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FeedbackScreenshots",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
ContentType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
BlobContainerName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
BlobName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FeedbackScreenshots", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FeedbackScreenshots_FeedbackReports_FeedbackReportId",
|
||||||
|
column: x => x.FeedbackReportId,
|
||||||
|
principalTable: "FeedbackReports",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FeedbackScreenshots_FeedbackReportId",
|
||||||
|
table: "FeedbackScreenshots",
|
||||||
|
column: "FeedbackReportId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FeedbackScreenshots");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -672,6 +672,51 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("FeedbackReports", (string)null);
|
b.ToTable("FeedbackReports", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("BlobContainerName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("BlobName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
b.Property<Guid>("FeedbackReportId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FeedbackReportId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("FeedbackScreenshots", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b =>
|
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1081,6 +1126,17 @@ namespace Socialize.Api.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
|
||||||
|
.WithOne("Screenshot")
|
||||||
|
.HasForeignKey("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", "FeedbackReportId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("FeedbackReport");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b =>
|
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
|
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
|
||||||
@@ -1094,6 +1150,8 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
|
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("Screenshot");
|
||||||
|
|
||||||
b.Navigation("Tags");
|
b.Navigation("Tags");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ public record FeedbackMetadataDto(
|
|||||||
int? ViewportHeight,
|
int? ViewportHeight,
|
||||||
string? AppVersion);
|
string? AppVersion);
|
||||||
|
|
||||||
|
public record FeedbackScreenshotDto(
|
||||||
|
Guid Id,
|
||||||
|
string FileName,
|
||||||
|
string ContentType,
|
||||||
|
long SizeBytes,
|
||||||
|
string DownloadPath,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
public record FeedbackReportDto(
|
public record FeedbackReportDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Type,
|
string Type,
|
||||||
@@ -29,6 +37,7 @@ public record FeedbackReportDto(
|
|||||||
string ReporterEmail,
|
string ReporterEmail,
|
||||||
FeedbackMetadataDto Metadata,
|
FeedbackMetadataDto Metadata,
|
||||||
FeedbackContextDto Context,
|
FeedbackContextDto Context,
|
||||||
|
FeedbackScreenshotDto? Screenshot,
|
||||||
IReadOnlyCollection<string> Tags,
|
IReadOnlyCollection<string> Tags,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset LastActivityAt,
|
DateTimeOffset LastActivityAt,
|
||||||
@@ -62,6 +71,15 @@ public static class FeedbackDtoMapper
|
|||||||
report.ProjectName,
|
report.ProjectName,
|
||||||
report.ContentItemId,
|
report.ContentItemId,
|
||||||
report.ContentItemTitle),
|
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.Tags.OrderBy(tag => tag.Name).Select(tag => tag.Name).ToArray(),
|
||||||
report.CreatedAt,
|
report.CreatedAt,
|
||||||
report.LastActivityAt,
|
report.LastActivityAt,
|
||||||
|
|||||||
@@ -45,6 +45,22 @@ public static class FeedbackModelConfiguration
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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;
|
return modelBuilder;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,5 @@ public class FeedbackReport
|
|||||||
public Guid? CancelledByUserId { get; set; }
|
public Guid? CancelledByUserId { get; set; }
|
||||||
public string? CancellationReason { get; set; }
|
public string? CancellationReason { get; set; }
|
||||||
public ICollection<FeedbackTag> Tags { get; } = new List<FeedbackTag>();
|
public ICollection<FeedbackTag> Tags { get; } = new List<FeedbackTag>();
|
||||||
|
public FeedbackScreenshot? Screenshot { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ public class CancelMyFeedbackHandler(AppDbContext dbContext)
|
|||||||
|
|
||||||
FeedbackReport? report = await dbContext.FeedbackReports
|
FeedbackReport? report = await dbContext.FeedbackReports
|
||||||
.Include(candidate => candidate.Tags)
|
.Include(candidate => candidate.Tags)
|
||||||
|
.Include(candidate => candidate.Screenshot)
|
||||||
.SingleOrDefaultAsync(
|
.SingleOrDefaultAsync(
|
||||||
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
|
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
|
||||||
ct);
|
ct);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class GetDeveloperFeedbackHandler(AppDbContext dbContext)
|
|||||||
Guid id = Route<Guid>("id");
|
Guid id = Route<Guid>("id");
|
||||||
FeedbackReportDto? report = await dbContext.FeedbackReports
|
FeedbackReportDto? report = await dbContext.FeedbackReports
|
||||||
.Include(candidate => candidate.Tags)
|
.Include(candidate => candidate.Tags)
|
||||||
|
.Include(candidate => candidate.Screenshot)
|
||||||
.Where(candidate => candidate.Id == id)
|
.Where(candidate => candidate.Id == id)
|
||||||
.Select(candidate => candidate.ToDto())
|
.Select(candidate => candidate.ToDto())
|
||||||
.SingleOrDefaultAsync(ct);
|
.SingleOrDefaultAsync(ct);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ public class GetMyFeedbackHandler(AppDbContext dbContext)
|
|||||||
|
|
||||||
FeedbackReportDto? report = await dbContext.FeedbackReports
|
FeedbackReportDto? report = await dbContext.FeedbackReports
|
||||||
.Include(candidate => candidate.Tags)
|
.Include(candidate => candidate.Tags)
|
||||||
|
.Include(candidate => candidate.Screenshot)
|
||||||
.Where(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId)
|
.Where(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId)
|
||||||
.Select(candidate => candidate.ToDto())
|
.Select(candidate => candidate.ToDto())
|
||||||
.SingleOrDefaultAsync(ct);
|
.SingleOrDefaultAsync(ct);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class ListDeveloperFeedbackHandler(AppDbContext dbContext)
|
|||||||
{
|
{
|
||||||
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
||||||
.Include(report => report.Tags)
|
.Include(report => report.Tags)
|
||||||
|
.Include(report => report.Screenshot)
|
||||||
.OrderByDescending(report => report.LastActivityAt)
|
.OrderByDescending(report => report.LastActivityAt)
|
||||||
.Select(report => report.ToDto())
|
.Select(report => report.ToDto())
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class ListMyFeedbackHandler(AppDbContext dbContext)
|
|||||||
Guid reporterUserId = User.GetUserId();
|
Guid reporterUserId = User.GetUserId();
|
||||||
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
||||||
.Include(report => report.Tags)
|
.Include(report => report.Tags)
|
||||||
|
.Include(report => report.Screenshot)
|
||||||
.Where(report => report.ReporterUserId == reporterUserId)
|
.Where(report => report.ReporterUserId == reporterUserId)
|
||||||
.OrderByDescending(report => report.LastActivityAt)
|
.OrderByDescending(report => report.LastActivityAt)
|
||||||
.Select(report => report.ToDto())
|
.Select(report => report.ToDto())
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
|
|||||||
Guid id = Route<Guid>("id");
|
Guid id = Route<Guid>("id");
|
||||||
FeedbackReport? report = await dbContext.FeedbackReports
|
FeedbackReport? report = await dbContext.FeedbackReports
|
||||||
.Include(candidate => candidate.Tags)
|
.Include(candidate => candidate.Tags)
|
||||||
|
.Include(candidate => candidate.Screenshot)
|
||||||
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||||
|
|
||||||
if (report is null)
|
if (report is null)
|
||||||
|
|||||||
@@ -13,4 +13,9 @@ public static class FeedbackAccessRules
|
|||||||
{
|
{
|
||||||
return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status);
|
return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool CanAccessScreenshot(FeedbackReport report, Guid userId, bool isDeveloper)
|
||||||
|
{
|
||||||
|
return isDeveloper || CanReporterAccess(report, userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,79 @@ public class FeedbackRulesTests
|
|||||||
Assert.False(otherUserAllowed);
|
Assert.False(otherUserAllowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanAccessScreenshot_allows_report_owner()
|
||||||
|
{
|
||||||
|
Guid reporterUserId = Guid.NewGuid();
|
||||||
|
FeedbackReport report = new() { ReporterUserId = reporterUserId };
|
||||||
|
|
||||||
|
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, reporterUserId, isDeveloper: false);
|
||||||
|
|
||||||
|
Assert.True(allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanAccessScreenshot_allows_developer()
|
||||||
|
{
|
||||||
|
FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() };
|
||||||
|
|
||||||
|
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, Guid.NewGuid(), isDeveloper: true);
|
||||||
|
|
||||||
|
Assert.True(allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanAccessScreenshot_rejects_unrelated_non_developer()
|
||||||
|
{
|
||||||
|
FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() };
|
||||||
|
|
||||||
|
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, Guid.NewGuid(), isDeveloper: false);
|
||||||
|
|
||||||
|
Assert.False(allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("image/png")]
|
||||||
|
[InlineData("image/jpeg")]
|
||||||
|
[InlineData("image/jpg")]
|
||||||
|
public void Screenshot_content_type_allows_png_and_jpeg(string contentType)
|
||||||
|
{
|
||||||
|
bool allowed = FeedbackScreenshotRules.IsAllowedContentType(contentType);
|
||||||
|
|
||||||
|
Assert.True(allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("text/html")]
|
||||||
|
[InlineData("application/pdf")]
|
||||||
|
[InlineData("")]
|
||||||
|
public void Screenshot_content_type_rejects_non_images(string contentType)
|
||||||
|
{
|
||||||
|
bool allowed = FeedbackScreenshotRules.IsAllowedContentType(contentType);
|
||||||
|
|
||||||
|
Assert.False(allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(FeedbackScreenshotRules.MaxScreenshotBytes)]
|
||||||
|
public void Screenshot_size_allows_non_empty_files_up_to_limit(long sizeBytes)
|
||||||
|
{
|
||||||
|
bool allowed = FeedbackScreenshotRules.IsAllowedSize(sizeBytes);
|
||||||
|
|
||||||
|
Assert.True(allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(FeedbackScreenshotRules.MaxScreenshotBytes + 1)]
|
||||||
|
public void Screenshot_size_rejects_empty_and_oversized_files(long sizeBytes)
|
||||||
|
{
|
||||||
|
bool allowed = FeedbackScreenshotRules.IsAllowedSize(sizeBytes);
|
||||||
|
|
||||||
|
Assert.False(allowed);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NormalizeTags_trims_deduplicates_and_orders()
|
public void NormalizeTags_trims_deduplicates_and_orders()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ This is product-level support data for the SaaS operator. It may capture workspa
|
|||||||
|
|
||||||
- Screenshots are uploaded through the blob storage abstraction, not embedded in feedback database rows.
|
- Screenshots are uploaded through the blob storage abstraction, not embedded in feedback database rows.
|
||||||
- Feedback screenshots should use a dedicated storage area or prefix.
|
- Feedback screenshots should use a dedicated storage area or prefix.
|
||||||
|
- Feedback screenshot records store blob container/path metadata and expose a protected API download path, not a public blob URL.
|
||||||
- Annotated captures are exported as compressed image files.
|
- Annotated captures are exported as compressed image files.
|
||||||
- Backend upload size and content type validation must be enforced.
|
- Backend upload size and content type validation must be enforced.
|
||||||
- The UI must show a friendly error when an image is too large or invalid.
|
- The UI must show a friendly error when an image is too large or invalid.
|
||||||
|
|||||||
119
frontend/src/api/schema.d.ts
vendored
119
frontend/src/api/schema.d.ts
vendored
@@ -436,6 +436,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/my-feedback/{id}/screenshot": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/my-feedback/{id}/cancel": {
|
"/api/my-feedback/{id}/cancel": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -468,6 +484,22 @@ export interface paths {
|
|||||||
patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"];
|
patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/feedback/{id}/screenshot": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/my-feedback/{id}": {
|
"/api/my-feedback/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -998,6 +1030,7 @@ export interface components {
|
|||||||
reporterEmail?: string;
|
reporterEmail?: string;
|
||||||
metadata?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackMetadataDto"];
|
metadata?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackMetadataDto"];
|
||||||
context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"];
|
context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"];
|
||||||
|
screenshot?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"] | null;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
@@ -1030,6 +1063,21 @@ export interface components {
|
|||||||
contentItemId?: string | null;
|
contentItemId?: string | null;
|
||||||
contentItemTitle?: string | null;
|
contentItemTitle?: string | null;
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesFeedbackContractsFeedbackScreenshotDto: {
|
||||||
|
/** Format: guid */
|
||||||
|
id?: string;
|
||||||
|
fileName?: string;
|
||||||
|
contentType?: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
sizeBytes?: number;
|
||||||
|
downloadPath?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest: {
|
||||||
|
/** Format: binary */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest: {
|
SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest: {
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
};
|
};
|
||||||
@@ -2238,6 +2286,48 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackReportDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesFeedbackHandlersCancelMyFeedbackHandler: {
|
SocializeApiModulesFeedbackHandlersCancelMyFeedbackHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2365,6 +2455,35 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SystemIOStream"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesFeedbackHandlersGetMyFeedbackHandler: {
|
SocializeApiModulesFeedbackHandlersGetMyFeedbackHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -1221,6 +1221,68 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/my-feedback/{id}/screenshot": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Feedback",
|
||||||
|
"Api"
|
||||||
|
],
|
||||||
|
"operationId": "SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"x-name": "AttachMyFeedbackScreenshotRequest",
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"x-position": 1
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackReportDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JWTBearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/my-feedback/{id}/cancel": {
|
"/api/my-feedback/{id}/cancel": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1392,6 +1454,45 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/feedback/{id}/screenshot": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Feedback",
|
||||||
|
"Api"
|
||||||
|
],
|
||||||
|
"operationId": "SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SystemIOStream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JWTBearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/my-feedback/{id}": {
|
"/api/my-feedback/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3320,6 +3421,14 @@
|
|||||||
"context": {
|
"context": {
|
||||||
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackContextDto"
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackContextDto"
|
||||||
},
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"nullable": true,
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -3414,6 +3523,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SocializeApiModulesFeedbackContractsFeedbackScreenshotDto": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid"
|
||||||
|
},
|
||||||
|
"fileName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"contentType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sizeBytes": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"downloadPath": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"file"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary",
|
||||||
|
"minLength": 1,
|
||||||
|
"nullable": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest": {
|
"SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user