using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using Backend.Common; using Backend.Contracts; using Backend.Data; using Backend.Domain; using Microsoft.EntityFrameworkCore; namespace Backend.Endpoints; public static partial class PublicEndpoints { private static readonly Regex PublicEmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled); private static async Task GetSponsors(int year, AwardsDbContext db) { var season = await db.Seasons .AsNoTracking() .FirstOrDefaultAsync(item => item.Year == year); if (season is null) { return Results.NotFound(); } var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); if (settings is null || !settings.SponsorsVisible) { return Results.Ok(new PublicSponsorsResponse(year, [])); } var sponsors = await db.Sponsors .AsNoTracking() .Where(item => item.SeasonId == season.Id && item.IsVisible) .OrderBy(item => item.SortOrder) .ThenBy(item => item.Name) .Select(item => new SponsorDto( item.Id, item.SeasonId, item.Name, item.WebsiteUrl, item.LogoUrl, item.Description, item.Tier, item.SortOrder, item.IsVisible)) .ToArrayAsync(); return Results.Ok(new PublicSponsorsResponse(year, sponsors)); } private static readonly JsonSerializerOptions ShowactJsonOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; private static async Task CreateShowactApplication( HttpContext context, CreateShowactApplicationRequest request, AwardsDbContext db) { var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted); if (settings is null || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow))) { return Results.BadRequest(new { message = string.IsNullOrWhiteSpace(settings?.ShowactApplicationDisabledMessage) ? "Showact-Bewerbungen sind aktuell geschlossen." : settings.ShowactApplicationDisabledMessage, }); } var season = await db.Seasons.FirstOrDefaultAsync(item => item.IsCurrent, context.RequestAborted); if (season is null) { return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." }); } if (!IsBlankOrValidJsonObject(request.FieldResponsesJson)) { return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." }); } var schema = ParseShowactSchema(settings.ShowactFormSchemaJson); var hasDynamicForm = schema.Count > 0; if (hasDynamicForm) { Dictionary responses; try { responses = string.IsNullOrWhiteSpace(request.FieldResponsesJson) ? new Dictionary() : JsonSerializer.Deserialize>(request.FieldResponsesJson, ShowactJsonOptions) ?? new(); } catch (JsonException) { return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." }); } MergeLegacyShowactFieldsIntoResponses(schema, responses, request); var dynamicValidationError = ValidateShowactResponses(schema, responses); if (dynamicValidationError is not null) { return Results.BadRequest(new { message = dynamicValidationError }); } var artistNameField = schema.FirstOrDefault(f => f.IsArtistName); var artistName = artistNameField is not null && responses.TryGetValue(artistNameField.Id, out var name) ? name.Trim() : "Unbekannt"; var contactEmail = NormalizePublicText(request.ContactEmail, 180); var contactDiscord = NormalizePublicText(request.ContactDiscord, 120); var performanceType = NormalizePublicText(request.PerformanceType, 80); var description = NormalizePublicText(request.Description, 1000); var technicalNotes = NormalizePublicText(request.TechnicalNotes, 1000); var platformUrl = NormalizePublicText(request.PlatformUrl, 500); var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500); var fieldResponsesJson = JsonSerializer.Serialize(responses, ShowactJsonOptions); var metadata = RequestMetadataReader.Read(context); var application = new ShowactApplication { SeasonId = season.Id, ArtistName = artistName[..Math.Min(artistName.Length, 120)], ContactEmail = contactEmail, ContactDiscord = contactDiscord, PlatformUrl = platformUrl, PerformanceType = performanceType, Description = description, TechnicalNotes = technicalNotes, ReferenceUrl = referenceUrl, FieldResponsesJson = fieldResponsesJson, Status = "pending", CreatedFromIp = metadata.ClientIp, UserAgent = metadata.UserAgent, CreatedAt = DateTimeOffset.UtcNow, }; db.ShowactApplications.Add(application); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true, applicationId = application.Id }); } else { // Legacy fixed-fields path var artistName = NormalizePublicText(request.ArtistName, 120); var contactEmail = NormalizePublicText(request.ContactEmail, 180); var contactDiscord = NormalizePublicText(request.ContactDiscord, 120); var performanceType = NormalizePublicText(request.PerformanceType, 80); var description = NormalizePublicText(request.Description, 1000); var platformUrl = NormalizePublicText(request.PlatformUrl, 500); var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500); if (string.IsNullOrWhiteSpace(artistName)) { return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." }); } if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord)) { return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." }); } if (!IsBlankOrValidEmail(contactEmail)) { return Results.BadRequest(new { message = "E-Mail muss eine gueltige E-Mail-Adresse sein." }); } if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description)) { return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." }); } if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl)) { return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." }); } var metadata = RequestMetadataReader.Read(context); var application = new ShowactApplication { SeasonId = season.Id, ArtistName = artistName, ContactEmail = contactEmail, ContactDiscord = contactDiscord, PlatformUrl = platformUrl, PerformanceType = performanceType, Description = description, TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000), ReferenceUrl = referenceUrl, Status = "pending", CreatedFromIp = metadata.ClientIp, UserAgent = metadata.UserAgent, CreatedAt = DateTimeOffset.UtcNow, }; db.ShowactApplications.Add(application); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true, applicationId = application.Id }); } } private sealed class ShowactFieldDefinition { [JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("type")] public string Type { get; set; } = ""; [JsonPropertyName("label")] public string Label { get; set; } = ""; [JsonPropertyName("required")] public bool Required { get; set; } [JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; } [JsonPropertyName("maxLength")] public int MaxLength { get; set; } } private static string NormalizePublicText(string? value, int maxLength) { var trimmed = (value ?? string.Empty).Trim(); return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; } private static bool IsBlankOrHttpUrl(string value) => string.IsNullOrWhiteSpace(value) || (Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)); private static bool IsBlankOrValidEmail(string value) => string.IsNullOrWhiteSpace(value) || PublicEmailPattern.IsMatch(value); private static bool IsBlankOrValidJsonObject(string? value) { if (string.IsNullOrWhiteSpace(value)) { return true; } try { using var document = JsonDocument.Parse(value); return document.RootElement.ValueKind == JsonValueKind.Object; } catch (JsonException) { return false; } } private static List ParseShowactSchema(string? schemaJson) { if (string.IsNullOrWhiteSpace(schemaJson) || schemaJson == "[]") { return []; } try { return JsonSerializer.Deserialize>(schemaJson, ShowactJsonOptions)? .Where(field => !string.IsNullOrWhiteSpace(field.Id)) .ToList() ?? []; } catch (JsonException) { return []; } } private static string? ValidateShowactResponses( IReadOnlyCollection schema, IDictionary responses) { foreach (var field in schema) { var value = responses.TryGetValue(field.Id, out var rawValue) ? NormalizePublicText(rawValue, ResolveShowactMaxLength(field)) : string.Empty; responses[field.Id] = value; if (field.Required && string.IsNullOrWhiteSpace(value)) { return string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase) ? $"Bitte bestaetige: {field.Label}" : $"{field.Label} ist erforderlich."; } if (string.IsNullOrWhiteSpace(value)) { continue; } if (string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase) && !PublicEmailPattern.IsMatch(value)) { return $"{field.Label} muss eine gueltige E-Mail-Adresse sein."; } if (string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && !IsBlankOrHttpUrl(value)) { return $"{field.Label} muss ein gueltiger http(s)-Link sein."; } } return null; } private static int ResolveShowactMaxLength(ShowactFieldDefinition field) { if (field.MaxLength > 0) { return Math.Min(field.MaxLength, 2000); } return string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) ? 1000 : 500; } private static void MergeLegacyShowactFieldsIntoResponses( IReadOnlyCollection schema, IDictionary responses, CreateShowactApplicationRequest request) { var artistField = schema.FirstOrDefault(field => field.IsArtistName); MergeResponseValue(artistField, request.ArtistName, responses, 120); var emailField = schema.FirstOrDefault(field => string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase)); MergeResponseValue(emailField, request.ContactEmail, responses, 180); var discordField = schema.FirstOrDefault(field => ContainsAny(field, "discord")); MergeResponseValue(discordField, request.ContactDiscord, responses, 120); var platformField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "platform", "kanal", "profil", "channel")); MergeResponseValue(platformField, request.PlatformUrl, responses, 500); var referenceField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "referenz", "reference")); MergeResponseValue(referenceField, request.ReferenceUrl, responses, 500); var performanceField = schema.FirstOrDefault(field => ContainsAnyId(field, "performance_type", "showact_type", "show_type", "showact_roles", "roles") || ContainsAnyLabel(field, "performance", "showact-art", "showact art", "art des showacts", "wofür möchtest", "wofuer moechtest", "bewerben")); MergeResponseValue(performanceField, request.PerformanceType, responses, 80); var descriptionField = schema.FirstOrDefault(field => string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) && (ContainsAnyId(field, "description", "beschreibung", "show_description") || ContainsAnyLabel(field, "beschreibung", "idee", "was moechtest", "was möchtest", "zeigen"))); MergeResponseValue(descriptionField, request.Description, responses, 1000); var technicalNotesField = schema.FirstOrDefault(field => ContainsAny(field, "technical_notes", "technik", "technical", "setup", "timing")); MergeResponseValue(technicalNotesField, request.TechnicalNotes, responses, 1000); } private static void MergeResponseValue( ShowactFieldDefinition? field, string? requestValue, IDictionary responses, int maxLength) { if (field is null) { return; } if (responses.TryGetValue(field.Id, out var existingValue) && !string.IsNullOrWhiteSpace(existingValue)) { return; } var normalized = NormalizePublicText(requestValue, maxLength); if (!string.IsNullOrWhiteSpace(normalized)) { responses[field.Id] = normalized; } } private static bool ContainsAny(ShowactFieldDefinition field, params string[] needles) { var haystack = $"{field.Id} {field.Label}".ToLowerInvariant(); return needles.Any(haystack.Contains); } private static bool ContainsAnyId(ShowactFieldDefinition field, params string[] needles) { var haystack = field.Id.ToLowerInvariant(); return needles.Any(haystack.Contains); } private static bool ContainsAnyLabel(ShowactFieldDefinition field, params string[] needles) { var haystack = field.Label.ToLowerInvariant(); return needles.Any(haystack.Contains); } }