feat: add content media dam uploads
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
266
backend/src/Socialize.Api/Modules/Assets/Handlers/UploadAsset.cs
Normal file
266
backend/src/Socialize.Api/Modules/Assets/Handlers/UploadAsset.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user