Adds multiple media urls for content

This commit is contained in:
Jonathan Bourdon
2024-07-31 17:38:58 -04:00
parent 042fd53463
commit bbcc7a8a33
19 changed files with 319 additions and 111 deletions

View File

@@ -8,7 +8,7 @@
<PackageVersion Include="AutoMapper" Version="13.0.1" />
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.0" />
<PackageVersion Include="Azure.Identity" Version="1.11.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.20.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.21.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.0" />
<PackageVersion Include="FastEndpoints" Version="5.26.0" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" />

View File

@@ -3,4 +3,5 @@
public static class ContainerNames
{
public static string Users = "users";
public static string Creators = "creators";
}

View File

@@ -3,5 +3,5 @@
public static class SubDirectoryNames
{
public static string Profile = "profile";
public static string Posts = "posts";
public static string Contents = "contents";
}

View File

@@ -2,6 +2,6 @@
public interface IAzureBlobStorageService
{
Task<string> UploadFileAsync(string containerName, string blobName, Stream fileStream, string contentType);
Task<MemoryStream> DownloadFileAsync(string containerName, string blobName);
Task<string> UploadFileAsync(string containerName, string blobName, MemoryStream memoryStream, string contentType, CancellationToken ct = default);
Task<MemoryStream> DownloadFileAsync(string containerName, string blobName, CancellationToken ct = default);
}

View File

@@ -10,7 +10,7 @@ namespace Hutopy.Application.Users.Commands;
/// </summary>
public class UploadBannerPictureCommand : IRequest<IResult>
{
public required Stream BannerPicture { get; init; }
public required MemoryStream BannerPicture { get; init; }
public string BannerPictureUrl { get; init; } = string.Empty;
}
@@ -32,7 +32,12 @@ public class UploadBannerPictureCommandHandler(IHttpContextAccessor contextAcces
var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.BannerPicture}";
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.BannerPicture, contentType);
var url = await azureBlobStorageService.UploadFileAsync(
ContainerNames.Users,
blobName,
request.BannerPicture,
contentType,
cancellationToken);
await identityService.UpdateCurrentUserBannerPictureUrlAsync(url);

View File

@@ -10,13 +10,13 @@ namespace Hutopy.Application.Users.Commands;
/// </summary>
public class UploadProfilePictureCommand : IRequest<IResult>
{
public required Stream ProfilePicture { get; init; }
public required MemoryStream ProfilePicture { get; init; }
public string ProfilePictureUrl { get; init; } = string.Empty;
}
public class UploadProfilePictureCommandHandler(IHttpContextAccessor contextAccessor, IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadProfilePictureCommand, IResult>
{
public async Task<IResult> Handle(UploadProfilePictureCommand request, CancellationToken cancellationToken)
public async Task<IResult> Handle(UploadProfilePictureCommand request, CancellationToken ct)
{
// If an url to the picture is provided, use it right away and don't upload anything.
if (!string.IsNullOrEmpty(request.ProfilePictureUrl))
@@ -32,7 +32,12 @@ public class UploadProfilePictureCommandHandler(IHttpContextAccessor contextAcce
var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}";
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.ProfilePicture, contentType);
var url = await azureBlobStorageService.UploadFileAsync(
ContainerNames.Users,
blobName,
request.ProfilePicture,
contentType,
ct);
await identityService.UpdateCurrentUserProfilePictureUrlAsync(url);

View File

@@ -10,14 +10,17 @@ namespace Hutopy.Application.Users.Commands;
/// </summary>
public class UploadWebsiteIconCommand : IRequest<IResult>
{
public required Stream WebsiteIcon { get; init; }
public required MemoryStream WebsiteIcon { get; init; }
public string WebsitePictureUrl { get; init; } = string.Empty;
}
public class UploadWebsiteIconCommandHandler(IHttpContextAccessor contextAccessor, IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadWebsiteIconCommand, IResult>
public class UploadWebsiteIconCommandHandler(
IHttpContextAccessor contextAccessor,
IIdentityService identityService,
IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadWebsiteIconCommand, IResult>
{
public async Task<IResult> Handle(UploadWebsiteIconCommand request, CancellationToken cancellationToken)
public async Task<IResult> Handle(UploadWebsiteIconCommand request, CancellationToken ct)
{
// If an url to the picture is provided, use it right away and don't upload anything.
if (!string.IsNullOrEmpty(request.WebsitePictureUrl))
@@ -25,19 +28,23 @@ public class UploadWebsiteIconCommandHandler(IHttpContextAccessor contextAccesso
await identityService.UpdateCurrentUserWebsiteIconUrlAsync(request.WebsitePictureUrl);
return Results.Ok(request.WebsitePictureUrl);
}
var contentType = contextAccessor.EnsureContentType();
var identityUser = await identityService.GetCurrentUserAsync();
var currentUserId = new Guid(identityUser?.Id ?? "").ToString();
var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.WebsiteIcon}";
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.WebsiteIcon, contentType);
var url = await azureBlobStorageService.UploadFileAsync(
ContainerNames.Users,
blobName,
request.WebsiteIcon,
contentType,
ct);
await identityService.UpdateCurrentUserWebsiteIconUrlAsync(url);
return Results.Ok(request.WebsitePictureUrl);
}
}

View File

@@ -11,7 +11,7 @@ public class AzureBlobStorageService : IAzureBlobStorageService
{
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger<AzureBlobStorageService> _logger;
private readonly long _maxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
private readonly long _maxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
public AzureBlobStorageService(IConfiguration configuration, ILogger<AzureBlobStorageService> logger)
{
@@ -25,63 +25,73 @@ public class AzureBlobStorageService : IAzureBlobStorageService
/// </summary>
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
/// <param name="containerName">The name of the container where the file is stored.</param>
/// <param name="fileStream">The stream.</param>
/// <param name="memoryStream">The memory stream containing the image.</param>
/// <param name="contentType">The content type.</param>
/// <param name="ct">The cancellation token</param>
/// <returns></returns>
public async Task<string> UploadFileAsync(string containerName, string blobName, Stream fileStream, string contentType)
public async Task<string> UploadFileAsync(string containerName, string blobName, MemoryStream memoryStream,
string contentType, CancellationToken ct = default)
{
// Read the file stream into a memory stream to determine the length
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
// Check if the file size exceeds the maximum upload size
if (memoryStream.Length > _maxUploadSize)
{
_logger.LogInformation($"Blob storage: File size exceeds the maximum allowed size of {_maxUploadSize} bytes.");
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {_maxUploadSize} bytes.");
_logger.LogError(
$"Blob storage: File size exceeds the maximum allowed size of {_maxUploadSize} bytes.");
throw new InvalidOperationException(
$"Blob storage: File size exceeds the maximum allowed size of {_maxUploadSize} bytes.");
}
// Validate content type
if (!ContentTypes.IsAllowed(contentType, memoryStream))
{
_logger.LogInformation($"Blob storage: Unsupported file type {contentType}. Only PNG and JPEG are allowed.");
_logger.LogError(
$"Blob storage: Unsupported file type {contentType}. Only PNG and JPEG are allowed.");
throw new InvalidOperationException("Unsupported file type. Only PNG and JPEG are allowed.");
}
try
{
// Get a reference to a container
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Create the container if it does not exist
await containerClient.CreateIfNotExistsAsync();
await containerClient.CreateIfNotExistsAsync(
PublicAccessType.Blob,
cancellationToken: ct);
// Get a reference to a blob
var blobClient = containerClient.GetBlobClient(blobName);
// Define the BlobHttpHeaders to include the content type
var blobHttpHeaders = new BlobHttpHeaders
{
ContentType = contentType
};
var blobHttpHeaders = new BlobHttpHeaders { ContentType = contentType };
// Upload the file
var response = await blobClient.UploadAsync(memoryStream, new BlobUploadOptions
{
HttpHeaders = blobHttpHeaders
});
var response = await blobClient.UploadAsync(
memoryStream,
new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
ct);
var fileUri = blobClient.Uri.ToString();
_logger.LogInformation(
$"Blob storage: Status [ {response.GetRawResponse().Status.ToString()} ] " +
$"Uploaded [ {blobName} ] to the container [ {containerName} ] " +
$"with contentType [ {contentType} ] " +
$"with a length of [ {memoryStream.Length} bytes ]" +
$"with the uri [ {fileUri} ]"
);
"""
Blob storage: Status [ {ResponseStatus} ]
Uploaded [ {BlobName} ] to the container [ {ContainerName} ]
with contentType [ {ContentType} ]
with a length of [ {StreamLength} bytes ]
with the uri [ {FileUri} ]
""",
response.GetRawResponse().Status.ToString(),
blobName,
containerName,
contentType,
memoryStream.Length,
fileUri
);
// Return the URI of the uploaded blob
return fileUri;
@@ -89,7 +99,7 @@ public class AzureBlobStorageService : IAzureBlobStorageService
catch (RequestFailedException ex)
{
_logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
throw new Exception("Error uploading file to Azure Blob Storage", ex);
throw;
}
catch (Exception ex)
{
@@ -99,25 +109,27 @@ public class AzureBlobStorageService : IAzureBlobStorageService
}
/// <summary>
/// Download a file to microsoft azure blob storage.
/// Download a file to microsoft's azure blob storage.
/// </summary>
/// <param name="blobName">The blob name (path within the container).</param>
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
/// <param name="ct">The cancellation token for the request</param>
/// <returns></returns>
public async Task<MemoryStream> DownloadFileAsync(string containerName, string blobName)
public async Task<MemoryStream> DownloadFileAsync(string containerName, string blobName,
CancellationToken ct = default)
{
try
{
// Get a reference to a container
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Get a reference to a blob
var blobClient = containerClient.GetBlobClient(blobName);
// Download the blob to a stream
BlobDownloadInfo download = await blobClient.DownloadAsync();
BlobDownloadInfo download = await blobClient.DownloadAsync(ct);
MemoryStream memoryStream = new();
await download.Content.CopyToAsync(memoryStream);
await download.Content.CopyToAsync(memoryStream, ct);
memoryStream.Position = 0; // Ensure the stream is at the beginning
return memoryStream;
@@ -125,12 +137,12 @@ public class AzureBlobStorageService : IAzureBlobStorageService
catch (RequestFailedException ex)
{
_logger.LogError($"Azure Storage request failed: {ex.Message}");
throw new Exception("Error downloading file from Azure Blob Storage", ex);
throw;
}
catch (Exception ex)
{
_logger.LogError($"An error occurred: {ex.Message}");
throw new ApplicationException("Error downloading file from Azure Blob Storage.", ex);
throw;
}
}
}

View File

@@ -4,7 +4,7 @@
<AssemblyName>Hutopy.Infrastructure</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.21.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />

View File

@@ -66,7 +66,7 @@ namespace Hutopy.Infrastructure.Migrations
b.HasKey("Id");
b.ToTable("FutureCreators");
b.ToTable("FutureCreators", (string)null);
});
modelBuilder.Entity("Hutopy.Domain.Entities.UserTransaction", b =>
@@ -141,7 +141,7 @@ namespace Hutopy.Infrastructure.Migrations
b.HasIndex("ApplicationUserId");
b.ToTable("UserTransactions");
b.ToTable("UserTransactions", (string)null);
});
modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b =>
@@ -395,7 +395,7 @@ namespace Hutopy.Infrastructure.Migrations
modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b =>
{
b.OwnsOne("Hutopy.Infrastructure.Identity.OwnedEntities.ProfileColors", "ProfileColors", b1 =>
b.OwnsOne("Hutopy.Infrastructure.Identity.ApplicationUser.ProfileColors#Hutopy.Infrastructure.Identity.OwnedEntities.ProfileColors", "ProfileColors", b1 =>
{
b1.Property<string>("ApplicationUserId")
.HasColumnType("nvarchar(450)");
@@ -424,7 +424,7 @@ namespace Hutopy.Infrastructure.Migrations
.HasForeignKey("ApplicationUserId");
});
b.OwnsOne("Hutopy.Infrastructure.Identity.OwnedEntities.SocialNetworks", "SocialNetworks", b1 =>
b.OwnsOne("Hutopy.Infrastructure.Identity.ApplicationUser.SocialNetworks#Hutopy.Infrastructure.Identity.OwnedEntities.SocialNetworks", "SocialNetworks", b1 =>
{
b1.Property<string>("ApplicationUserId")
.HasColumnType("nvarchar(450)");
@@ -469,7 +469,7 @@ namespace Hutopy.Infrastructure.Migrations
.HasForeignKey("ApplicationUserId");
});
b.OwnsOne("Hutopy.Infrastructure.Identity.OwnedEntities.StoredDataUrls", "StoredDataUrls", b1 =>
b.OwnsOne("Hutopy.Infrastructure.Identity.ApplicationUser.StoredDataUrls#Hutopy.Infrastructure.Identity.OwnedEntities.StoredDataUrls", "StoredDataUrls", b1 =>
{
b1.Property<string>("ApplicationUserId")
.HasColumnType("nvarchar(450)");

View File

@@ -19,19 +19,19 @@ public class UpdateMyUser : EndpointGroupBase
return await sender.Send(command);
}
private static async Task<IResult> UpdateCurrentUserProfilePicture(ISender sender, Stream stream, string url = "")
private static async Task<IResult> UpdateCurrentUserProfilePicture(ISender sender, MemoryStream stream, string url = "")
{
var command = new UploadProfilePictureCommand { ProfilePicture = stream, ProfilePictureUrl = url};
return await sender.Send(command);
}
private static async Task<IResult> UpdateCurrentUserBannerPicture(ISender sender, Stream stream, string url = "")
private static async Task<IResult> UpdateCurrentUserBannerPicture(ISender sender, MemoryStream stream, string url = "")
{
var command = new UploadBannerPictureCommand { BannerPicture = stream, BannerPictureUrl = url};
return await sender.Send(command);
}
private static async Task<IResult> UpdateCurrentUserWebsiteIcon(ISender sender, Stream stream, string url = "")
private static async Task<IResult> UpdateCurrentUserWebsiteIcon(ISender sender, MemoryStream stream, string url = "")
{
var command = new UploadWebsiteIconCommand { WebsiteIcon = stream, WebsitePictureUrl = url};
return await sender.Send(command);

View File

@@ -6,7 +6,7 @@ public class Content
public Guid CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? Title { get; init; }
public string? Description { get; init; }
public string? Uri { get; init; }
public string Title { get; set; }
public string Description { get; set; }
public string[]? Urls { get; init; }
}

View File

@@ -0,0 +1,114 @@
using System.Collections.Concurrent;
using FastEndpoints;
using FluentValidation;
using Hutopy.Application.AzureBlobStorage.Constants;
using Hutopy.Application.Common.Interfaces;
using Hutopy.Web.Common;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
public record PostContentRequest(
Guid Id,
Guid CreatorId,
string Title,
string Description,
IFormFileCollection Files);
public sealed class PostContentRequestValidator : Validator<PostContentRequest>
{
public PostContentRequestValidator()
{
RuleFor(r => r.Id)
.NotNull().WithMessage("You should specify the Id")
.NotEmpty().WithMessage("You should specify a valid/not empty Id");
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
RuleFor(r => r.Title)
.NotNull().WithMessage("You should specify the Title")
.NotEmpty().WithMessage("You should specify a valid/not empty Title");
RuleFor(r => r.Description)
.NotNull().WithMessage("You should specify the Description")
.NotEmpty().WithMessage("You should specify a valid/not empty Description");
}
}
public sealed class PostContent(
IAzureBlobStorageService blobStorage,
ContentDbContext context)
: Endpoint<PostContentRequest>
{
public override void Configure()
{
Post("/api/contents");
Options(o => o.WithTags("Contents"));
AllowFileUploads();
}
public override async Task HandleAsync(
PostContentRequest req,
CancellationToken ct)
{
var urls = new ConcurrentBag<string>();
await Parallel.ForEachAsync(
req.Files,
ct,
async (file, ict) =>
{
try
{
var contentUrl = await SaveFileAsync(
req.CreatorId,
req.Id,
file,
ict);
urls.Add(contentUrl);
}
catch (Exception ex)
{
Logger.LogError("{ErrorMessage}", ex.Message);
}
});
await context.Contents.AddAsync(
new()
{
Id = req.Id,
CreatedBy = User.GetUserId(),
Title = req.Title,
Description = req.Description,
Urls = urls.ToArray()
},
ct);
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
private async Task<string> SaveFileAsync(
Guid creatorId,
Guid contentId,
IFormFile file,
CancellationToken ct = default)
{
var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, ct);
// TODO: I would like us to use ContainerNames.Creators but it seems we are missing configurations @jbourdon
var url = await blobStorage.UploadFileAsync(
ContainerNames.Users,
$"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/{file.FileName}",
memoryStream,
file.ContentType,
ct: ct);
return url;
}
}

View File

@@ -3,11 +3,11 @@ using FluentValidation;
using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Common.Models;
namespace Hutopy.Web.Features.Creators.Handlers;
namespace Hutopy.Web.Features.Contents.Handlers;
public sealed class GetCreatorByAliasRequest
{
public string CreatorAlias { get; set; }
public string CreatorAlias { get; init; }
}
public sealed class GetCreatorByAliasRequestValidator
@@ -16,7 +16,8 @@ public sealed class GetCreatorByAliasRequestValidator
public GetCreatorByAliasRequestValidator()
{
RuleFor(r => r.CreatorAlias)
.NotNull().WithMessage("You must specify a creator-alias");
.NotNull().WithMessage("You should specify the CreatorAlias")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorAlias");
}
}

View File

@@ -1,39 +0,0 @@
using FastEndpoints;
using Hutopy.Web.Common;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
public record struct PostContentRequest(
string? Title,
string? Description,
string? Uri);
public class PostContent(
ContentDbContext context)
: Endpoint<PostContentRequest>
{
public override void Configure()
{
Post("/api/contents");
Options( o => o.WithTags("Contents"));
}
public override async Task HandleAsync(
PostContentRequest req,
CancellationToken ct)
{
await context.Contents.AddAsync(
new Content
{
Id = GuidHelper.GenerateUuidV7(),
CreatedBy = User.GetUserId(),
Title = req.Title,
Description = req.Description,
Uri = req.Uri
},
ct);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,59 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20240725022229_AddMultipleMediaUrlsToContent")]
partial class AddMultipleMediaUrlsToContent
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.ToTable("Contents", "Content");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Migrations
{
/// <inheritdoc />
public partial class AddMultipleMediaUrlsToContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Uri",
schema: "Content",
table: "Contents");
migrationBuilder.AddColumn<string[]>(
name: "Urls",
schema: "Content",
table: "Contents",
type: "text[]",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Urls",
schema: "Content",
table: "Contents");
migrationBuilder.AddColumn<string>(
name: "Uri",
schema: "Content",
table: "Contents",
type: "text",
nullable: true);
}
}
}

View File

@@ -23,7 +23,7 @@ namespace Hutopy.Web.Contents.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Contents.Data.Content", b =>
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -43,8 +43,8 @@ namespace Hutopy.Web.Contents.Migrations
b.Property<string>("Title")
.HasColumnType("text");
b.Property<string>("Uri")
.HasColumnType("text");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");

View File

@@ -12,6 +12,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" />
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="FastEndpoints" />