Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -459,4 +459,6 @@ src/emailpaywall.client/.svelte-kit

# Include example tfvars files
!**/*.tfvars.example
!**/terraform.tfvars.example
!**/terraform.tfvars.example

**/appsettings.Development.json
17 changes: 16 additions & 1 deletion deploy/Terraform/container-app.tf
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ resource "azurerm_container_app" "app" {
server = azurerm_container_registry.acr.login_server
identity = azurerm_user_assigned_identity.uami.id
}

depends_on = [azurerm_role_assignment.acr_pull, azurerm_mssql_database.db]

# needed for container app to access other Microsoft Entra protected resources
Expand Down Expand Up @@ -179,6 +179,16 @@ resource "azurerm_container_app" "app" {
name = "ASPNETCORE_ENVIRONMENT"
value = "Production"
}

env {
name = "AzureAIFoundry__ContentSafetyKey"
secret_name = "content-safety-key"
}

env {
name = "AzureAIFoundry__ContentSafetyEndpoint"
value = var.content_safety_api
}
}
}

Expand Down Expand Up @@ -216,4 +226,9 @@ resource "azurerm_container_app" "app" {
value = var.smtp_password
}

secret {
name = "content-safety-key"
value = var.content_safety_key
}

}
11 changes: 11 additions & 0 deletions deploy/Terraform/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,14 @@ variable "smtp_password" {
type = string
sensitive = true
}

variable "content_safety_key" {
description = "Azure AI foundry content safety key"
type = string
sensitive = true
}

variable "content_safety_api" {
description = "Azure AI foundry content safety api endpoint"
type = string
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ public interface IObjectStorageService {
Task<Uri> GetFileUri(string containerName, string fileName);
Task<BinaryData> GetFile(string containerName, string fileName);
Task<bool> IsFileExists(string containerName, string fileName);
Task<bool> PassesContentModeration(BinaryData binaryData);
}
6 changes: 6 additions & 0 deletions src/Evently.Server/Common/Domains/Models/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public sealed class Settings {
[NotMapped] public AuthSetting Authentication { get; init; } = new();

[NotMapped] public EmailSettings EmailSettings { get; init; } = new();
[NotMapped] public AzureAIFoundry AzureAiFoundry { get; init; } = new();
}

[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")]
Expand All @@ -32,4 +33,9 @@ public sealed class OAuthSetting {
public sealed class EmailSettings {
public string ActualFrom { get; init; } = string.Empty;
public string SmtpPassword { get; init; } = string.Empty;
}

public sealed class AzureAIFoundry {
public string ContentSafetyKey { get; init; } = string.Empty;
public string ContentSafetyEndpoint { get; init; } = string.Empty;
}
8 changes: 8 additions & 0 deletions src/Evently.Server/Common/Extensions/LoggerExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,12 @@ public static partial void LogSuccessEmail(
Message = "Error occurred at {context}: {errorMsg}")]
public static partial void LogErrorContext(
this ILogger logger, string context, string errorMsg);

[LoggerMessage(
EventId = 5,
Level = LogLevel.Error,
Message = "Analyze image failed. Status code: {statusCode}, Error code: {errorCode}, Error message: {errMsg}")]
public static partial void LogContentModerationError(
this ILogger logger, string statusCode, string errorCode, string errMsg);
//
}
1 change: 1 addition & 0 deletions src/Evently.Server/Evently.Server.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.AI.ContentSafety" Version="1.0.0"/>
<PackageReference Include="Azure.Storage.Blobs" Version="12.25.0"/>
<PackageReference Include="FluentValidation" Version="12.0.0"/>
<PackageReference Include="HtmlRenderer.PdfSharp" Version="1.5.0.6"/>
Expand Down
24 changes: 24 additions & 0 deletions src/Evently.Server/Features/Files/Services/ObjectStorageService.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using Azure;
using Azure.AI.ContentSafety;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Evently.Server.Common.Domains.Interfaces;
using Evently.Server.Common.Domains.Models;
using Evently.Server.Common.Extensions;
using Microsoft.Extensions.Options;

namespace Evently.Server.Features.Files.Services;
Expand All @@ -11,6 +13,9 @@ namespace Evently.Server.Features.Files.Services;
public sealed class ObjectStorageService(IOptions<Settings> settings, ILogger<ObjectStorageService> logger) : IObjectStorageService {
private readonly BlobServiceClient _blobServiceClient =
new(settings.Value.StorageAccount.AzureStorageConnectionString);
private readonly ContentSafetyClient _contentSafetyClient = new(
endpoint: new Uri(settings.Value.AzureAiFoundry.ContentSafetyEndpoint),
credential: new AzureKeyCredential(settings.Value.AzureAiFoundry.ContentSafetyKey));

public async Task<Uri> UploadFile(string containerName, string fileName, BinaryData binaryData,
string mimeType = "application/octet-stream") {
Expand Down Expand Up @@ -65,4 +70,23 @@ public async Task<BinaryData> GetFile(string containerName, string fileName) {
BinaryData data = BinaryData.FromBytes(bytes);
return data;
}

public async Task<bool> PassesContentModeration(BinaryData binaryData) {
ContentSafetyImageData image = new(binaryData);
AnalyzeImageOptions request = new(image);
Response<AnalyzeImageResult> response;
try {
response = await _contentSafetyClient.AnalyzeImageAsync(request);
} catch (RequestFailedException ex) {
logger.LogContentModerationError(ex.Status.ToString(), ex.ErrorCode ?? "", ex.Message);
throw;
}

AnalyzeImageResult result = response.Value;
int? score = result.CategoriesAnalysis
.Select(v => v.Severity)
.Aggregate((a, b) => a + b)
Copy link

Copilot AI Oct 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The aggregation logic assumes all severity values are integers that can be summed. Consider using Sum() instead of Aggregate((a, b) => a + b) for clarity and better null handling.

Suggested change
.Aggregate((a, b) => a + b)
.Sum()

Copilot uses AI. Check for mistakes.
?? 0;
Comment on lines +88 to +89
Copy link

Copilot AI Oct 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The null-conditional operator ?? is applied to the wrong expression. If result.CategoriesAnalysis is empty, Aggregate will throw an exception before the null check. Consider using DefaultIfEmpty() or checking if the collection is empty first.

Suggested change
.Aggregate((a, b) => a + b)
?? 0;
.DefaultIfEmpty(0)
.Aggregate((a, b) => a + b);

Copilot uses AI. Check for mistakes.
return score == 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ public async Task<ActionResult<Gathering>> CreateGathering([FromForm] GatheringR
}

if (coverImg != null) {
Uri uri = await UploadCoverImage(gatheringReqDto.GatheringId, coverImg: coverImg ?? throw new ArgumentNullException(nameof(coverImg)));
gatheringReqDto = gatheringReqDto with { CoverSrc = uri.AbsoluteUri };
string uri = await UploadCoverImage(gatheringReqDto.GatheringId, coverImg);
gatheringReqDto = gatheringReqDto with { CoverSrc = uri };
}

Gathering gathering = await gatheringService.CreateGathering(gatheringReqDto);
Expand All @@ -88,9 +88,8 @@ public async Task<ActionResult> UpdateGathering(long gatheringId, [FromForm] Gat
}

if (coverImg != null) {
Uri uri = await UploadCoverImage(gatheringReqDto.GatheringId, coverImg);
gathering.CoverSrc = uri.AbsoluteUri;
gatheringReqDto = gatheringReqDto with { CoverSrc = uri.AbsoluteUri };
string uri = await UploadCoverImage(gatheringReqDto.GatheringId, coverImg);
gatheringReqDto = gatheringReqDto with { CoverSrc = uri };
}

gathering = await gatheringService.UpdateGathering(gatheringId, gatheringReqDto);
Expand All @@ -112,12 +111,17 @@ public async Task<ActionResult<Gathering>> DeleteGathering(long gatheringId) {
return NoContent();
}

private async Task<Uri> UploadCoverImage(long gatheringId, IFormFile coverImg) {
private async Task<string> UploadCoverImage(long gatheringId, IFormFile coverImg) {
string fileName = $"gatherings/{gatheringId}/cover-image{Path.GetExtension(coverImg.FileName)}";
BinaryData binaryData = await coverImg.ToBinaryData();
return await objectStorageService.UploadFile(_containerName,
bool isContentSafe = await objectStorageService.PassesContentModeration(binaryData);
if (!isContentSafe) {
return string.Empty;
}
Uri uri = await objectStorageService.UploadFile(_containerName,
fileName,
binaryData,
mimeType: MimeTypes.GetMimeType(coverImg.FileName));
return uri.AbsoluteUri;
}
}
26 changes: 0 additions & 26 deletions src/Evently.Server/appsettings.Development.json

This file was deleted.

23 changes: 22 additions & 1 deletion src/Evently.Server/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,26 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"ConnectionStrings": {
"WebApiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Database=evently;Integrated Security=True;Persist Security Info=False;Pooling=False;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Application Name=\"SQL Server Management Studio\";Command Timeout=0;"
},
"StorageAccount": {
"AccountName": "evently-dev-images",
"AzureStorageConnectionString": "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;"
},
"AzureAIFoundry": {
"ContentSafetyKey": "<ai-foundry-content-safety-key>",
"ContentSafetyEndpoint": "<ai-foundry-content-safety-api>"
},
"Authentication": {
"Google": {
"ClientId": "<your-google-client-id>",
"ClientSecret": "<your-google-client-secret>"
}
},
"EmailSettings": {
"ActualFrom": "<your-email@example.com>",
"SmtpPassword": "<your-app-password>"
}
}
7 changes: 4 additions & 3 deletions src/evently.client/src/lib/components/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ export function Card({ gathering, accountId }: CardProps): JSX.Element {
(detail) => detail.category
);

// ignore img-src for now
const hash: number = hashString(gathering.name);
imgSrc = hash % 2 === 0 ? Placeholder1 : Placeholder2;
if (imgSrc == null || imgSrc.length === 0) {
const hash: number = hashString(gathering.name);
imgSrc = hash % 2 === 0 ? Placeholder1 : Placeholder2;
}

if (title.length > 30) {
title = title.substring(0, 30) + "...";
Expand Down
4 changes: 4 additions & 0 deletions src/evently.client/src/lib/services/gathering-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ function toFormData(gatheringDto: GatheringReqDto, coverImg?: File | null): Form
formData.set(key, value);
}
}

formData.delete("gatheringCategoryDetails");
if (gatheringDto.gatheringCategoryDetails.length === 0) {
formData.set("gatheringCategoryDetails", "[]");
}

for (let i = 0; i < gatheringDto.gatheringCategoryDetails.length; i++) {
const detail: GatheringCategoryDetailReqDto = gatheringDto.gatheringCategoryDetails[i];
Expand Down
Loading