feat: add content media dam uploads
All checks were successful
deploy-socialize / image (push) Successful in 1m15s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-09 12:42:20 -04:00
parent ca68132546
commit 5a798d6650
8 changed files with 801 additions and 31 deletions

View File

@@ -7,9 +7,25 @@ internal static class ContentTypes
private const string ImagePng = "image/png";
private const string ImageJpeg = "image/jpeg";
private const string ImageJpg = "image/jpg";
private const string ImageGif = "image/gif";
private const string ImageWebp = "image/webp";
private const string VideoMp4 = "video/mp4";
private const string VideoWebm = "video/webm";
private const string VideoQuickTime = "video/quicktime";
private const string TextHtml = "text/html";
private static readonly HashSet<string> AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg, TextHtml];
private static readonly HashSet<string> AllowedContentTypes =
[
ImagePng,
ImageJpeg,
ImageJpg,
ImageGif,
ImageWebp,
VideoMp4,
VideoWebm,
VideoQuickTime,
TextHtml,
];
public static bool IsAllowed(
string contentType,
@@ -37,6 +53,32 @@ internal static class ContentTypes
return true;
}
// GIF file signatures: GIF87a and GIF89a
if (buffer[0] == 0x47 && buffer[1] == 0x49 && buffer[2] == 0x46 &&
buffer[3] == 0x38 && (buffer[4] == 0x37 || buffer[4] == 0x39) && buffer[5] == 0x61)
{
return true;
}
// WebP files are RIFF containers with a WEBP marker.
if (buffer[0] == 0x52 && buffer[1] == 0x49 && buffer[2] == 0x46 && buffer[3] == 0x46 &&
buffer[8] == 0x57 && buffer[9] == 0x45 && buffer[10] == 0x42 && buffer[11] == 0x50)
{
return true;
}
// MP4/MOV containers expose an ftyp box near the beginning.
if (buffer[4] == 0x66 && buffer[5] == 0x74 && buffer[6] == 0x79 && buffer[7] == 0x70)
{
return true;
}
// WebM files use the EBML header.
if (buffer[0] == 0x1A && buffer[1] == 0x45 && buffer[2] == 0xDF && buffer[3] == 0xA3)
{
return true;
}
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
string content = Encoding.UTF8.GetString(buffer);
return content.Contains("<!DOCTYPE html>", StringComparison.OrdinalIgnoreCase);

View File

@@ -2,10 +2,11 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Assets.Data;
namespace Socialize.Api.Modules.Assets.Handlers;
internal record GetAssetsRequest(Guid ContentItemId);
internal record GetAssetsRequest(Guid? ContentItemId, Guid? WorkspaceId);
internal record AssetRevisionDto(
Guid Id,
@@ -44,23 +45,46 @@ internal class GetAssetsHandler(
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
{
var item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
if (item is null)
IQueryable<Asset> query = dbContext.Assets;
if (request.ContentItemId.HasValue)
{
await SendNotFoundAsync(ct);
var item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId.Value, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
}
query = query.Where(asset => asset.ContentItemId == request.ContentItemId.Value);
}
else if (request.WorkspaceId.HasValue)
{
if (!await accessScopeService.CanAccessWorkspaceAsync(User, request.WorkspaceId.Value, ct))
{
await SendForbiddenAsync(ct);
return;
}
query = query.Where(asset => asset.WorkspaceId == request.WorkspaceId.Value);
}
else
{
AddError(request => request.WorkspaceId, "A workspace or content item is required.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
}
List<AssetDto> assets = await dbContext.Assets
.Where(asset => asset.ContentItemId == request.ContentItemId)
.OrderBy(asset => asset.DisplayName)
List<AssetDto> assets = await query
.OrderByDescending(asset => asset.CreatedAt)
.ThenBy(asset => asset.DisplayName)
.Select(asset => new AssetDto(
asset.Id,
asset.WorkspaceId,

View File

@@ -0,0 +1,266 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers;
internal record UploadAssetRequest(
Guid WorkspaceId,
Guid ContentItemId,
string AssetType,
string? DisplayName,
IFormFile File);
internal class UploadAssetRequestValidator
: Validator<UploadAssetRequest>
{
public UploadAssetRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.AssetType).NotEmpty().MaximumLength(64);
RuleFor(x => x.DisplayName).MaximumLength(256);
RuleFor(x => x.File).NotNull();
}
}
internal class UploadAssetHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
IBlobStorage blobStorage,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter)
: Endpoint<UploadAssetRequest, AssetDto>
{
public override void Configure()
{
Post("/api/assets/upload");
Options(o => o.WithTags("Assets"));
AllowFileUploads();
}
public override async Task HandleAsync(UploadAssetRequest request, CancellationToken ct)
{
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
}
if (request.File.Length <= 0)
{
AddError(request => request.File, "The media file must not be empty.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedContentType = request.File.ContentType.Trim();
if (!IsSupportedMediaContentType(normalizedContentType))
{
AddError(request => request.File, "The media file must be an image or video.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
Guid assetId = Guid.NewGuid();
string fileName = NormalizeFileName(request.File.FileName, normalizedContentType);
string displayName = string.IsNullOrWhiteSpace(request.DisplayName)
? Path.GetFileNameWithoutExtension(fileName)
: request.DisplayName.Trim();
string blobName =
$"{contentItem.WorkspaceId}/{SubDirectoryNames.Contents}/{contentItem.Id}/assets/{assetId}/{fileName}";
string blobUrl;
try
{
blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Workspaces,
blobName,
request.File.OpenReadStream(),
normalizedContentType,
ct);
}
catch (InvalidOperationException)
{
AddError(request => request.File, "The media file is invalid or unsupported.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
Asset asset = new()
{
Id = assetId,
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
AssetType = NormalizeAssetType(request.AssetType, normalizedContentType),
SourceType = "Uploaded",
DisplayName = displayName,
GoogleDriveFileId = null,
GoogleDriveLink = null,
PreviewUrl = blobUrl,
CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow,
};
AssetRevision revision = new()
{
Id = Guid.NewGuid(),
AssetId = asset.Id,
RevisionNumber = 1,
SourceReference = blobName,
PreviewUrl = blobUrl,
CreatedByUserId = User.GetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Assets.Add(asset);
dbContext.AssetRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct);
await activityWriter.WriteAsync(
new ContentItemActivityWriteModel(
asset.WorkspaceId,
asset.ContentItemId,
"asset.uploaded",
"Asset",
asset.Id,
$"Asset {asset.DisplayName} was uploaded to {contentItem.Title}.",
User.GetUserId(),
User.GetEmail(),
JsonSerializer.Serialize(new
{
assetType = asset.AssetType,
sourceType = asset.SourceType,
currentRevisionNumber = asset.CurrentRevisionNumber,
})),
ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
asset.WorkspaceId,
asset.ContentItemId,
"asset.uploaded",
"Asset",
asset.Id,
$"Asset {asset.DisplayName} was uploaded to {contentItem.Title}.",
null,
null,
$$"""{"assetId":"{{asset.Id}}"}"""),
ct);
AssetDto dto = new(
asset.Id,
asset.WorkspaceId,
asset.ContentItemId,
asset.AssetType,
asset.SourceType,
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,
[
new AssetRevisionDto(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt)
]);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
private static bool IsSupportedMediaContentType(string contentType)
{
string normalized = contentType.Trim();
return normalized.Equals("image/png", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/gif", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/webp", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("video/mp4", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("video/webm", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeAssetType(string assetType, string contentType)
{
string normalized = assetType.Trim();
if (!normalized.Equals("None", StringComparison.OrdinalIgnoreCase))
{
return normalized.Length > 64 ? normalized[..64] : normalized;
}
return contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) ? "Video" : "Image";
}
private static string NormalizeFileName(string? fileName, string contentType)
{
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
normalized = $"asset{DefaultExtension(contentType)}";
}
return normalized.Length > 256 ? normalized[..256] : normalized;
}
private static string DefaultExtension(string contentType)
{
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
return ".png";
}
if (contentType.Equals("image/gif", StringComparison.OrdinalIgnoreCase))
{
return ".gif";
}
if (contentType.Equals("image/webp", StringComparison.OrdinalIgnoreCase))
{
return ".webp";
}
if (contentType.Equals("video/webm", StringComparison.OrdinalIgnoreCase))
{
return ".webm";
}
if (contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase))
{
return ".mov";
}
if (contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
{
return ".mp4";
}
return ".jpg";
}
}