using Application.Abstractions.Messaging; using Application.Abstractions.Data; using SharedKernel.Results; using Microsoft.EntityFrameworkCore; namespace Application.Features.Admin.NavMenu.Update; public sealed class Handler(IAppDbContext db) : ICommandHandler { public async Task Handle(Command request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.Label)) { return Result.Failure(Error.Problem("NavMenu.LabelRequired", "메뉴 명은 필수입니다.")); } if (string.IsNullOrWhiteSpace(request.Href)) { return Result.Failure(Error.Problem("NavMenu.HrefRequired", "링크 주소는 필수입니다.")); } var label = request.Label.Trim(); var href = request.Href.Trim(); if (label.Length > 50) { return Result.Failure(Error.Problem("NavMenu.LabelTooLong", "메뉴 명은 50자 이하여야 합니다.")); } if (href.Length > 200 || !IsSafeHref(href)) { return Result.Failure(Error.Problem("NavMenu.HrefInvalid", "링크는 200자 이하이며 / 또는 http(s):// 로 시작해야 합니다.")); } var item = await db.NavMenuItem.FirstOrDefaultAsync(c => c.ID == request.ID, ct); if (item is null) { return Result.Failure(Error.NotFound("NavMenu.NotFound", "메뉴를 찾을 수 없습니다.")); } item.Update(label, href, request.SortOrder, request.IsVisible); await db.SaveChangesAsync(ct); return Result.Success(); } // 링크 스킴 허용목록 — 익명 렌더 XSS(javascript: 등) 차단. 내부 경로(/) 또는 http(s) 만 허용. private static bool IsSafeHref(string href) { return href.StartsWith('/') || href.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || href.StartsWith("https://", StringComparison.OrdinalIgnoreCase); } }