서버 게시글
This commit is contained in:
106
GameServer/Controllers/Portfolio/PortfolioAuthController.cs
Normal file
106
GameServer/Controllers/Portfolio/PortfolioAuthController.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using GameServer.Models;
|
||||
using GameServer.Models.Portfolio;
|
||||
using GameServer.Services;
|
||||
|
||||
namespace GameServer.Controllers.Portfolio
|
||||
{
|
||||
/// <summary>
|
||||
/// 포트폴리오 관리자 로그인 / 로그아웃 / 세션 확인 API.
|
||||
/// 게임 API(/myGame/*) 와는 완전히 분리된 /api/portfolio/auth 경로를 사용한다.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/portfolio/auth")]
|
||||
public class PortfolioAuthController : ControllerBase
|
||||
{
|
||||
private readonly PortfolioAdminAuth _auth;
|
||||
|
||||
public PortfolioAuthController(PortfolioAdminAuth auth)
|
||||
{
|
||||
_auth = auth;
|
||||
}
|
||||
|
||||
/// <summary>비밀번호로 로그인하고 관리자 쿠키를 발급한다.</summary>
|
||||
[HttpPost("login")]
|
||||
[EnableRateLimiting("portfolio-login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequestDto request)
|
||||
{
|
||||
// 비밀번호가 아직 설정되지 않았다면 503 으로 안내한다.
|
||||
if (!_auth.IsConfigured)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new ApiResponse<AuthStateDto>
|
||||
{
|
||||
Data = new AuthStateDto { Authenticated = false },
|
||||
Message = "관리자 비밀번호가 설정되지 않았습니다. appsettings.json의 Portfolio:AdminPassword 를 변경해 주세요."
|
||||
});
|
||||
}
|
||||
|
||||
if (!_auth.Verify(request?.Password))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status401Unauthorized, new ApiResponse<AuthStateDto>
|
||||
{
|
||||
Data = new AuthStateDto { Authenticated = false },
|
||||
Message = "비밀번호가 올바르지 않습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// 관리자 신원 발급 (7일 유지)
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.Name, "admin"),
|
||||
new Claim(ClaimTypes.Role, "admin")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, PortfolioAdminAuth.CookieScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var props = new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = true,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddDays(7)
|
||||
};
|
||||
|
||||
await HttpContext.SignInAsync(PortfolioAdminAuth.CookieScheme, principal, props);
|
||||
|
||||
return Ok(new ApiResponse<AuthStateDto>
|
||||
{
|
||||
Data = new AuthStateDto { Authenticated = true },
|
||||
Message = "로그인되었습니다."
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>관리자 쿠키를 만료시킨다. 언제 호출해도 200.</summary>
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync(PortfolioAdminAuth.CookieScheme);
|
||||
|
||||
return Ok(new ApiResponse<AuthStateDto>
|
||||
{
|
||||
Data = new AuthStateDto { Authenticated = false },
|
||||
Message = "로그아웃되었습니다."
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 로그인 상태를 알려준다. 절대 401 을 돌려주지 않는다.
|
||||
/// Message 에는 비밀번호 설정 여부("configured" / "not-configured")를 담아
|
||||
/// 관리자 화면이 안내 문구를 띄울 수 있게 한다.
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
public async Task<IActionResult> Me()
|
||||
{
|
||||
var result = await HttpContext.AuthenticateAsync(PortfolioAdminAuth.CookieScheme);
|
||||
bool authenticated = result.Succeeded && result.Principal?.Identity?.IsAuthenticated == true;
|
||||
|
||||
return Ok(new ApiResponse<AuthStateDto>
|
||||
{
|
||||
Data = new AuthStateDto { Authenticated = authenticated },
|
||||
Message = _auth.IsConfigured ? "configured" : "not-configured"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
291
GameServer/Controllers/Portfolio/PortfolioCategoryController.cs
Normal file
291
GameServer/Controllers/Portfolio/PortfolioCategoryController.cs
Normal file
@@ -0,0 +1,291 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameServer.Data; // AppDbContext
|
||||
using GameServer.Models; // ApiResponse<T>
|
||||
using GameServer.Models.Portfolio; // 포트폴리오 모델 / DTO
|
||||
using GameServer.Services; // PortfolioAdminAuth
|
||||
|
||||
namespace GameServer.Controllers.Portfolio
|
||||
{
|
||||
// 포트폴리오 카테고리 API
|
||||
// 목록 조회는 누구나 가능하고, 생성/수정/삭제는 관리자 쿠키 인증이 필요합니다.
|
||||
[ApiController]
|
||||
[Route("api/portfolio/categories")]
|
||||
public class PortfolioCategoryController : ControllerBase
|
||||
{
|
||||
// 슬러그는 영문 소문자, 숫자, 하이픈만 허용합니다.
|
||||
private static readonly Regex SlugPattern =
|
||||
new Regex("^[a-z0-9-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private const int SlugMaxLength = 64;
|
||||
private const int NameMaxLength = 100;
|
||||
private const int DescriptionMaxLength = 255;
|
||||
private const int AccentMaxLength = 32;
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public PortfolioCategoryController(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GET /api/portfolio/categories (공개)
|
||||
// 정렬 순서(SortOrder) → 카테고리 번호(CategoryNo) 순으로 반환합니다.
|
||||
// 게시물 수는 그룹 쿼리 한 번으로 구한 뒤 메모리에서 합칩니다. (N+1 없음)
|
||||
// ------------------------------------------------------------------
|
||||
[HttpGet("")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetCategories()
|
||||
{
|
||||
List<PortfolioCategoryModel> categories = await _context.PortfolioCategories
|
||||
.AsNoTracking()
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.ThenBy(c => c.CategoryNo)
|
||||
.ToListAsync();
|
||||
|
||||
Dictionary<int, int> postCounts = await LoadPostCountsAsync();
|
||||
|
||||
List<CategoryDto> list = new List<CategoryDto>(categories.Count);
|
||||
foreach (PortfolioCategoryModel category in categories)
|
||||
{
|
||||
int postCount = postCounts.TryGetValue(category.CategoryNo, out int found) ? found : 0;
|
||||
list.Add(ToDto(category, postCount));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<List<CategoryDto>>
|
||||
{
|
||||
Data = list,
|
||||
Count = list.Count,
|
||||
Message = "Success"
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// POST /api/portfolio/categories (관리자)
|
||||
// ------------------------------------------------------------------
|
||||
[HttpPost("")]
|
||||
[Authorize(Policy = PortfolioAdminAuth.PolicyName)]
|
||||
public async Task<IActionResult> CreateCategory([FromBody] CategoryUpsertDto? dto)
|
||||
{
|
||||
if (dto == null)
|
||||
{
|
||||
return BadRequest(Error("요청 본문이 비어 있습니다."));
|
||||
}
|
||||
|
||||
var input = NormalizeCategory(dto);
|
||||
if (input.Error != null)
|
||||
{
|
||||
return BadRequest(Error(input.Error));
|
||||
}
|
||||
|
||||
bool duplicated = await _context.PortfolioCategories
|
||||
.AnyAsync(c => c.Slug == input.Slug);
|
||||
if (duplicated)
|
||||
{
|
||||
return Conflict(Error("이미 사용 중인 슬러그입니다."));
|
||||
}
|
||||
|
||||
PortfolioCategoryModel entity = new PortfolioCategoryModel
|
||||
{
|
||||
Slug = input.Slug,
|
||||
Name = input.Name,
|
||||
Description = input.Description,
|
||||
Accent = input.Accent,
|
||||
SortOrder = dto.SortOrder,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.PortfolioCategories.Add(entity);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new ApiResponse<CategoryDto>
|
||||
{
|
||||
Data = ToDto(entity, 0),
|
||||
Message = "카테고리를 만들었습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// PUT /api/portfolio/categories/{categoryNo} (관리자)
|
||||
// ------------------------------------------------------------------
|
||||
[HttpPut("{categoryNo:int}")]
|
||||
[Authorize(Policy = PortfolioAdminAuth.PolicyName)]
|
||||
public async Task<IActionResult> UpdateCategory(int categoryNo, [FromBody] CategoryUpsertDto? dto)
|
||||
{
|
||||
if (dto == null)
|
||||
{
|
||||
return BadRequest(Error("요청 본문이 비어 있습니다."));
|
||||
}
|
||||
|
||||
var input = NormalizeCategory(dto);
|
||||
if (input.Error != null)
|
||||
{
|
||||
return BadRequest(Error(input.Error));
|
||||
}
|
||||
|
||||
PortfolioCategoryModel? entity = await _context.PortfolioCategories
|
||||
.FirstOrDefaultAsync(c => c.CategoryNo == categoryNo);
|
||||
if (entity == null)
|
||||
{
|
||||
return NotFound(Error("카테고리를 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
// 다른 행이 같은 슬러그를 이미 쓰고 있으면 충돌입니다.
|
||||
bool duplicated = await _context.PortfolioCategories
|
||||
.AnyAsync(c => c.Slug == input.Slug && c.CategoryNo != categoryNo);
|
||||
if (duplicated)
|
||||
{
|
||||
return Conflict(Error("이미 사용 중인 슬러그입니다."));
|
||||
}
|
||||
|
||||
entity.Slug = input.Slug;
|
||||
entity.Name = input.Name;
|
||||
entity.Description = input.Description;
|
||||
entity.Accent = input.Accent;
|
||||
entity.SortOrder = dto.SortOrder;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
int postCount = await _context.PortfolioPosts
|
||||
.CountAsync(p => p.CategoryNo == categoryNo);
|
||||
|
||||
return Ok(new ApiResponse<CategoryDto>
|
||||
{
|
||||
Data = ToDto(entity, postCount),
|
||||
Message = "카테고리를 수정했습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// DELETE /api/portfolio/categories/{categoryNo} (관리자)
|
||||
// 게시물이 남아 있으면 409 로 막습니다. (DB 의 외래키도 RESTRICT)
|
||||
// ------------------------------------------------------------------
|
||||
[HttpDelete("{categoryNo:int}")]
|
||||
[Authorize(Policy = PortfolioAdminAuth.PolicyName)]
|
||||
public async Task<IActionResult> DeleteCategory(int categoryNo)
|
||||
{
|
||||
PortfolioCategoryModel? entity = await _context.PortfolioCategories
|
||||
.FirstOrDefaultAsync(c => c.CategoryNo == categoryNo);
|
||||
if (entity == null)
|
||||
{
|
||||
return NotFound(Error("카테고리를 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
bool hasPosts = await _context.PortfolioPosts
|
||||
.AnyAsync(p => p.CategoryNo == categoryNo);
|
||||
if (hasPosts)
|
||||
{
|
||||
return Conflict(Error("이 카테고리에 게시물이 남아 있습니다."));
|
||||
}
|
||||
|
||||
_context.PortfolioCategories.Remove(entity);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new ApiResponse<object>
|
||||
{
|
||||
Data = null,
|
||||
Message = "카테고리를 삭제했습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 내부 도우미
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// 카테고리별 게시물 수를 그룹 쿼리 한 번으로 가져옵니다.
|
||||
private async Task<Dictionary<int, int>> LoadPostCountsAsync()
|
||||
{
|
||||
var grouped = await _context.PortfolioPosts
|
||||
.AsNoTracking()
|
||||
.GroupBy(p => p.CategoryNo)
|
||||
.Select(g => new { CategoryNo = g.Key, Total = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
Dictionary<int, int> map = new Dictionary<int, int>();
|
||||
foreach (var row in grouped)
|
||||
{
|
||||
map[row.CategoryNo] = row.Total;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static CategoryDto ToDto(PortfolioCategoryModel model, int postCount)
|
||||
{
|
||||
return new CategoryDto
|
||||
{
|
||||
CategoryNo = model.CategoryNo,
|
||||
Slug = model.Slug,
|
||||
Name = model.Name,
|
||||
Description = model.Description,
|
||||
Accent = model.Accent,
|
||||
SortOrder = model.SortOrder,
|
||||
PostCount = postCount
|
||||
};
|
||||
}
|
||||
|
||||
// 들어온 값을 다듬고 검증합니다. Error 가 null 이 아니면 400 으로 응답합니다.
|
||||
private static (string? Error, string Slug, string Name, string? Description, string? Accent)
|
||||
NormalizeCategory(CategoryUpsertDto dto)
|
||||
{
|
||||
string slug = Clean(dto.Slug) ?? string.Empty;
|
||||
string name = Clean(dto.Name) ?? string.Empty;
|
||||
string? description = Clean(dto.Description);
|
||||
string? accent = Clean(dto.Accent);
|
||||
|
||||
if (slug.Length == 0)
|
||||
{
|
||||
return ("슬러그를 입력해 주세요.", slug, name, description, accent);
|
||||
}
|
||||
if (slug.Length > SlugMaxLength)
|
||||
{
|
||||
return ($"슬러그는 {SlugMaxLength}자 이하여야 합니다.", slug, name, description, accent);
|
||||
}
|
||||
if (!SlugPattern.IsMatch(slug))
|
||||
{
|
||||
return ("슬러그는 영문 소문자, 숫자, 하이픈(-)만 사용할 수 있습니다.", slug, name, description, accent);
|
||||
}
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return ("이름을 입력해 주세요.", slug, name, description, accent);
|
||||
}
|
||||
if (name.Length > NameMaxLength)
|
||||
{
|
||||
return ($"이름은 {NameMaxLength}자 이하여야 합니다.", slug, name, description, accent);
|
||||
}
|
||||
if (description != null && description.Length > DescriptionMaxLength)
|
||||
{
|
||||
return ($"설명은 {DescriptionMaxLength}자 이하여야 합니다.", slug, name, description, accent);
|
||||
}
|
||||
if (accent != null && accent.Length > AccentMaxLength)
|
||||
{
|
||||
return ($"강조 색상은 {AccentMaxLength}자 이하여야 합니다.", slug, name, description, accent);
|
||||
}
|
||||
|
||||
return (null, slug, name, description, accent);
|
||||
}
|
||||
|
||||
// 앞뒤 공백을 제거하고, 빈 문자열은 null 로 바꿉니다.
|
||||
private static string? Clean(string? value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string trimmed = value.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
// 오류 응답도 ApiResponse<T> 형태를 유지합니다.
|
||||
private static ApiResponse<object> Error(string message)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
Data = null,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
575
GameServer/Controllers/Portfolio/PortfolioPostController.cs
Normal file
575
GameServer/Controllers/Portfolio/PortfolioPostController.cs
Normal file
@@ -0,0 +1,575 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GameServer.Data; // AppDbContext
|
||||
using GameServer.Models; // ApiResponse<T>
|
||||
using GameServer.Models.Portfolio; // 포트폴리오 모델 / DTO
|
||||
using GameServer.Services; // PortfolioAdminAuth
|
||||
|
||||
namespace GameServer.Controllers.Portfolio
|
||||
{
|
||||
// 포트폴리오 게시물 API
|
||||
// 조회는 누구나 가능하고, 생성/수정/삭제는 관리자 쿠키 인증이 필요합니다.
|
||||
[ApiController]
|
||||
[Route("api/portfolio/posts")]
|
||||
public class PortfolioPostController : ControllerBase
|
||||
{
|
||||
private const int DefaultPageSize = 12;
|
||||
private const int MinPageSize = 1;
|
||||
private const int MaxPageSize = 50;
|
||||
|
||||
private const int TitleMaxLength = 200;
|
||||
private const int SummaryMaxLength = 500;
|
||||
private const int ThumbnailUrlMaxLength = 500;
|
||||
private const int ImageUrlMaxLength = 500;
|
||||
private const int CaptionMaxLength = 255;
|
||||
|
||||
// 업로드된 이미지가 놓이는 사이트 내부 경로 접두사.
|
||||
private const string UploadsPrefix = "/uploads/";
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public PortfolioPostController(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GET /api/portfolio/posts?category=&page=&size= (공개)
|
||||
// Count 에는 현재 페이지 길이가 아니라 조건에 맞는 "전체" 개수를 담습니다.
|
||||
// 정렬은 CreatedAt 내림차순 → PostNo 내림차순.
|
||||
// ------------------------------------------------------------------
|
||||
[HttpGet("")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetPosts(
|
||||
[FromQuery] string? category = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int size = DefaultPageSize)
|
||||
{
|
||||
// 페이지 값 보정
|
||||
if (page < 1)
|
||||
{
|
||||
page = 1;
|
||||
}
|
||||
if (size < MinPageSize)
|
||||
{
|
||||
size = MinPageSize;
|
||||
}
|
||||
if (size > MaxPageSize)
|
||||
{
|
||||
size = MaxPageSize;
|
||||
}
|
||||
|
||||
// 카테고리 슬러그 필터: 비어 있거나 all 이면 필터를 걸지 않습니다.
|
||||
int? categoryNo = null;
|
||||
string? slug = Clean(category);
|
||||
if (slug != null && !string.Equals(slug, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int? found = await _context.PortfolioCategories
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Slug == slug)
|
||||
.Select(c => (int?)c.CategoryNo)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (found == null)
|
||||
{
|
||||
// 존재하지 않는 슬러그는 404 가 아니라 "빈 페이지" 로 돌려줍니다.
|
||||
return Ok(new ApiResponse<List<PostSummaryDto>>
|
||||
{
|
||||
Data = new List<PostSummaryDto>(),
|
||||
Count = 0,
|
||||
Message = "존재하지 않는 카테고리입니다."
|
||||
});
|
||||
}
|
||||
|
||||
categoryNo = found.Value;
|
||||
}
|
||||
|
||||
IQueryable<PortfolioPostModel> query = _context.PortfolioPosts.AsNoTracking();
|
||||
if (categoryNo.HasValue)
|
||||
{
|
||||
int filter = categoryNo.Value;
|
||||
query = query.Where(p => p.CategoryNo == filter);
|
||||
}
|
||||
|
||||
int total = await query.CountAsync();
|
||||
|
||||
List<PortfolioPostModel> posts = await query
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.ThenByDescending(p => p.PostNo)
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToListAsync();
|
||||
|
||||
List<PostSummaryDto> list = new List<PostSummaryDto>(posts.Count);
|
||||
|
||||
if (posts.Count > 0)
|
||||
{
|
||||
// 이미지 개수는 그룹 쿼리 한 번, 카테고리는 IN 조회 한 번으로 끝냅니다. (N+1 없음)
|
||||
List<int> postNos = posts.Select(p => p.PostNo).ToList();
|
||||
List<int> categoryNos = posts.Select(p => p.CategoryNo).Distinct().ToList();
|
||||
|
||||
var groupedImages = await _context.PortfolioPostImages
|
||||
.AsNoTracking()
|
||||
.Where(i => postNos.Contains(i.PostNo))
|
||||
.GroupBy(i => i.PostNo)
|
||||
.Select(g => new { PostNo = g.Key, Total = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
Dictionary<int, int> imageCounts = new Dictionary<int, int>();
|
||||
foreach (var row in groupedImages)
|
||||
{
|
||||
imageCounts[row.PostNo] = row.Total;
|
||||
}
|
||||
|
||||
List<PortfolioCategoryModel> categories = await _context.PortfolioCategories
|
||||
.AsNoTracking()
|
||||
.Where(c => categoryNos.Contains(c.CategoryNo))
|
||||
.ToListAsync();
|
||||
|
||||
Dictionary<int, PortfolioCategoryModel> categoryMap =
|
||||
new Dictionary<int, PortfolioCategoryModel>();
|
||||
foreach (PortfolioCategoryModel c in categories)
|
||||
{
|
||||
categoryMap[c.CategoryNo] = c;
|
||||
}
|
||||
|
||||
// 여기서부터는 메모리 조인입니다.
|
||||
foreach (PortfolioPostModel post in posts)
|
||||
{
|
||||
categoryMap.TryGetValue(post.CategoryNo, out PortfolioCategoryModel? postCategory);
|
||||
int imageCount = imageCounts.TryGetValue(post.PostNo, out int n) ? n : 0;
|
||||
list.Add(ToSummary(post, postCategory, imageCount));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<List<PostSummaryDto>>
|
||||
{
|
||||
Data = list,
|
||||
Count = total, // 페이지 길이가 아니라 전체 개수입니다.
|
||||
Message = "Success"
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GET /api/portfolio/posts/{postNo} (공개)
|
||||
// ------------------------------------------------------------------
|
||||
[HttpGet("{postNo:int}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetPost(int postNo)
|
||||
{
|
||||
PortfolioPostModel? post = await _context.PortfolioPosts
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.PostNo == postNo);
|
||||
|
||||
if (post == null)
|
||||
{
|
||||
return NotFound(Error("게시물을 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
PortfolioCategoryModel? category = await _context.PortfolioCategories
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CategoryNo == post.CategoryNo);
|
||||
|
||||
List<PortfolioPostImageModel> images = await LoadImagesAsync(postNo, tracking: false);
|
||||
|
||||
return Ok(new ApiResponse<PostDetailDto>
|
||||
{
|
||||
Data = ToDetail(post, category, images),
|
||||
Message = "Success"
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// POST /api/portfolio/posts (관리자)
|
||||
// 게시물과 이미지 행을 함께 만듭니다.
|
||||
// ------------------------------------------------------------------
|
||||
[HttpPost("")]
|
||||
[Authorize(Policy = PortfolioAdminAuth.PolicyName)]
|
||||
public async Task<IActionResult> CreatePost([FromBody] PostUpsertDto? dto)
|
||||
{
|
||||
if (dto == null)
|
||||
{
|
||||
return BadRequest(Error("요청 본문이 비어 있습니다."));
|
||||
}
|
||||
|
||||
var input = NormalizePost(dto);
|
||||
if (input.Error != null)
|
||||
{
|
||||
return BadRequest(Error(input.Error));
|
||||
}
|
||||
|
||||
PortfolioCategoryModel? category = await _context.PortfolioCategories
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CategoryNo == dto.CategoryNo);
|
||||
if (category == null)
|
||||
{
|
||||
return BadRequest(Error("존재하지 않는 카테고리입니다."));
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
|
||||
PortfolioPostModel post = new PortfolioPostModel
|
||||
{
|
||||
CategoryNo = category.CategoryNo,
|
||||
Title = input.Title,
|
||||
Summary = input.Summary,
|
||||
ContentMd = input.ContentMd,
|
||||
ThumbnailUrl = input.ThumbnailUrl,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
// 이미지 행은 자동 증가로 생성되는 PostNo 를 알아야 채울 수 있으므로
|
||||
// 저장을 두 단계로 나눕니다. 트랜잭션으로 묶어 원자성을 지킵니다.
|
||||
using (var transaction = await _context.Database.BeginTransactionAsync())
|
||||
{
|
||||
_context.PortfolioPosts.Add(post);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
if (input.Images.Count > 0)
|
||||
{
|
||||
foreach (PortfolioPostImageModel image in input.Images)
|
||||
{
|
||||
image.PostNo = post.PostNo;
|
||||
image.CreatedAt = now;
|
||||
}
|
||||
|
||||
_context.PortfolioPostImages.AddRange(input.Images);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
List<PortfolioPostImageModel> saved = SortImages(input.Images);
|
||||
|
||||
return Ok(new ApiResponse<PostDetailDto>
|
||||
{
|
||||
Data = ToDetail(post, category, saved),
|
||||
Message = "게시물을 등록했습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// PUT /api/portfolio/posts/{postNo} (관리자)
|
||||
// 스칼라 필드를 바꾸고 이미지 목록은 통째로 교체합니다.
|
||||
// CreatedAt 은 그대로 두고 UpdatedAt 만 갱신합니다.
|
||||
// ------------------------------------------------------------------
|
||||
[HttpPut("{postNo:int}")]
|
||||
[Authorize(Policy = PortfolioAdminAuth.PolicyName)]
|
||||
public async Task<IActionResult> UpdatePost(int postNo, [FromBody] PostUpsertDto? dto)
|
||||
{
|
||||
if (dto == null)
|
||||
{
|
||||
return BadRequest(Error("요청 본문이 비어 있습니다."));
|
||||
}
|
||||
|
||||
var input = NormalizePost(dto);
|
||||
if (input.Error != null)
|
||||
{
|
||||
return BadRequest(Error(input.Error));
|
||||
}
|
||||
|
||||
PortfolioPostModel? post = await _context.PortfolioPosts
|
||||
.FirstOrDefaultAsync(p => p.PostNo == postNo);
|
||||
if (post == null)
|
||||
{
|
||||
return NotFound(Error("게시물을 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
PortfolioCategoryModel? category = await _context.PortfolioCategories
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CategoryNo == dto.CategoryNo);
|
||||
if (category == null)
|
||||
{
|
||||
return BadRequest(Error("존재하지 않는 카테고리입니다."));
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
|
||||
post.CategoryNo = category.CategoryNo;
|
||||
post.Title = input.Title;
|
||||
post.Summary = input.Summary;
|
||||
post.ContentMd = input.ContentMd;
|
||||
post.ThumbnailUrl = input.ThumbnailUrl;
|
||||
post.UpdatedAt = now; // CreatedAt 은 건드리지 않습니다.
|
||||
|
||||
// 기존 이미지 행을 지우고 새 목록을 넣습니다.
|
||||
// PostNo 를 이미 알고 있으므로 한 번의 SaveChangesAsync 안에서 원자적으로 처리됩니다.
|
||||
List<PortfolioPostImageModel> existing = await LoadImagesAsync(postNo, tracking: true);
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
_context.PortfolioPostImages.RemoveRange(existing);
|
||||
}
|
||||
|
||||
if (input.Images.Count > 0)
|
||||
{
|
||||
foreach (PortfolioPostImageModel image in input.Images)
|
||||
{
|
||||
image.PostNo = postNo;
|
||||
image.CreatedAt = now;
|
||||
}
|
||||
|
||||
_context.PortfolioPostImages.AddRange(input.Images);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
List<PortfolioPostImageModel> saved = SortImages(input.Images);
|
||||
|
||||
return Ok(new ApiResponse<PostDetailDto>
|
||||
{
|
||||
Data = ToDetail(post, category, saved),
|
||||
Message = "게시물을 수정했습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// DELETE /api/portfolio/posts/{postNo} (관리자)
|
||||
// 이미지 행을 먼저 지우고 게시물을 지웁니다.
|
||||
// ------------------------------------------------------------------
|
||||
[HttpDelete("{postNo:int}")]
|
||||
[Authorize(Policy = PortfolioAdminAuth.PolicyName)]
|
||||
public async Task<IActionResult> DeletePost(int postNo)
|
||||
{
|
||||
PortfolioPostModel? post = await _context.PortfolioPosts
|
||||
.FirstOrDefaultAsync(p => p.PostNo == postNo);
|
||||
if (post == null)
|
||||
{
|
||||
return NotFound(Error("게시물을 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
// 이미지 → 게시물 순서로 저장을 두 번 나눈다.
|
||||
// 모델에 탐색 속성이 없어 EF 는 두 테이블의 의존 관계를 모른다.
|
||||
// 한 번의 SaveChanges 로 묶으면 게시물 DELETE 가 먼저 나갈 수 있고,
|
||||
// 그러면 DB 의 ON DELETE CASCADE 가 이미지 행을 이미 지워 버려서
|
||||
// 뒤따르는 이미지 DELETE 가 0행이 되고 DbUpdateConcurrencyException 이 난다.
|
||||
await using var tx = await _context.Database.BeginTransactionAsync();
|
||||
|
||||
List<PortfolioPostImageModel> images = await LoadImagesAsync(postNo, tracking: true);
|
||||
if (images.Count > 0)
|
||||
{
|
||||
_context.PortfolioPostImages.RemoveRange(images);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_context.PortfolioPosts.Remove(post);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Ok(new ApiResponse<object>
|
||||
{
|
||||
Data = null,
|
||||
Message = "게시물을 삭제했습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 내부 도우미
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// 게시물 이미지들을 SortOrder → ImageNo 순으로 읽습니다.
|
||||
private async Task<List<PortfolioPostImageModel>> LoadImagesAsync(int postNo, bool tracking)
|
||||
{
|
||||
IQueryable<PortfolioPostImageModel> query = _context.PortfolioPostImages;
|
||||
if (!tracking)
|
||||
{
|
||||
query = query.AsNoTracking();
|
||||
}
|
||||
|
||||
return await query
|
||||
.Where(i => i.PostNo == postNo)
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ThenBy(i => i.ImageNo)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// 방금 저장한 이미지 목록을 응답용 순서로 정렬합니다.
|
||||
private static List<PortfolioPostImageModel> SortImages(List<PortfolioPostImageModel> images)
|
||||
{
|
||||
return images
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ThenBy(i => i.ImageNo)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static PostSummaryDto ToSummary(
|
||||
PortfolioPostModel post,
|
||||
PortfolioCategoryModel? category,
|
||||
int imageCount)
|
||||
{
|
||||
return new PostSummaryDto
|
||||
{
|
||||
PostNo = post.PostNo,
|
||||
CategoryNo = post.CategoryNo,
|
||||
CategorySlug = category?.Slug ?? string.Empty,
|
||||
CategoryName = category?.Name ?? string.Empty,
|
||||
CategoryAccent = category?.Accent,
|
||||
Title = post.Title,
|
||||
Summary = post.Summary,
|
||||
ThumbnailUrl = post.ThumbnailUrl,
|
||||
ImageCount = imageCount,
|
||||
CreatedAt = post.CreatedAt,
|
||||
UpdatedAt = post.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static PostDetailDto ToDetail(
|
||||
PortfolioPostModel post,
|
||||
PortfolioCategoryModel? category,
|
||||
List<PortfolioPostImageModel> images)
|
||||
{
|
||||
PostDetailDto dto = new PostDetailDto
|
||||
{
|
||||
PostNo = post.PostNo,
|
||||
CategoryNo = post.CategoryNo,
|
||||
CategorySlug = category?.Slug ?? string.Empty,
|
||||
CategoryName = category?.Name ?? string.Empty,
|
||||
CategoryAccent = category?.Accent,
|
||||
Title = post.Title,
|
||||
Summary = post.Summary,
|
||||
ThumbnailUrl = post.ThumbnailUrl,
|
||||
ImageCount = images.Count,
|
||||
CreatedAt = post.CreatedAt,
|
||||
UpdatedAt = post.UpdatedAt,
|
||||
ContentMd = post.ContentMd,
|
||||
Images = new List<PostImageDto>(images.Count)
|
||||
};
|
||||
|
||||
foreach (PortfolioPostImageModel image in images)
|
||||
{
|
||||
dto.Images.Add(new PostImageDto
|
||||
{
|
||||
ImageNo = image.ImageNo,
|
||||
ImageUrl = image.ImageUrl,
|
||||
Caption = image.Caption,
|
||||
SortOrder = image.SortOrder
|
||||
});
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
// 들어온 값을 다듬고 검증합니다. Error 가 null 이 아니면 400 으로 응답합니다.
|
||||
// 길이를 넘기면 잘라내지 않고 거절합니다.
|
||||
private static (string? Error, string Title, string? Summary, string? ContentMd,
|
||||
string? ThumbnailUrl, List<PortfolioPostImageModel> Images) NormalizePost(PostUpsertDto dto)
|
||||
{
|
||||
List<PortfolioPostImageModel> images = new List<PortfolioPostImageModel>();
|
||||
|
||||
string title = Clean(dto.Title) ?? string.Empty;
|
||||
string? summary = Clean(dto.Summary);
|
||||
string? contentMd = Clean(dto.ContentMd);
|
||||
string? thumbnailUrl = Clean(dto.ThumbnailUrl);
|
||||
|
||||
if (title.Length == 0)
|
||||
{
|
||||
return ("제목을 입력해 주세요.", title, summary, contentMd, thumbnailUrl, images);
|
||||
}
|
||||
if (title.Length > TitleMaxLength)
|
||||
{
|
||||
return ($"제목은 {TitleMaxLength}자 이하여야 합니다.", title, summary, contentMd, thumbnailUrl, images);
|
||||
}
|
||||
if (summary != null && summary.Length > SummaryMaxLength)
|
||||
{
|
||||
return ($"요약은 {SummaryMaxLength}자 이하여야 합니다.", title, summary, contentMd, thumbnailUrl, images);
|
||||
}
|
||||
if (thumbnailUrl != null)
|
||||
{
|
||||
if (thumbnailUrl.Length > ThumbnailUrlMaxLength)
|
||||
{
|
||||
return ($"대표 이미지 주소는 {ThumbnailUrlMaxLength}자 이하여야 합니다.",
|
||||
title, summary, contentMd, thumbnailUrl, images);
|
||||
}
|
||||
if (!IsAllowedMediaUrl(thumbnailUrl))
|
||||
{
|
||||
return ("대표 이미지 주소는 /uploads/ 로 시작하는 경로이거나 http(s) 주소여야 합니다.",
|
||||
title, summary, contentMd, thumbnailUrl, images);
|
||||
}
|
||||
}
|
||||
|
||||
List<PostImageUpsertDto> incoming = dto.Images ?? new List<PostImageUpsertDto>();
|
||||
foreach (PostImageUpsertDto item in incoming)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? imageUrl = Clean(item.ImageUrl);
|
||||
string? caption = Clean(item.Caption);
|
||||
|
||||
if (imageUrl == null)
|
||||
{
|
||||
return ("이미지 주소를 입력해 주세요.", title, summary, contentMd, thumbnailUrl, images);
|
||||
}
|
||||
if (imageUrl.Length > ImageUrlMaxLength)
|
||||
{
|
||||
return ($"이미지 주소는 {ImageUrlMaxLength}자 이하여야 합니다.",
|
||||
title, summary, contentMd, thumbnailUrl, images);
|
||||
}
|
||||
if (!IsAllowedMediaUrl(imageUrl))
|
||||
{
|
||||
return ("이미지 주소는 /uploads/ 로 시작하는 경로이거나 http(s) 주소여야 합니다.",
|
||||
title, summary, contentMd, thumbnailUrl, images);
|
||||
}
|
||||
if (caption != null && caption.Length > CaptionMaxLength)
|
||||
{
|
||||
return ($"이미지 설명은 {CaptionMaxLength}자 이하여야 합니다.",
|
||||
title, summary, contentMd, thumbnailUrl, images);
|
||||
}
|
||||
|
||||
images.Add(new PortfolioPostImageModel
|
||||
{
|
||||
ImageUrl = imageUrl,
|
||||
Caption = caption,
|
||||
SortOrder = item.SortOrder
|
||||
});
|
||||
}
|
||||
|
||||
return (null, title, summary, contentMd, thumbnailUrl, images);
|
||||
}
|
||||
|
||||
// 허용하는 이미지 주소인지 확인합니다.
|
||||
// 1) /uploads/ 로 시작하는 사이트 내부 경로 2) http:// 또는 https:// 절대 주소
|
||||
private static bool IsAllowedMediaUrl(string url)
|
||||
{
|
||||
if (url.StartsWith(UploadsPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
// 상위 경로 탈출(..)은 막습니다.
|
||||
return url.Length > UploadsPrefix.Length
|
||||
&& !url.Contains("..", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out Uri? parsed))
|
||||
{
|
||||
return parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 앞뒤 공백을 제거하고, 빈 문자열은 null 로 바꿉니다.
|
||||
private static string? Clean(string? value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string trimmed = value.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
// 오류 응답도 ApiResponse<T> 형태를 유지합니다.
|
||||
private static ApiResponse<object> Error(string message)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
Data = null,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
530
GameServer/Controllers/Portfolio/PortfolioUploadController.cs
Normal file
530
GameServer/Controllers/Portfolio/PortfolioUploadController.cs
Normal file
@@ -0,0 +1,530 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GameServer.Models;
|
||||
using GameServer.Models.Portfolio;
|
||||
using GameServer.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GameServer.Controllers.Portfolio
|
||||
{
|
||||
/// <summary>
|
||||
/// 포트폴리오 이미지 업로드 API. 관리자 쿠키 인증을 통과한 요청만 사용할 수 있습니다.
|
||||
/// 저장 위치는 wwwroot/uploads/{yyyy}/{MM}/{guid}{확장자} 이며,
|
||||
/// 클라이언트가 보낸 파일 이름은 절대로 경로에 사용하지 않습니다.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/portfolio/uploads")]
|
||||
[Authorize(Policy = PortfolioAdminAuth.PolicyName)]
|
||||
public class PortfolioUploadController : ControllerBase
|
||||
{
|
||||
// 요청 본문 자체의 절대 상한(20 MiB). 설정값이 이보다 크면 이 값으로 잘립니다.
|
||||
private const long AbsoluteMaxBytes = 100L * 1024 * 1024;
|
||||
|
||||
// Portfolio:MaxUploadBytes 가 없을 때 사용하는 기본 상한(10 MiB).
|
||||
private const long DefaultMaxUploadBytes = 10L * 1024 * 1024;
|
||||
|
||||
// 매직 바이트 검사를 위해 앞에서 읽어 볼 바이트 수.
|
||||
private const int HeaderProbeBytes = 16;
|
||||
|
||||
// 업로드 파일이 노출되는 URL 접두사.
|
||||
private const string UploadsUrlPrefix = "/uploads/";
|
||||
|
||||
// 허용 확장자(대소문자 무시).
|
||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"
|
||||
};
|
||||
|
||||
// 파일 이름에서 밑줄로 바꿀 위험 문자(윈도우/리눅스 공통 기준으로 직접 지정).
|
||||
private static readonly char[] UnsafeNameChars = { '\\', '/', ':', '*', '?', '"', '<', '>', '|' };
|
||||
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<PortfolioUploadController> _logger;
|
||||
|
||||
public PortfolioUploadController(
|
||||
IWebHostEnvironment env,
|
||||
IConfiguration config,
|
||||
ILogger<PortfolioUploadController> logger)
|
||||
{
|
||||
_env = env;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 업로드 한도와 허용 확장자를 알려 줍니다.
|
||||
/// 편집기가 파일을 보내기 전에 미리 걸러 낼 수 있도록 두었습니다.
|
||||
/// (한도를 넘긴 본문은 서버가 연결을 끊어 버려서, 브라우저에서는
|
||||
/// 원인을 알 수 없는 네트워크 오류로만 보이기 때문입니다.)
|
||||
/// </summary>
|
||||
[HttpGet("limit")]
|
||||
public IActionResult GetLimit()
|
||||
{
|
||||
long maxBytes = ResolveMaxUploadBytes();
|
||||
return Ok(new ApiResponse<UploadLimitDto>
|
||||
{
|
||||
Data = new UploadLimitDto
|
||||
{
|
||||
MaxBytes = maxBytes,
|
||||
MaxMegabytes = Math.Floor(maxBytes / (1024d * 1024d)),
|
||||
AllowedExtensions = AllowedExtensions.ToList()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이미지 한 장을 업로드합니다. multipart/form-data 의 필드 이름은 file 입니다.
|
||||
/// </summary>
|
||||
[HttpPost("")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(100L * 1024 * 1024)]
|
||||
public async Task<IActionResult> Upload([FromForm] IFormFile? file)
|
||||
{
|
||||
CancellationToken cancellationToken = HttpContext.RequestAborted;
|
||||
|
||||
if (file is null)
|
||||
{
|
||||
return Fail(StatusCodes.Status400BadRequest, "업로드할 파일이 없습니다.");
|
||||
}
|
||||
|
||||
if (file.Length <= 0)
|
||||
{
|
||||
return Fail(StatusCodes.Status400BadRequest, "빈 파일은 업로드할 수 없습니다.");
|
||||
}
|
||||
|
||||
long maxBytes = ResolveMaxUploadBytes();
|
||||
if (file.Length > maxBytes)
|
||||
{
|
||||
string limitText = (maxBytes / (1024d * 1024d)).ToString("0.#", CultureInfo.InvariantCulture);
|
||||
return Fail(StatusCodes.Status400BadRequest, $"파일이 너무 큽니다. 최대 {limitText}MB 까지 업로드할 수 있습니다.");
|
||||
}
|
||||
|
||||
// 클라이언트가 보낸 이름은 표시용으로만 쓰고, 경로에는 사용하지 않습니다.
|
||||
string safeName = SanitizeFileName(file.FileName);
|
||||
string extension = Path.GetExtension(safeName).ToLowerInvariant();
|
||||
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedExtensions.Contains(extension))
|
||||
{
|
||||
return Fail(StatusCodes.Status400BadRequest, "png, jpg, jpeg, gif, webp, avif 이미지만 업로드할 수 있습니다.");
|
||||
}
|
||||
|
||||
string uploadsRoot = ResolveUploadsRoot();
|
||||
DateTime now = DateTime.UtcNow;
|
||||
string yearSegment = now.ToString("yyyy", CultureInfo.InvariantCulture);
|
||||
string monthSegment = now.ToString("MM", CultureInfo.InvariantCulture);
|
||||
string storedName = Guid.NewGuid().ToString("N") + extension;
|
||||
|
||||
string targetDirectory = Path.Combine(uploadsRoot, yearSegment, monthSegment);
|
||||
string targetPath = Path.Combine(targetDirectory, storedName);
|
||||
string publicUrl = UploadsUrlPrefix + yearSegment + "/" + monthSegment + "/" + storedName;
|
||||
|
||||
long savedBytes;
|
||||
Stream stream = file.OpenReadStream();
|
||||
try
|
||||
{
|
||||
// 1) 앞부분 16바이트를 읽어 매직 바이트를 검사합니다.
|
||||
byte[] header = new byte[HeaderProbeBytes];
|
||||
int headerLength = 0;
|
||||
while (headerLength < header.Length)
|
||||
{
|
||||
int read = await stream.ReadAsync(header.AsMemory(headerLength, header.Length - headerLength), cancellationToken);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
headerLength += read;
|
||||
}
|
||||
|
||||
if (!LooksLikeAllowedImage(header, headerLength))
|
||||
{
|
||||
return Fail(StatusCodes.Status400BadRequest, "이미지 파일이 아닙니다.");
|
||||
}
|
||||
|
||||
// 2) 검사에 쓴 만큼 위치가 앞으로 갔으므로 처음으로 되감습니다.
|
||||
// 되감을 수 없는 스트림이면 새로 엽니다.
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
}
|
||||
else
|
||||
{
|
||||
await stream.DisposeAsync();
|
||||
stream = file.OpenReadStream();
|
||||
}
|
||||
|
||||
// 3) 연/월 디렉터리는 필요할 때 만듭니다.
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
|
||||
await using (FileStream destination = new FileStream(
|
||||
targetPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 81920,
|
||||
useAsync: true))
|
||||
{
|
||||
await stream.CopyToAsync(destination, cancellationToken);
|
||||
await destination.FlushAsync(cancellationToken);
|
||||
savedBytes = destination.Length;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TryDeleteQuietly(targetPath);
|
||||
_logger.LogError(ex, "포트폴리오 이미지 업로드 실패: {TargetPath}", targetPath);
|
||||
return Fail(StatusCodes.Status500InternalServerError, "파일을 저장하지 못했습니다.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await stream.DisposeAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("포트폴리오 이미지 업로드 완료: {Url} ({Size} bytes)", publicUrl, savedBytes);
|
||||
|
||||
return Ok(new ApiResponse<UploadResultDto>
|
||||
{
|
||||
Data = new UploadResultDto
|
||||
{
|
||||
Url = publicUrl,
|
||||
FileName = safeName,
|
||||
Size = savedBytes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이전에 업로드한 파일을 삭제합니다. url 은 /uploads/ 로 시작하는 사이트 상대 경로여야 합니다.
|
||||
/// </summary>
|
||||
[HttpDelete("")]
|
||||
public IActionResult Delete([FromQuery] string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return Fail(StatusCodes.Status400BadRequest, "삭제할 파일 경로가 필요합니다.");
|
||||
}
|
||||
|
||||
string candidate = url.Trim();
|
||||
|
||||
// 쿼리스트링/프래그먼트는 경로에서 잘라냅니다.
|
||||
int cut = candidate.IndexOfAny(new[] { '?', '#' });
|
||||
if (cut >= 0)
|
||||
{
|
||||
candidate = candidate.Substring(0, cut);
|
||||
}
|
||||
|
||||
// %2e%2e 같은 인코딩 우회를 막기 위해 디코딩한 값도 함께 검사합니다.
|
||||
string decoded;
|
||||
try
|
||||
{
|
||||
decoded = Uri.UnescapeDataString(candidate);
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return Fail(StatusCodes.Status400BadRequest, "잘못된 파일 경로입니다.");
|
||||
}
|
||||
|
||||
if (!IsSafeUploadUrl(candidate) || !IsSafeUploadUrl(decoded))
|
||||
{
|
||||
return Fail(StatusCodes.Status400BadRequest, "잘못된 파일 경로입니다.");
|
||||
}
|
||||
|
||||
string relative = decoded.Substring(UploadsUrlPrefix.Length).TrimStart('/');
|
||||
if (relative.Length == 0 || Path.IsPathRooted(relative))
|
||||
{
|
||||
return Fail(StatusCodes.Status400BadRequest, "잘못된 파일 경로입니다.");
|
||||
}
|
||||
|
||||
string uploadsRoot = ResolveUploadsRoot();
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(Path.Combine(uploadsRoot, relative.Replace('/', Path.DirectorySeparatorChar)));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException)
|
||||
{
|
||||
return Fail(StatusCodes.Status400BadRequest, "잘못된 파일 경로입니다.");
|
||||
}
|
||||
|
||||
// 완전히 해석된 절대 경로가 업로드 루트 안에 있는지 확인합니다(경로 탈출 방지).
|
||||
if (!IsInsideDirectory(uploadsRoot, fullPath))
|
||||
{
|
||||
_logger.LogWarning("업로드 경로 탈출 시도 차단: {Url}", url);
|
||||
return Fail(StatusCodes.Status400BadRequest, "잘못된 파일 경로입니다.");
|
||||
}
|
||||
|
||||
// 업로드 루트 안이라도 허용 확장자 파일만 삭제할 수 있습니다.
|
||||
string extension = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedExtensions.Contains(extension))
|
||||
{
|
||||
return Fail(StatusCodes.Status400BadRequest, "이미지 파일만 삭제할 수 있습니다.");
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
{
|
||||
return Fail(StatusCodes.Status404NotFound, "파일을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
System.IO.File.Delete(fullPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogError(ex, "포트폴리오 이미지 삭제 실패: {FullPath}", fullPath);
|
||||
return Fail(StatusCodes.Status500InternalServerError, "파일을 삭제하지 못했습니다.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("포트폴리오 이미지 삭제 완료: {Url}", url);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>appsettings 의 Portfolio:MaxUploadBytes 를 읽습니다(없거나 잘못되면 기본값).</summary>
|
||||
private long ResolveMaxUploadBytes()
|
||||
{
|
||||
long max = DefaultMaxUploadBytes;
|
||||
|
||||
string? raw = _config["Portfolio:MaxUploadBytes"];
|
||||
if (!string.IsNullOrWhiteSpace(raw)
|
||||
&& long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed)
|
||||
&& parsed > 0)
|
||||
{
|
||||
max = parsed;
|
||||
}
|
||||
|
||||
return Math.Min(max, AbsoluteMaxBytes);
|
||||
}
|
||||
|
||||
/// <summary>wwwroot/uploads 의 절대 경로. WebRootPath 가 비어 있으면 ContentRootPath 로 대체합니다.</summary>
|
||||
private string ResolveUploadsRoot()
|
||||
{
|
||||
string webRoot = _env.WebRootPath;
|
||||
if (string.IsNullOrWhiteSpace(webRoot))
|
||||
{
|
||||
webRoot = Path.Combine(_env.ContentRootPath, "wwwroot");
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(webRoot, "uploads"));
|
||||
}
|
||||
|
||||
/// <summary>모든 응답 본문은 ApiResponse 형식을 사용합니다.</summary>
|
||||
private IActionResult Fail(int statusCode, string message)
|
||||
{
|
||||
return StatusCode(statusCode, new ApiResponse<UploadResultDto>
|
||||
{
|
||||
Data = null,
|
||||
Message = message
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>표시용 파일 이름을 안전하게 다듬습니다(경로 성분 제거 + 위험 문자 치환).</summary>
|
||||
private static string SanitizeFileName(string? rawName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawName))
|
||||
{
|
||||
return "image";
|
||||
}
|
||||
|
||||
// 디렉터리 성분은 모두 버립니다.
|
||||
string name = rawName.Replace('\\', '/');
|
||||
int lastSlash = name.LastIndexOf('/');
|
||||
if (lastSlash >= 0)
|
||||
{
|
||||
name = name.Substring(lastSlash + 1);
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder(name.Length);
|
||||
foreach (char ch in name)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(Array.IndexOf(UnsafeNameChars, ch) >= 0 ? '_' : ch);
|
||||
}
|
||||
|
||||
// 앞뒤 공백과 점(숨김 파일 · 상위 경로 표기)을 제거합니다.
|
||||
string cleaned = builder.ToString().Trim().Trim('.').Trim();
|
||||
|
||||
if (cleaned.Length == 0)
|
||||
{
|
||||
return "image";
|
||||
}
|
||||
|
||||
if (cleaned.Length > 120)
|
||||
{
|
||||
string tailExtension = Path.GetExtension(cleaned);
|
||||
string stem = cleaned.Substring(0, Math.Max(1, 120 - tailExtension.Length));
|
||||
cleaned = stem + tailExtension;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 앞부분 매직 바이트가 허용 이미지 포맷 중 하나인지 확인합니다.
|
||||
/// PNG / JPEG / GIF87a / GIF89a / WEBP / AVIF.
|
||||
/// </summary>
|
||||
private static bool LooksLikeAllowedImage(byte[] header, int length)
|
||||
{
|
||||
if (header is null || length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (length >= 8
|
||||
&& header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47
|
||||
&& header[4] == 0x0D && header[5] == 0x0A && header[6] == 0x1A && header[7] == 0x0A)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (length >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// GIF: GIF87a 또는 GIF89a
|
||||
if (length >= 6
|
||||
&& (MatchesAscii(header, length, 0, "GIF87a") || MatchesAscii(header, length, 0, "GIF89a")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// WEBP: 0번지에 RIFF, 8번지에 WEBP
|
||||
if (length >= 12 && MatchesAscii(header, length, 0, "RIFF") && MatchesAscii(header, length, 8, "WEBP"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// AVIF: 4번지에 ftyp, 8번지에 브랜드(avif / avis / mif1)
|
||||
if (length >= 12
|
||||
&& MatchesAscii(header, length, 4, "ftyp")
|
||||
&& (MatchesAscii(header, length, 8, "avif")
|
||||
|| MatchesAscii(header, length, 8, "avis")
|
||||
|| MatchesAscii(header, length, 8, "mif1")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>header 의 offset 위치가 주어진 아스키 문자열과 같은지 확인합니다.</summary>
|
||||
private static bool MatchesAscii(byte[] header, int length, int offset, string ascii)
|
||||
{
|
||||
if (offset < 0 || offset + ascii.Length > length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < ascii.Length; i++)
|
||||
{
|
||||
if (header[offset + i] != (byte)ascii[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>삭제 요청 URL 이 /uploads/ 아래의 안전한 상대 경로인지 검사합니다.</summary>
|
||||
private static bool IsSafeUploadUrl(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.IndexOf('\0') >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 역슬래시(윈도우 경로 구분자) 금지
|
||||
if (value.IndexOf('\\') >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상위 디렉터리 표기 금지
|
||||
if (value.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 절대 URL(스킴 포함)과 드라이브 문자 금지
|
||||
if (value.Contains("://", StringComparison.Ordinal) || value.IndexOf(':') >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 프로토콜 상대 URL(//host/...) 금지
|
||||
if (value.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 반드시 /uploads/ 아래여야 하고, 뒤에 파일 이름이 있어야 합니다.
|
||||
if (!value.StartsWith(UploadsUrlPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.Length > UploadsUrlPrefix.Length;
|
||||
}
|
||||
|
||||
/// <summary>fullPath 가 root 안쪽 경로인지 완전히 해석된 절대 경로끼리 비교합니다.</summary>
|
||||
private static bool IsInsideDirectory(string root, string fullPath)
|
||||
{
|
||||
string normalizedRoot = Path.GetFullPath(root);
|
||||
if (!normalizedRoot.EndsWith(Path.DirectorySeparatorChar))
|
||||
{
|
||||
normalizedRoot += Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
StringComparison comparison = OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
return fullPath.Length > normalizedRoot.Length
|
||||
&& fullPath.StartsWith(normalizedRoot, comparison);
|
||||
}
|
||||
|
||||
/// <summary>중간에 실패해 남은 파일 조각을 조용히 지웁니다.</summary>
|
||||
private static void TryDeleteQuietly(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 정리 실패는 무시합니다.
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// 정리 실패는 무시합니다.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user