531 lines
20 KiB
C#
531 lines
20 KiB
C#
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)
|
|
{
|
|
// 정리 실패는 무시합니다.
|
|
}
|
|
}
|
|
}
|
|
}
|