diff --git a/.gitignore b/.gitignore index c5b41fe..3dd3587 100644 --- a/.gitignore +++ b/.gitignore @@ -271,4 +271,8 @@ __pycache__/ # Cake - Uncomment if you are using it # tools/ -# End of https://www.toptal.com/developers/gitignore/api/aspnetcore \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/aspnetcore + +# Portfolio uploaded media +GameServer/wwwroot/uploads/* +!GameServer/wwwroot/uploads/.gitkeep diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6dcd07f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,52 @@ +{ + // VS Code 에서 F5 를 누르면 서버가 뜨고 브라우저가 자동으로 열립니다. + // (Visual Studio 의 "실행" 버튼과 같은 동작) + "version": "0.2.0", + "configurations": [ + { + "name": "서버 실행 (브라우저 자동 열기)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/GameServer/bin/Debug/net9.0/GameServer.dll", + "args": [], + // wwwroot 와 appsettings.json 을 찾으려면 프로젝트 폴더가 작업 경로여야 합니다. + "cwd": "${workspaceFolder}/GameServer", + "stopAtEntry": false, + "console": "internalConsole", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5281" + // 관리자 비밀번호를 여기 두고 싶다면 아래 줄의 주석을 풀어 쓰세요. + // 단, 이 파일은 git 에 커밋되므로 공개 저장소라면 넣지 마세요. + // 비워 두면 appsettings.json 의 Portfolio:AdminPassword 값을 씁니다. + // "Portfolio__AdminPassword": "여기에-비밀번호" + }, + // 서버가 "Now listening on: ..." 을 찍으면 그 주소를 브라우저로 엽니다. + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + } + }, + { + "name": "서버 실행 (브라우저 안 열기)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/GameServer/bin/Debug/net9.0/GameServer.dll", + "args": [], + "cwd": "${workspaceFolder}/GameServer", + "stopAtEntry": false, + "console": "internalConsole", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5281" + } + }, + { + "name": "실행 중인 서버에 붙기", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..5cbd5bc --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,50 @@ +{ + "version": "2.0.0", + "tasks": [ + { + // F5 가 실행 전에 부르는 빌드 작업. Ctrl+Shift+B 로도 돌릴 수 있습니다. + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/GameServer/GameServer.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + // C# 코드를 고치면 알아서 다시 빌드하고 서버를 재시작합니다. + // 터미널 > 작업 실행 > watch (핫 리로드) + // wwwroot 의 HTML/CSS/JS 는 빌드가 필요 없으니 브라우저 새로고침만 하면 됩니다. + "label": "watch (핫 리로드)", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/GameServer/GameServer.csproj", + "--launch-profile", + "http" + ], + "problemMatcher": "$msCompile", + "isBackground": true + }, + { + "label": "clean", + "command": "dotnet", + "type": "process", + "args": [ + "clean", + "${workspaceFolder}/GameServer/GameServer.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/GameServer/Controllers/Portfolio/PortfolioAuthController.cs b/GameServer/Controllers/Portfolio/PortfolioAuthController.cs new file mode 100644 index 0000000..8e77a06 --- /dev/null +++ b/GameServer/Controllers/Portfolio/PortfolioAuthController.cs @@ -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 +{ + /// + /// 포트폴리오 관리자 로그인 / 로그아웃 / 세션 확인 API. + /// 게임 API(/myGame/*) 와는 완전히 분리된 /api/portfolio/auth 경로를 사용한다. + /// + [ApiController] + [Route("api/portfolio/auth")] + public class PortfolioAuthController : ControllerBase + { + private readonly PortfolioAdminAuth _auth; + + public PortfolioAuthController(PortfolioAdminAuth auth) + { + _auth = auth; + } + + /// 비밀번호로 로그인하고 관리자 쿠키를 발급한다. + [HttpPost("login")] + [EnableRateLimiting("portfolio-login")] + public async Task Login([FromBody] LoginRequestDto request) + { + // 비밀번호가 아직 설정되지 않았다면 503 으로 안내한다. + if (!_auth.IsConfigured) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new ApiResponse + { + Data = new AuthStateDto { Authenticated = false }, + Message = "관리자 비밀번호가 설정되지 않았습니다. appsettings.json의 Portfolio:AdminPassword 를 변경해 주세요." + }); + } + + if (!_auth.Verify(request?.Password)) + { + return StatusCode(StatusCodes.Status401Unauthorized, new ApiResponse + { + Data = new AuthStateDto { Authenticated = false }, + Message = "비밀번호가 올바르지 않습니다." + }); + } + + // 관리자 신원 발급 (7일 유지) + var claims = new List + { + 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 + { + Data = new AuthStateDto { Authenticated = true }, + Message = "로그인되었습니다." + }); + } + + /// 관리자 쿠키를 만료시킨다. 언제 호출해도 200. + [HttpPost("logout")] + public async Task Logout() + { + await HttpContext.SignOutAsync(PortfolioAdminAuth.CookieScheme); + + return Ok(new ApiResponse + { + Data = new AuthStateDto { Authenticated = false }, + Message = "로그아웃되었습니다." + }); + } + + /// + /// 현재 로그인 상태를 알려준다. 절대 401 을 돌려주지 않는다. + /// Message 에는 비밀번호 설정 여부("configured" / "not-configured")를 담아 + /// 관리자 화면이 안내 문구를 띄울 수 있게 한다. + /// + [HttpGet("me")] + public async Task Me() + { + var result = await HttpContext.AuthenticateAsync(PortfolioAdminAuth.CookieScheme); + bool authenticated = result.Succeeded && result.Principal?.Identity?.IsAuthenticated == true; + + return Ok(new ApiResponse + { + Data = new AuthStateDto { Authenticated = authenticated }, + Message = _auth.IsConfigured ? "configured" : "not-configured" + }); + } + } +} diff --git a/GameServer/Controllers/Portfolio/PortfolioCategoryController.cs b/GameServer/Controllers/Portfolio/PortfolioCategoryController.cs new file mode 100644 index 0000000..ffb5359 --- /dev/null +++ b/GameServer/Controllers/Portfolio/PortfolioCategoryController.cs @@ -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 +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 GetCategories() + { + List categories = await _context.PortfolioCategories + .AsNoTracking() + .OrderBy(c => c.SortOrder) + .ThenBy(c => c.CategoryNo) + .ToListAsync(); + + Dictionary postCounts = await LoadPostCountsAsync(); + + List list = new List(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> + { + Data = list, + Count = list.Count, + Message = "Success" + }); + } + + // ------------------------------------------------------------------ + // POST /api/portfolio/categories (관리자) + // ------------------------------------------------------------------ + [HttpPost("")] + [Authorize(Policy = PortfolioAdminAuth.PolicyName)] + public async Task 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 + { + Data = ToDto(entity, 0), + Message = "카테고리를 만들었습니다." + }); + } + + // ------------------------------------------------------------------ + // PUT /api/portfolio/categories/{categoryNo} (관리자) + // ------------------------------------------------------------------ + [HttpPut("{categoryNo:int}")] + [Authorize(Policy = PortfolioAdminAuth.PolicyName)] + public async Task 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 + { + Data = ToDto(entity, postCount), + Message = "카테고리를 수정했습니다." + }); + } + + // ------------------------------------------------------------------ + // DELETE /api/portfolio/categories/{categoryNo} (관리자) + // 게시물이 남아 있으면 409 로 막습니다. (DB 의 외래키도 RESTRICT) + // ------------------------------------------------------------------ + [HttpDelete("{categoryNo:int}")] + [Authorize(Policy = PortfolioAdminAuth.PolicyName)] + public async Task 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 + { + Data = null, + Message = "카테고리를 삭제했습니다." + }); + } + + // ------------------------------------------------------------------ + // 내부 도우미 + // ------------------------------------------------------------------ + + // 카테고리별 게시물 수를 그룹 쿼리 한 번으로 가져옵니다. + private async Task> LoadPostCountsAsync() + { + var grouped = await _context.PortfolioPosts + .AsNoTracking() + .GroupBy(p => p.CategoryNo) + .Select(g => new { CategoryNo = g.Key, Total = g.Count() }) + .ToListAsync(); + + Dictionary map = new Dictionary(); + 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 형태를 유지합니다. + private static ApiResponse Error(string message) + { + return new ApiResponse + { + Data = null, + Message = message + }; + } + } +} diff --git a/GameServer/Controllers/Portfolio/PortfolioPostController.cs b/GameServer/Controllers/Portfolio/PortfolioPostController.cs new file mode 100644 index 0000000..ba0c07c --- /dev/null +++ b/GameServer/Controllers/Portfolio/PortfolioPostController.cs @@ -0,0 +1,575 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using GameServer.Data; // AppDbContext +using GameServer.Models; // ApiResponse +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 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> + { + Data = new List(), + Count = 0, + Message = "존재하지 않는 카테고리입니다." + }); + } + + categoryNo = found.Value; + } + + IQueryable query = _context.PortfolioPosts.AsNoTracking(); + if (categoryNo.HasValue) + { + int filter = categoryNo.Value; + query = query.Where(p => p.CategoryNo == filter); + } + + int total = await query.CountAsync(); + + List posts = await query + .OrderByDescending(p => p.CreatedAt) + .ThenByDescending(p => p.PostNo) + .Skip((page - 1) * size) + .Take(size) + .ToListAsync(); + + List list = new List(posts.Count); + + if (posts.Count > 0) + { + // 이미지 개수는 그룹 쿼리 한 번, 카테고리는 IN 조회 한 번으로 끝냅니다. (N+1 없음) + List postNos = posts.Select(p => p.PostNo).ToList(); + List 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 imageCounts = new Dictionary(); + foreach (var row in groupedImages) + { + imageCounts[row.PostNo] = row.Total; + } + + List categories = await _context.PortfolioCategories + .AsNoTracking() + .Where(c => categoryNos.Contains(c.CategoryNo)) + .ToListAsync(); + + Dictionary categoryMap = + new Dictionary(); + 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> + { + Data = list, + Count = total, // 페이지 길이가 아니라 전체 개수입니다. + Message = "Success" + }); + } + + // ------------------------------------------------------------------ + // GET /api/portfolio/posts/{postNo} (공개) + // ------------------------------------------------------------------ + [HttpGet("{postNo:int}")] + [AllowAnonymous] + public async Task 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 images = await LoadImagesAsync(postNo, tracking: false); + + return Ok(new ApiResponse + { + Data = ToDetail(post, category, images), + Message = "Success" + }); + } + + // ------------------------------------------------------------------ + // POST /api/portfolio/posts (관리자) + // 게시물과 이미지 행을 함께 만듭니다. + // ------------------------------------------------------------------ + [HttpPost("")] + [Authorize(Policy = PortfolioAdminAuth.PolicyName)] + public async Task 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 saved = SortImages(input.Images); + + return Ok(new ApiResponse + { + Data = ToDetail(post, category, saved), + Message = "게시물을 등록했습니다." + }); + } + + // ------------------------------------------------------------------ + // PUT /api/portfolio/posts/{postNo} (관리자) + // 스칼라 필드를 바꾸고 이미지 목록은 통째로 교체합니다. + // CreatedAt 은 그대로 두고 UpdatedAt 만 갱신합니다. + // ------------------------------------------------------------------ + [HttpPut("{postNo:int}")] + [Authorize(Policy = PortfolioAdminAuth.PolicyName)] + public async Task 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 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 saved = SortImages(input.Images); + + return Ok(new ApiResponse + { + Data = ToDetail(post, category, saved), + Message = "게시물을 수정했습니다." + }); + } + + // ------------------------------------------------------------------ + // DELETE /api/portfolio/posts/{postNo} (관리자) + // 이미지 행을 먼저 지우고 게시물을 지웁니다. + // ------------------------------------------------------------------ + [HttpDelete("{postNo:int}")] + [Authorize(Policy = PortfolioAdminAuth.PolicyName)] + public async Task 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 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 + { + Data = null, + Message = "게시물을 삭제했습니다." + }); + } + + // ------------------------------------------------------------------ + // 내부 도우미 + // ------------------------------------------------------------------ + + // 게시물 이미지들을 SortOrder → ImageNo 순으로 읽습니다. + private async Task> LoadImagesAsync(int postNo, bool tracking) + { + IQueryable 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 SortImages(List 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 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(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 Images) NormalizePost(PostUpsertDto dto) + { + List images = new List(); + + 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 incoming = dto.Images ?? new List(); + 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 형태를 유지합니다. + private static ApiResponse Error(string message) + { + return new ApiResponse + { + Data = null, + Message = message + }; + } + } +} diff --git a/GameServer/Controllers/Portfolio/PortfolioUploadController.cs b/GameServer/Controllers/Portfolio/PortfolioUploadController.cs new file mode 100644 index 0000000..f59e62c --- /dev/null +++ b/GameServer/Controllers/Portfolio/PortfolioUploadController.cs @@ -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 +{ + /// + /// 포트폴리오 이미지 업로드 API. 관리자 쿠키 인증을 통과한 요청만 사용할 수 있습니다. + /// 저장 위치는 wwwroot/uploads/{yyyy}/{MM}/{guid}{확장자} 이며, + /// 클라이언트가 보낸 파일 이름은 절대로 경로에 사용하지 않습니다. + /// + [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 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 _logger; + + public PortfolioUploadController( + IWebHostEnvironment env, + IConfiguration config, + ILogger logger) + { + _env = env; + _config = config; + _logger = logger; + } + + /// + /// 업로드 한도와 허용 확장자를 알려 줍니다. + /// 편집기가 파일을 보내기 전에 미리 걸러 낼 수 있도록 두었습니다. + /// (한도를 넘긴 본문은 서버가 연결을 끊어 버려서, 브라우저에서는 + /// 원인을 알 수 없는 네트워크 오류로만 보이기 때문입니다.) + /// + [HttpGet("limit")] + public IActionResult GetLimit() + { + long maxBytes = ResolveMaxUploadBytes(); + return Ok(new ApiResponse + { + Data = new UploadLimitDto + { + MaxBytes = maxBytes, + MaxMegabytes = Math.Floor(maxBytes / (1024d * 1024d)), + AllowedExtensions = AllowedExtensions.ToList() + } + }); + } + + /// + /// 이미지 한 장을 업로드합니다. multipart/form-data 의 필드 이름은 file 입니다. + /// + [HttpPost("")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(100L * 1024 * 1024)] + public async Task 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 + { + Data = new UploadResultDto + { + Url = publicUrl, + FileName = safeName, + Size = savedBytes + } + }); + } + + /// + /// 이전에 업로드한 파일을 삭제합니다. url 은 /uploads/ 로 시작하는 사이트 상대 경로여야 합니다. + /// + [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(); + } + + /// appsettings 의 Portfolio:MaxUploadBytes 를 읽습니다(없거나 잘못되면 기본값). + 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); + } + + /// wwwroot/uploads 의 절대 경로. WebRootPath 가 비어 있으면 ContentRootPath 로 대체합니다. + private string ResolveUploadsRoot() + { + string webRoot = _env.WebRootPath; + if (string.IsNullOrWhiteSpace(webRoot)) + { + webRoot = Path.Combine(_env.ContentRootPath, "wwwroot"); + } + + return Path.GetFullPath(Path.Combine(webRoot, "uploads")); + } + + /// 모든 응답 본문은 ApiResponse 형식을 사용합니다. + private IActionResult Fail(int statusCode, string message) + { + return StatusCode(statusCode, new ApiResponse + { + Data = null, + Message = message + }); + } + + /// 표시용 파일 이름을 안전하게 다듬습니다(경로 성분 제거 + 위험 문자 치환). + 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; + } + + /// + /// 앞부분 매직 바이트가 허용 이미지 포맷 중 하나인지 확인합니다. + /// PNG / JPEG / GIF87a / GIF89a / WEBP / AVIF. + /// + 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; + } + + /// header 의 offset 위치가 주어진 아스키 문자열과 같은지 확인합니다. + 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; + } + + /// 삭제 요청 URL 이 /uploads/ 아래의 안전한 상대 경로인지 검사합니다. + 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; + } + + /// fullPath 가 root 안쪽 경로인지 완전히 해석된 절대 경로끼리 비교합니다. + 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); + } + + /// 중간에 실패해 남은 파일 조각을 조용히 지웁니다. + private static void TryDeleteQuietly(string path) + { + try + { + if (System.IO.File.Exists(path)) + { + System.IO.File.Delete(path); + } + } + catch (IOException) + { + // 정리 실패는 무시합니다. + } + catch (UnauthorizedAccessException) + { + // 정리 실패는 무시합니다. + } + } + } +} diff --git a/GameServer/Data/AppDbContext.cs b/GameServer/Data/AppDbContext.cs index 469ef3d..d2d729c 100644 --- a/GameServer/Data/AppDbContext.cs +++ b/GameServer/Data/AppDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using GameServer.Models; +using GameServer.Models.Portfolio; namespace GameServer.Data { @@ -11,5 +12,10 @@ namespace GameServer.Data } public DbSet UserCharacters { get; set; } public DbSet Characters { get; set; } + + // 포트폴리오 + public DbSet PortfolioCategories { get; set; } + public DbSet PortfolioPosts { get; set; } + public DbSet PortfolioPostImages { get; set; } } } diff --git a/GameServer/Data/PortfolioSchemaInitializer.cs b/GameServer/Data/PortfolioSchemaInitializer.cs new file mode 100644 index 0000000..a99b356 --- /dev/null +++ b/GameServer/Data/PortfolioSchemaInitializer.cs @@ -0,0 +1,116 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace GameServer.Data +{ + /// + /// 포트폴리오 전용 테이블(tb_portfolio_*)을 서버 기동 시점에 보장한다. + /// CREATE TABLE IF NOT EXISTS 를 사용하므로 몇 번을 실행해도 안전(멱등)하며, + /// DB 장애가 게임 서버 기동을 막지 않도록 어떤 예외도 밖으로 던지지 않는다. + /// + public static class PortfolioSchemaInitializer + { + // 카테고리 테이블 + private const string CreateCategoryTableSql = @" +CREATE TABLE IF NOT EXISTS tb_portfolio_category ( + category_no INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + description VARCHAR(255) NULL, + accent VARCHAR(32) NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_portfolio_category_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"; + + // 게시물 테이블 + private const string CreatePostTableSql = @" +CREATE TABLE IF NOT EXISTS tb_portfolio_post ( + post_no INT AUTO_INCREMENT PRIMARY KEY, + category_no INT NOT NULL, + title VARCHAR(200) NOT NULL, + summary VARCHAR(500) NULL, + content_md MEDIUMTEXT NULL, + thumbnail_url VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY ix_portfolio_post_category (category_no), + CONSTRAINT fk_portfolio_post_category FOREIGN KEY (category_no) + REFERENCES tb_portfolio_category (category_no) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"; + + // 게시물 이미지 테이블 + private const string CreatePostImageTableSql = @" +CREATE TABLE IF NOT EXISTS tb_portfolio_post_image ( + image_no INT AUTO_INCREMENT PRIMARY KEY, + post_no INT NOT NULL, + image_url VARCHAR(500) NOT NULL, + caption VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY ix_portfolio_post_image_post (post_no), + CONSTRAINT fk_portfolio_post_image_post FOREIGN KEY (post_no) + REFERENCES tb_portfolio_post (post_no) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"; + + /// + /// 테이블 생성 + (비어 있을 때만) 기본 카테고리 4건 시딩. + /// 실패해도 예외를 던지지 않고 로그만 남긴다. + /// + public static async Task EnsureCreatedAsync(IServiceProvider services, ILogger logger) + { + try + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // 문장 하나씩 실행 (MySql 커맨드는 다중 문장을 한 번에 처리하지 않는 경우가 있다) + await db.Database.ExecuteSqlRawAsync(CreateCategoryTableSql); + await db.Database.ExecuteSqlRawAsync(CreatePostTableSql); + await db.Database.ExecuteSqlRawAsync(CreatePostImageTableSql); + + var categoryCount = await db.PortfolioCategories.CountAsync(); + if (categoryCount == 0) + { + await SeedCategoriesAsync(db); + logger.LogInformation("포트폴리오 기본 카테고리 4건을 시딩했습니다."); + } + + logger.LogInformation("포트폴리오 스키마 준비 완료 (카테고리 {Count}건).", + categoryCount == 0 ? 4 : categoryCount); + } + catch (Exception ex) + { + // 스키마 준비 실패가 게임 서버 기동을 막으면 안 되므로 여기서 삼킨다. + logger.LogError(ex, "포트폴리오 스키마 초기화에 실패했습니다. 포트폴리오 API가 동작하지 않을 수 있습니다."); + } + } + + // 기본 카테고리 4건 (카테고리 테이블이 완전히 비어 있을 때만 호출된다) + private static async Task SeedCategoriesAsync(AppDbContext db) + { + var now = DateTime.UtcNow; + + await InsertCategoryAsync(db, "game", "게임", "Unity 게임 프로젝트", "#7c5cff", 1, now); + await InsertCategoryAsync(db, "dev", "개발", "서버 · 툴 · 시스템", "#20d5c4", 2, now); + await InsertCategoryAsync(db, "art", "아트", "그래픽 · UI · 연출", "#ff5c9a", 3, now); + await InsertCategoryAsync(db, "log", "기록", "작업 일지와 회고", "#ffb74d", 4, now); + } + + // 값은 반드시 파라미터로 전달한다 (문자열 결합 금지) + private static async Task InsertCategoryAsync( + AppDbContext db, + string slug, + string name, + string description, + string accent, + int sortOrder, + DateTime createdAt) + { + await db.Database.ExecuteSqlInterpolatedAsync( + $@"INSERT INTO tb_portfolio_category (slug, name, description, accent, sort_order, created_at) + VALUES ({slug}, {name}, {description}, {accent}, {sortOrder}, {createdAt})"); + } + } +} diff --git a/GameServer/Models/Portfolio/PortfolioCategoryModel.cs b/GameServer/Models/Portfolio/PortfolioCategoryModel.cs new file mode 100644 index 0000000..2393542 --- /dev/null +++ b/GameServer/Models/Portfolio/PortfolioCategoryModel.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace GameServer.Models.Portfolio +{ + // 포트폴리오 카테고리 (게임 / 개발 / 아트 / 기록) + [Table("tb_portfolio_category")] + public class PortfolioCategoryModel + { + [Key] + [Column("category_no")] + public int CategoryNo { get; set; } + + // URL 에 쓰이는 식별자 (예: game, dev, art, log) + [Required] + [Column("slug")] + public string Slug { get; set; } = string.Empty; + + [Required] + [Column("name")] + public string Name { get; set; } = string.Empty; + + [Column("description")] + public string? Description { get; set; } + + // 카테고리 강조 색상 (예: #7c5cff) + [Column("accent")] + public string? Accent { get; set; } + + [Column("sort_order")] + public int SortOrder { get; set; } + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + } +} diff --git a/GameServer/Models/Portfolio/PortfolioDtos.cs b/GameServer/Models/Portfolio/PortfolioDtos.cs new file mode 100644 index 0000000..dd0089d --- /dev/null +++ b/GameServer/Models/Portfolio/PortfolioDtos.cs @@ -0,0 +1,105 @@ +namespace GameServer.Models.Portfolio +{ + // 카테고리 조회 응답 + public class CategoryDto + { + public int CategoryNo { get; set; } + public string Slug { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public string? Accent { get; set; } + public int SortOrder { get; set; } + + // 해당 카테고리에 속한 게시물 수 + public int PostCount { get; set; } + } + + // 카테고리 생성/수정 요청 + public class CategoryUpsertDto + { + public string Slug { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public string? Accent { get; set; } + public int SortOrder { get; set; } + } + + // 게시물 이미지 조회 응답 + public class PostImageDto + { + public int ImageNo { get; set; } + public string ImageUrl { get; set; } = string.Empty; + public string? Caption { get; set; } + public int SortOrder { get; set; } + } + + // 목록용 게시물 요약 (카테고리 정보 포함) + public class PostSummaryDto + { + public int PostNo { get; set; } + public int CategoryNo { get; set; } + public string CategorySlug { get; set; } = string.Empty; + public string CategoryName { get; set; } = string.Empty; + public string? CategoryAccent { get; set; } + public string Title { get; set; } = string.Empty; + public string? Summary { get; set; } + public string? ThumbnailUrl { get; set; } + public int ImageCount { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } + + // 상세 조회 응답 (본문 + 이미지 목록 추가) + public class PostDetailDto : PostSummaryDto + { + public string? ContentMd { get; set; } + public List Images { get; set; } = new(); + } + + // 게시물 생성/수정 요청 + public class PostUpsertDto + { + public int CategoryNo { get; set; } + public string Title { get; set; } = string.Empty; + public string? Summary { get; set; } + public string? ContentMd { get; set; } + public string? ThumbnailUrl { get; set; } + public List Images { get; set; } = new(); + } + + // 게시물 이미지 생성/수정 요청 + public class PostImageUpsertDto + { + public string ImageUrl { get; set; } = string.Empty; + public string? Caption { get; set; } + public int SortOrder { get; set; } + } + + // 관리자 로그인 요청 + public class LoginRequestDto + { + public string Password { get; set; } = string.Empty; + } + + // 로그인 상태 응답 + public class AuthStateDto + { + public bool Authenticated { get; set; } + } + + // 이미지 업로드 결과 + public class UploadResultDto + { + public string Url { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public long Size { get; set; } + } + + /// 업로드 한도 안내. 편집기가 보내기 전에 미리 확인하는 용도입니다. + public class UploadLimitDto + { + public long MaxBytes { get; set; } + public double MaxMegabytes { get; set; } + public List AllowedExtensions { get; set; } = new(); + } +} diff --git a/GameServer/Models/Portfolio/PortfolioPostImageModel.cs b/GameServer/Models/Portfolio/PortfolioPostImageModel.cs new file mode 100644 index 0000000..c6a31f7 --- /dev/null +++ b/GameServer/Models/Portfolio/PortfolioPostImageModel.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace GameServer.Models.Portfolio +{ + // 게시물에 첨부된 이미지 (게시물 삭제 시 함께 삭제) + [Table("tb_portfolio_post_image")] + public class PortfolioPostImageModel + { + [Key] + [Column("image_no")] + public int ImageNo { get; set; } + + [Column("post_no")] + public int PostNo { get; set; } + + [Required] + [Column("image_url")] + public string ImageUrl { get; set; } = string.Empty; + + [Column("caption")] + public string? Caption { get; set; } + + [Column("sort_order")] + public int SortOrder { get; set; } + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + } +} diff --git a/GameServer/Models/Portfolio/PortfolioPostModel.cs b/GameServer/Models/Portfolio/PortfolioPostModel.cs new file mode 100644 index 0000000..71aa71a --- /dev/null +++ b/GameServer/Models/Portfolio/PortfolioPostModel.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace GameServer.Models.Portfolio +{ + // 포트폴리오 게시물 본문 (마크다운으로 저장) + [Table("tb_portfolio_post")] + public class PortfolioPostModel + { + [Key] + [Column("post_no")] + public int PostNo { get; set; } + + [Column("category_no")] + public int CategoryNo { get; set; } + + [Required] + [Column("title")] + public string Title { get; set; } = string.Empty; + + [Column("summary")] + public string? Summary { get; set; } + + // 마크다운 원문 + [Column("content_md")] + public string? ContentMd { get; set; } + + [Column("thumbnail_url")] + public string? ThumbnailUrl { get; set; } + + // 생성/수정 시각은 애플리케이션에서 DateTime.UtcNow 로 직접 기록한다. + [Column("created_at")] + public DateTime CreatedAt { get; set; } + + [Column("updated_at")] + public DateTime UpdatedAt { get; set; } + } +} diff --git a/GameServer/Program.cs b/GameServer/Program.cs index fb813b2..456e076 100644 --- a/GameServer/Program.cs +++ b/GameServer/Program.cs @@ -1,6 +1,13 @@ using Microsoft.EntityFrameworkCore; using GameServer.Data; +using GameServer.Services; using Scalar.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.RateLimiting; +using System.Threading.RateLimiting; +using System.Security.Claims; var builder = WebApplication.CreateBuilder(args); @@ -11,9 +18,52 @@ var connectionString = builder.Configuration.GetConnectionString("DefaultConnect builder.Services.AddDbContext(options => options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))); +// ── 포트폴리오 1) 관리자 비밀번호 검증 도우미 (싱글턴) ─────────────────────── +builder.Services.AddSingleton(); + +// ── 포트폴리오 2) 관리자 전용 쿠키 인증 / 인가 정책 ───────────────────────── +builder.Services.AddAuthentication(PortfolioAdminAuth.CookieScheme) + .AddCookie(PortfolioAdminAuth.CookieScheme, o => + { + o.Cookie.Name = "portfolio_admin"; + o.Cookie.HttpOnly = true; + o.Cookie.SameSite = SameSiteMode.Lax; + o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + o.ExpireTimeSpan = TimeSpan.FromDays(7); + o.SlidingExpiration = true; + // API 서버이므로 로그인 페이지로 리다이렉트하지 않고 상태 코드만 돌려준다. + o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = 401; return Task.CompletedTask; }; + o.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = 403; return Task.CompletedTask; }; + }); + +builder.Services.AddAuthorization(o => + o.AddPolicy(PortfolioAdminAuth.PolicyName, p => + p.AddAuthenticationSchemes(PortfolioAdminAuth.CookieScheme).RequireAuthenticatedUser())); + +// ── 포트폴리오 3) 로그인 무차별 대입 방지: IP별 고정 창(5분에 8회) ────────── +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.AddPolicy("portfolio-login", httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 8, + Window = TimeSpan.FromMinutes(5), + QueueLimit = 0, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + AutoReplenishment = true + })); +}); + builder.Services.AddControllers(); builder.Services.AddOpenApi(); +// ── 포트폴리오 5) 이미지 업로드용 멀티파트 본문 제한 (100MB) ──────────────── +builder.Services.Configure(o => o.MultipartBodyLengthLimit = 100L * 1024 * 1024); + var app = builder.Build(); if (app.Environment.IsDevelopment()) @@ -23,7 +73,23 @@ if (app.Environment.IsDevelopment()) } app.UseHttpsRedirection(); + +// ── 포트폴리오 6) 정적 사이트 + 레이트 리미터 + 인증 파이프라인 ───────────── +app.UseDefaultFiles(); // "/" 요청을 index.html 로 연결 (UseStaticFiles 앞에 와야 한다) +app.UseStaticFiles(); +app.UseRateLimiter(); +app.UseAuthentication(); + app.UseAuthorization(); app.MapControllers(); -app.Run(); \ No newline at end of file +// ── 포트폴리오 7) SPA 딥링크 대응. +// MapControllers 뒤에 있으므로 /myGame/* 와 /api/* 라우팅을 가리지 않는다. +app.MapFallbackToFile("index.html"); + +// ── 포트폴리오 8) 포트폴리오 테이블 생성 + 시드. +// 실패해도 예외를 던지지 않으므로 게임 서버 부팅을 막지 않는다. +await PortfolioSchemaInitializer.EnsureCreatedAsync(app.Services, + app.Services.GetRequiredService().CreateLogger("PortfolioSchema")); + +app.Run(); diff --git a/GameServer/Services/PortfolioAdminAuth.cs b/GameServer/Services/PortfolioAdminAuth.cs new file mode 100644 index 0000000..1ade67e --- /dev/null +++ b/GameServer/Services/PortfolioAdminAuth.cs @@ -0,0 +1,54 @@ +using System.Security.Cryptography; +using System.Text; + +namespace GameServer.Services +{ + /// + /// 포트폴리오 관리자 인증 도우미. + /// appsettings.json 의 Portfolio:AdminPassword 값을 읽어 비밀번호를 검증한다. + /// Program.cs 에서 싱글턴으로 등록된다. + /// + public class PortfolioAdminAuth + { + // 게임 API 와 섞이지 않도록 포트폴리오 전용 쿠키 인증 스킴을 사용한다. + public const string CookieScheme = "PortfolioAdminCookie"; + + // 관리자 전용 인가 정책 이름. + public const string PolicyName = "PortfolioAdmin"; + + // 비밀번호가 아직 설정되지 않았음을 뜻하는 기본 자리표시자 값. + private const string PlaceholderPassword = "CHANGE_ME"; + + private readonly string? _password; + + public PortfolioAdminAuth(IConfiguration config) + { + _password = config["Portfolio:AdminPassword"]; + } + + /// + /// 실제 비밀번호가 설정되어 있는지 여부. + /// 값이 비어 있거나 자리표시자(CHANGE_ME) 그대로면 false. + /// + public bool IsConfigured => + !string.IsNullOrWhiteSpace(_password) && + !string.Equals(_password, PlaceholderPassword, StringComparison.Ordinal); + + /// + /// 비밀번호를 상수 시간으로 비교한다. 미설정 상태면 언제나 false. + /// 길이 차이로 조기 종료되지 않도록 양쪽 모두 SHA-256 해시로 만든 뒤 비교한다. + /// + public bool Verify(string? password) + { + if (!IsConfigured) + { + return false; + } + + byte[] expected = SHA256.HashData(Encoding.UTF8.GetBytes(_password!)); + byte[] actual = SHA256.HashData(Encoding.UTF8.GetBytes(password ?? string.Empty)); + + return CryptographicOperations.FixedTimeEquals(expected, actual); + } + } +} diff --git a/GameServer/appsettings.json b/GameServer/appsettings.json index 857bc63..a2759ac 100644 --- a/GameServer/appsettings.json +++ b/GameServer/appsettings.json @@ -8,5 +8,11 @@ "Microsoft.AspNetCore": "Warning" } }, + "Portfolio": { + "AdminPassword": "!qwer7979!", + "SiteTitle": "My Game Portfolio", + "SiteTagline": "Unity Game Developer", + "MaxUploadBytes": 104857600 + }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/GameServer/sql/portfolio_schema.sql b/GameServer/sql/portfolio_schema.sql new file mode 100644 index 0000000..b093aec --- /dev/null +++ b/GameServer/sql/portfolio_schema.sql @@ -0,0 +1,61 @@ +-- ============================================================= +-- 포트폴리오 스키마 참조 파일 (MariaDB / utf8mb4) +-- 실제 생성은 서버 기동 시 Data/PortfolioSchemaInitializer.cs 가 담당한다. +-- 이 파일은 수동 배포·점검용 사본이며, 몇 번을 실행해도 안전하다(멱등). +-- - 테이블: CREATE TABLE IF NOT EXISTS +-- - 시드 : INSERT IGNORE (slug 유니크 키로 중복 삽입 차단) +-- created_at / updated_at 은 애플리케이션이 DateTime.UtcNow 로 직접 기록한다. +-- (ON UPDATE CURRENT_TIMESTAMP 에 의존하지 않는다.) +-- ============================================================= + +CREATE TABLE IF NOT EXISTS tb_portfolio_category ( + category_no INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + description VARCHAR(255) NULL, + accent VARCHAR(32) NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_portfolio_category_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tb_portfolio_post ( + post_no INT AUTO_INCREMENT PRIMARY KEY, + category_no INT NOT NULL, + title VARCHAR(200) NOT NULL, + summary VARCHAR(500) NULL, + content_md MEDIUMTEXT NULL, + thumbnail_url VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY ix_portfolio_post_category (category_no), + CONSTRAINT fk_portfolio_post_category FOREIGN KEY (category_no) + REFERENCES tb_portfolio_category (category_no) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tb_portfolio_post_image ( + image_no INT AUTO_INCREMENT PRIMARY KEY, + post_no INT NOT NULL, + image_url VARCHAR(500) NOT NULL, + caption VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY ix_portfolio_post_image_post (post_no), + CONSTRAINT fk_portfolio_post_image_post FOREIGN KEY (post_no) + REFERENCES tb_portfolio_post (post_no) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------- +-- 기본 카테고리 시드 (재실행해도 중복되지 않음) +-- ------------------------------------------------------------- +INSERT IGNORE INTO tb_portfolio_category (slug, name, description, accent, sort_order, created_at) +VALUES ('game', '게임', 'Unity 게임 프로젝트', '#7c5cff', 1, UTC_TIMESTAMP()); + +INSERT IGNORE INTO tb_portfolio_category (slug, name, description, accent, sort_order, created_at) +VALUES ('dev', '개발', '서버 · 툴 · 시스템', '#20d5c4', 2, UTC_TIMESTAMP()); + +INSERT IGNORE INTO tb_portfolio_category (slug, name, description, accent, sort_order, created_at) +VALUES ('art', '아트', '그래픽 · UI · 연출', '#ff5c9a', 3, UTC_TIMESTAMP()); + +INSERT IGNORE INTO tb_portfolio_category (slug, name, description, accent, sort_order, created_at) +VALUES ('log', '기록', '작업 일지와 회고', '#ffb74d', 4, UTC_TIMESTAMP()); diff --git a/GameServer/wwwroot/css/style.css b/GameServer/wwwroot/css/style.css new file mode 100644 index 0000000..8f29b83 --- /dev/null +++ b/GameServer/wwwroot/css/style.css @@ -0,0 +1,1866 @@ +/* ========================================================================== + 김낙준 포트폴리오 — EDITORIAL INDEX + 밝은 종이 바탕. 배경 장식 없음. 타이포와 여백, 얇은 괘선이 화면을 만든다. + + 설계 원칙 + · 목록은 카드 그리드가 아니라 '작품집 목차'다. 번호 → 제목 → 메타. + · 장식(그라디언트 블롭·큰 그림자)을 쓰지 않는다. 위계는 크기 대비로 만든다. + · 한글 본문은 산세리프, 숫자·라벨은 모노스페이스. 이 대비가 유일한 장식이다. + · 색은 카테고리 강조색으로만 들어온다. + + 명암비 (#FBF9FF 바탕 기준) + 본문 --text 14.9 : 1 + 보조 --text-dim 7.8 : 1 + 흐림 --text-mute 6.6 : 1 + 링크 --accent 5.4 : 1 + + 구성 + 1. 토큰 + 2. 리셋 / 기본 + 3. 표면(유리) 기본형 + 4. 내비게이션 + 5. 히어로 / 섹션 + 6. 인라인 카테고리 필터 + 7. 인덱스 목록 + 커서 썸네일 + 8. 게시물 상세 + 9. 마크다운 본문 + 10. 버튼 / 폼 + 11. 관리자 + 12. 피드백 + 13. 유틸리티 + 14. 반응형 + 15. 모션 최소화 + ========================================================================== */ + +/* -------------------------------------------------------------------------- + 1. 토큰 + -------------------------------------------------------------------------- */ + +:root { + /* 바탕 */ + --bg: #FBF9FF; + --bg-2: #F4F0FB; + --surface: #FFFFFF; + --surface-2: #F6F2FD; + + /* 표면 */ + --glass: rgba(255, 255, 255, 0.82); + --glass-border: rgba(124, 92, 200, 0.16); + --glass-hi: rgba(255, 255, 255, 0.95); + --blur: blur(14px) saturate(130%); + + /* 괘선 — 이 디자인의 뼈대 */ + --rule: rgba(42, 33, 64, 0.12); + --rule-strong: rgba(42, 33, 64, 0.26); + + /* 글자 */ + --text: #2A2140; + --text-dim: #584A75; + --text-mute: #635578; + + /* 강조 */ + --accent: #6D48F0; + --accent-2: #E85BA0; + --accent-3: #1F9E8B; + --accent-ink: #FFFFFF; + + /* 그라디언트 — 아주 제한적으로만 쓴다 */ + --grad-hero: linear-gradient(100deg, #8B6BFF 0%, #C56FE8 45%, #F072B8 100%); + --grad-accent: linear-gradient(135deg, #8B6BFF 0%, #E85BA0 100%); + + /* 그림자 — 떠 있는 것(썸네일·모달·토스트)에만 */ + --shadow: + 0 1px 2px rgba(76, 46, 140, 0.05), + 0 4px 14px -6px rgba(76, 46, 140, 0.14); + --shadow-lg: + 0 2px 8px rgba(76, 46, 140, 0.07), + 0 18px 40px -16px rgba(76, 46, 140, 0.26); + --ring: + 0 0 0 2px var(--bg), + 0 0 0 4px rgba(109, 72, 240, 0.6); + + /* 형태 */ + --radius-sm: 10px; + --radius: 14px; + --radius-lg: 20px; + + /* 타이포 — 디스플레이와 본문의 낙차를 크게 둔다 */ + --step--2: clamp(0.68rem, 0.66rem + 0.10vw, 0.74rem); + --step--1: clamp(0.78rem, 0.75rem + 0.15vw, 0.86rem); + --step-0: clamp(0.94rem, 0.90rem + 0.20vw, 1.02rem); + --step-1: clamp(1.06rem, 0.99rem + 0.34vw, 1.26rem); + --step-2: clamp(1.24rem, 1.08rem + 0.76vw, 1.72rem); + --step-3: clamp(1.50rem, 1.20rem + 1.44vw, 2.44rem); + --step-4: clamp(1.95rem, 1.35rem + 2.90vw, 3.60rem); + --step-5: clamp(2.80rem, 1.30rem + 6.60vw, 6.40rem); + + /* 간격 */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 18px; + --space-5: 26px; + --space-6: 40px; + --space-7: 64px; + --space-8: 104px; + + /* 모션 */ + --ease: cubic-bezier(0.2, 0.8, 0.2, 1); + --dur: 220ms; + + /* 카테고리 강조색. JS 가 인라인으로 덮어쓴다. */ + --card-accent: var(--accent); + + /* 글꼴 */ + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Pretendard, + "Apple SD Gothic Neo", "Malgun Gothic", Roboto, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "Cascadia Mono", Consolas, monospace; + + color-scheme: light; +} + +/* -------------------------------------------------------------------------- + 2. 리셋 / 기본 + -------------------------------------------------------------------------- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font); + font-size: var(--step-0); + line-height: 1.75; + color: var(--text); + /* 장식 없는 종이. 위쪽만 아주 미세하게 밝다. */ + background-color: var(--bg); + background-image: linear-gradient(180deg, #FFFFFF 0%, var(--bg) 42%); + background-repeat: no-repeat; + background-size: 100% 60vh; + background-attachment: fixed; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +img, +svg, +video { + max-width: 100%; + display: block; +} + +a { + color: var(--accent); + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +h1, h2, h3, h4, h5, h6 { + margin: 0; + line-height: 1.22; + letter-spacing: -0.025em; + font-weight: 700; +} + +p { + margin: 0; +} + +ol, ul { + margin: 0; + padding: 0; +} + +em { + font-style: normal; + color: var(--accent-3); + font-weight: 600; +} + +::selection { + background: rgba(232, 91, 160, 0.22); + color: var(--text); +} + +:focus-visible { + outline: none; + box-shadow: var(--ring); + border-radius: var(--radius-sm); +} + +* { + scrollbar-width: thin; + scrollbar-color: rgba(124, 92, 200, 0.3) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-thumb { + background: rgba(124, 92, 200, 0.24); + border-radius: 99px; + border: 2px solid transparent; + background-clip: content-box; +} + +*::-webkit-scrollbar-thumb:hover { + background: rgba(124, 92, 200, 0.42); + background-clip: content-box; +} + +/* -------------------------------------------------------------------------- + 3. 표면(유리) 기본형 — 소개 패널·모달·관리자 카드에만 쓴다 + -------------------------------------------------------------------------- */ + +.glass { + position: relative; + background-color: var(--glass); + -webkit-backdrop-filter: var(--blur); + backdrop-filter: var(--blur); + border: 1px solid var(--rule); + border-radius: var(--radius); +} + +@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + .glass { + background-color: var(--surface); + } +} + +/* -------------------------------------------------------------------------- + 4. 내비게이션 — 떠 있는 알약이 아니라 얇은 괘선 한 줄 + -------------------------------------------------------------------------- */ + +.nav { + position: sticky; + top: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; + width: min(1080px, calc(100% - 2 * var(--space-5))); + margin: 0 auto; + padding: var(--space-4) 0; + border-radius: 0; + border-bottom: 1px solid var(--rule); + background: color-mix(in srgb, var(--bg) 88%, transparent); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); +} + +/* .nav 는 index.html 에서 .glass 도 함께 갖는다. 여기서는 그 장식을 지운다. */ +.nav.glass { + border: 0; + border-bottom: 1px solid var(--rule); + border-radius: 0; +} + +.nav__brand { + display: inline-flex; + align-items: center; + gap: var(--space-3); + font-weight: 700; + font-size: var(--step-0); + letter-spacing: -0.01em; + color: var(--text); +} + +.nav__mark { + width: 10px; + height: 10px; + border-radius: 3px; + background: var(--grad-accent); + flex: none; +} + +.nav__links { + display: flex; + align-items: center; + gap: var(--space-5); + flex-wrap: wrap; +} + +.nav__link { + position: relative; + padding: var(--space-1) 0; + color: var(--text-mute); + font-size: var(--step--1); + font-weight: 600; + transition: color var(--dur) var(--ease); +} + +.nav__link:hover { + color: var(--text); +} + +.nav__link.is-active { + color: var(--text); +} + +/* 활성 항목에만 얇은 밑줄 */ +.nav__link.is-active::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 2px; + background: var(--grad-accent); +} + +.nav__admin { + padding: 0; + border: 0; + background: none; + color: var(--text-mute); + font-size: var(--step--1); + font-weight: 600; + font-family: var(--font-mono); + cursor: pointer; + transition: color var(--dur) var(--ease); +} + +.nav__admin:hover { + color: var(--accent); +} + +/* -------------------------------------------------------------------------- + 5. 히어로 / 섹션 + -------------------------------------------------------------------------- */ + +.shell { + position: relative; + z-index: 1; + width: min(1080px, calc(100% - 2 * var(--space-5))); + margin: 0 auto; + padding: var(--space-8) 0; + display: block; +} + +.hero { + padding: 0 0 var(--space-6); +} + +/* 인덱스 구조에서는 쓰지 않지만, 다른 화면이 쓸 수 있어 남겨 둔다. */ +.hero__eyebrow { + display: inline-block; + font-family: var(--font-mono); + font-size: var(--step--2); + letter-spacing: 0.34em; + text-transform: uppercase; + color: var(--text-mute); + margin-bottom: var(--space-5); +} + +/* 화면에서 가장 큰 요소. 여기서 위계가 결정된다. */ +.hero__title { + font-size: var(--step-5); + font-weight: 800; + letter-spacing: -0.05em; + line-height: 0.98; + max-width: min(100%, 12em); + word-break: keep-all; + color: var(--text); +} + +.hero__grad { + color: var(--accent); +} + +@supports ((-webkit-background-clip: text) or (background-clip: text)) { + .hero__grad { + background: var(--grad-hero); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } +} + +.hero__sub { + margin-top: var(--space-5); + max-width: min(100%, 30em); + word-break: keep-all; + font-size: var(--step-1); + color: var(--text-dim); + line-height: 1.7; +} + +/* 통계는 배지가 아니라 모노스페이스 한 줄 */ +.hero__meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-5); + margin-top: var(--space-6); + padding-top: var(--space-4); + border-top: 1px solid var(--rule); + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--text-mute); +} + +.hero__stat b { + color: var(--text); + font-weight: 700; +} + +.section { + margin-top: var(--space-7); +} + +.section__head { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +.section__title { + font-size: var(--step-2); + letter-spacing: -0.03em; + color: var(--text); +} + +.section__sub { + font-size: var(--step--1); + color: var(--text-mute); +} + +/* -------------------------------------------------------------------------- + 6. 인라인 카테고리 필터 — 칩이 아니라 글자 + -------------------------------------------------------------------------- */ + +.filters { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-4); +} + +.filter { + position: relative; + display: inline-flex; + align-items: baseline; + gap: var(--space-2); + padding: var(--space-1) 0; + color: var(--text-mute); + font-size: var(--step--1); + font-weight: 600; + white-space: nowrap; + transition: color var(--dur) var(--ease); +} + +.filter:hover { + color: var(--text); +} + +.filter.is-active { + color: var(--text); +} + +/* 활성 항목은 카테고리 색 밑줄 */ +.filter.is-active::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + border-radius: 2px; + background: var(--card-accent); +} + +.filter__count { + font-family: var(--font-mono); + font-size: var(--step--2); + color: var(--text-mute); + opacity: 0.7; +} + +.filter.is-active .filter__count { + color: var(--card-accent); + opacity: 1; +} + +/* -------------------------------------------------------------------------- + 7. 인덱스 목록 + 커서 썸네일 + -------------------------------------------------------------------------- */ + +/* 대표 작업 — 목록 맨 앞 글을 크게 보여 준다. + 글이 하나뿐이어도 화면이 비어 보이지 않게 하는 역할. */ +.lead { + display: grid; + grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); + gap: var(--space-6); + align-items: center; + margin-bottom: var(--space-7); + color: var(--text); +} + +.lead__media { + position: relative; + margin: 0; + aspect-ratio: 16 / 10; + overflow: hidden; + border-radius: var(--radius); + border: 1px solid var(--rule); + background: var(--surface-2); +} + +.lead__media img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 620ms var(--ease); +} + +.lead:hover .lead__media img { + transform: scale(1.03); +} + +/* 이미지가 없는 글의 자리 — 카테고리 색으로 옅게 물든 판 + 첫 글자 */ +.lead__media--none, +.idx__thumb--none { + display: grid; + place-items: center; + background-color: var(--surface-2); + background-image: + linear-gradient(140deg, + color-mix(in srgb, var(--card-accent) 16%, transparent) 0%, + transparent 62%); + color: color-mix(in srgb, var(--card-accent) 45%, var(--text-mute)); + font-family: var(--font-mono); + font-weight: 700; + line-height: 1; +} + +.lead__media--none { + font-size: clamp(56px, 8vw, 104px); +} + +.idx__thumb--none { + font-size: 20px; +} + +/* color-mix 미지원 브라우저 대비 */ +@supports not (background: color-mix(in srgb, red 10%, transparent)) { + .lead__media--none, + .idx__thumb--none { + background-image: none; + color: var(--text-mute); + } +} + +.lead__body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); + min-width: 0; +} + +/* 한글은 자간을 크게 벌리면 글자가 흩어져 보인다. 살짝만 준다. */ +.lead__label { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--step--2); + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-mute); +} + +.lead__label::before { + content: ""; + width: 18px; + height: 2px; + border-radius: 2px; + background: var(--card-accent); +} + +.lead__cat { + font-family: var(--font-mono); + font-size: var(--step--1); + font-weight: 700; + letter-spacing: 0.06em; + color: var(--card-accent); +} + +.lead__title { + font-size: var(--step-3); + font-weight: 800; + letter-spacing: -0.035em; + line-height: 1.2; + word-break: keep-all; + color: var(--text); +} + +.lead__sum { + font-size: var(--step-0); + color: var(--text-dim); + line-height: 1.75; + word-break: keep-all; +} + +.lead__meta { + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--text-mute); +} + +.lead__more { + margin-top: var(--space-2); + font-size: var(--step--1); + font-weight: 700; + color: var(--card-accent); + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--card-accent) 32%, transparent); + transition: box-shadow var(--dur) var(--ease); +} + +.lead__more::after { + content: " →"; +} + +.lead:hover .lead__more { + box-shadow: inset 0 -2px 0 var(--card-accent); +} + +/* 인덱스 행의 작은 썸네일 */ +.idx__thumb { + margin: 0; + width: 96px; + aspect-ratio: 4 / 3; + overflow: hidden; + border-radius: var(--radius-sm); + border: 1px solid var(--rule); + background: var(--surface-2); + flex: none; + transition: transform var(--dur) var(--ease), border-color var(--dur) var(--ease); +} + +.idx__thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.idx:hover .idx__thumb { + transform: scale(1.05); + border-color: color-mix(in srgb, var(--card-accent) 45%, var(--rule)); +} + +.index { + list-style: none; + border-top: 1px solid var(--rule-strong); +} + +.index__item { + border-bottom: 1px solid var(--rule); +} + +/* 행 하나. 번호 / 본문 / 메타 세 칸 그리드. */ +.idx { + display: grid; + /* 번호 · 썸네일 · 본문 · 메타 */ + grid-template-columns: 2.6rem auto 1fr auto; + align-items: center; + gap: var(--space-4); + padding: var(--space-4) 0; + color: var(--text); + transition: padding-left var(--dur) var(--ease), color var(--dur) var(--ease); +} + +.idx:hover { + /* 행 전체가 살짝 안으로 밀려 들어온다 */ + padding-left: var(--space-4); +} + +.idx__no { + font-family: var(--font-mono); + font-size: var(--step--1); + font-weight: 700; + color: var(--text-mute); + letter-spacing: 0.02em; + align-self: center; + transition: color var(--dur) var(--ease); +} + +.idx:hover .idx__no { + color: var(--card-accent); +} + +.idx__body { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} + +.idx__title { + font-size: var(--step-2); + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.3; + word-break: keep-all; + color: var(--text); +} + +/* 요약은 항상 보여 준다. + 호버로 높이를 바꾸면 아래 행들이 커서 밑에서 밀려 목록을 훑기가 어려워진다. */ +.idx__sum { + font-size: var(--step--1); + color: var(--text-mute); + line-height: 1.65; + word-break: keep-all; + max-width: 46em; + transition: color var(--dur) var(--ease); +} + +.idx:hover .idx__sum { + color: var(--text-dim); +} + +.idx__meta { + display: flex; + align-items: baseline; + gap: var(--space-4); + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--text-mute); + white-space: nowrap; +} + +.idx__cat { + color: var(--card-accent); + font-weight: 700; +} + +.idx__date { + font-variant-numeric: tabular-nums; +} + +/* 로딩 스켈레톤 */ +.index__item--skeleton .idx { + pointer-events: none; +} + +.index__item--skeleton .idx__no, +.index__item--skeleton .idx__title, +.index__item--skeleton .idx__meta { + display: block; + border-radius: 4px; + background: linear-gradient(100deg, + rgba(42, 33, 64, 0.05) 30%, + rgba(42, 33, 64, 0.10) 50%, + rgba(42, 33, 64, 0.05) 70%); + background-size: 220% 100%; + animation: shimmer 1.4s linear infinite; + color: transparent; +} + +.index__item--skeleton .idx__no { height: 12px; } +.index__item--skeleton .idx__title { height: 20px; width: 58%; } +.index__item--skeleton .idx__meta { height: 12px; width: 88px; } + +.index__item--skeleton .idx__thumb { + background: linear-gradient(100deg, + rgba(42, 33, 64, 0.05) 30%, + rgba(42, 33, 64, 0.10) 50%, + rgba(42, 33, 64, 0.05) 70%); + background-size: 220% 100%; + animation: shimmer 1.4s linear infinite; +} + +@keyframes shimmer { + from { background-position: 140% 0; } + to { background-position: -60% 0; } +} + +#home-more { + margin-top: var(--space-6); + display: flex; + justify-content: center; +} + +#home-state:not(:empty) { + margin-top: var(--space-5); +} + +/* -------------------------------------------------------------------------- + 8. 게시물 상세 + -------------------------------------------------------------------------- */ + +.post { + max-width: 100%; +} + +/* 소개 화면처럼 본문이 패널 안에 바로 들어가는 경우 */ +.glass.post { + max-width: min(100%, 46em); + padding: clamp(var(--space-5), 3.5vw, var(--space-7)); +} + +.glass.post .post__body { + max-width: 100%; +} + +.post__head { + margin-bottom: var(--space-6); + padding-bottom: var(--space-5); + border-bottom: 1px solid var(--rule); +} + +.post__cat { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-family: var(--font-mono); + font-size: var(--step--1); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--card-accent); +} + +.post__title { + margin-top: var(--space-4); + font-size: var(--step-4); + letter-spacing: -0.04em; + line-height: 1.1; + max-width: min(100%, 15em); + word-break: keep-all; + color: var(--text); +} + +.post__meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-5); + margin-top: var(--space-5); + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--text-mute); +} + +.post__nav { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + margin-top: var(--space-7); + padding-top: var(--space-5); + border-top: 1px solid var(--rule); +} + +/* -------------------------------------------------------------------------- + 9. 마크다운 본문 + -------------------------------------------------------------------------- */ + +.post__body { + max-width: min(100%, 36em); + font-size: var(--step-0); + line-height: 1.95; + color: var(--text-dim); + word-break: keep-all; +} + +.post__body > *:first-child { + margin-top: 0; +} + +.md-h1, .md-h2, .md-h3, .md-h4, .md-h5, .md-h6 { + color: var(--text); + margin: var(--space-7) 0 var(--space-3); + scroll-margin-top: 96px; + letter-spacing: -0.03em; +} + +.md-h1 { font-size: var(--step-3); } +.md-h2 { font-size: var(--step-2); } +.md-h3 { font-size: var(--step-1); } +.md-h4 { font-size: var(--step-0); } +.md-h5 { font-size: var(--step-0); color: var(--text-dim); } + +.md-h6 { + font-family: var(--font-mono); + font-size: var(--step--2); + color: var(--text-mute); + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.md-p { + margin: var(--space-4) 0; +} + +.md-ul, +.md-ol { + margin: var(--space-4) 0; + padding-left: 1.5em; +} + +.md-ul { list-style: none; } +.md-ol { list-style: decimal; } + +.md-li { + margin: var(--space-2) 0; + position: relative; +} + +/* 불릿은 짧은 선 하나 — 목차 디자인과 같은 어휘 */ +.md-ul > .md-li::before { + content: ""; + position: absolute; + left: -1.4em; + top: 0.92em; + width: 12px; + height: 1.5px; + background: var(--accent); +} + +.md-ol::marker, +.md-ol > .md-li::marker { + color: var(--text-mute); + font-family: var(--font-mono); + font-size: 0.9em; +} + +.md-ul .md-ul, +.md-ul .md-ol, +.md-ol .md-ul, +.md-ol .md-ol { + margin: var(--space-2) 0; +} + +.md-quote { + margin: var(--space-6) 0; + padding: 0 0 0 var(--space-5); + border-left: 2px solid var(--accent-2); + color: var(--text); + font-size: var(--step-1); + line-height: 1.7; +} + +.md-quote > *:first-child { margin-top: 0; } +.md-quote > *:last-child { margin-bottom: 0; } + +.md-code { + margin: var(--space-5) 0; + padding: var(--space-4) var(--space-5); + border-radius: var(--radius-sm); + border: 1px solid var(--rule); + background: #FAF8FE; + overflow-x: auto; + font-family: var(--font-mono); + font-size: var(--step--1); + line-height: 1.7; + color: #3B2E5A; +} + +.md-code code { + font: inherit; + color: inherit; + background: none; + padding: 0; + white-space: pre; +} + +.md-inline-code { + font-family: var(--font-mono); + font-size: 0.9em; + padding: 0.12em 0.4em; + border-radius: 5px; + background: rgba(124, 92, 200, 0.10); + color: #5B3FC7; + white-space: break-spaces; +} + +.md-hr { + margin: var(--space-7) 0; + border: 0; + height: 1px; + background: var(--rule); +} + +.md-link { + color: var(--text); + font-weight: 600; + box-shadow: inset 0 -2px 0 rgba(109, 72, 240, 0.35); + transition: box-shadow var(--dur) var(--ease), color var(--dur) var(--ease); +} + +.md-link:hover { + color: var(--accent); + box-shadow: inset 0 -2px 0 var(--accent); +} + +/* 이미지 — 기본은 본문 폭 100%. + alt 뒤에 크기 지시자를 붙여 조절한다: ![캡션|half](주소) + small 약 260px half 절반(연속 2장은 나란히) + wide 본문보다 넓게 숫자 그 픽셀 폭 (예: |320) */ +.md-figure { + margin: var(--space-6) 0; +} + +.md-img { + width: 100%; + height: auto; + border-radius: var(--radius-sm); + border: 1px solid var(--rule); + background: var(--surface-2); +} + +/* 캡션 — alt 텍스트가 있으면 이미지 아래에 붙는다 */ +.md-cap { + margin-top: var(--space-2); + font-size: var(--step--2); + color: var(--text-mute); + line-height: 1.55; +} + +/* 유튜브 임베드 — 16:9 비율을 유지한다 */ +.md-embed { + margin: var(--space-6) 0; + aspect-ratio: 16 / 9; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--rule); + background: #000; +} + +.md-embed__frame { + display: block; + width: 100%; + height: 100%; + border: 0; +} + +.md-figure--small { + width: min(100%, 260px); +} + +.md-figure--fixed { + width: min(100%, var(--md-img-w, 100%)); +} + +/* 연속해서 두 장을 쓰면 나란히 놓인다 (inline-block 사이 공백만큼 뺀다) */ +.md-figure--half { + display: inline-block; + vertical-align: top; + width: min(100%, calc(50% - 5px)); +} + +/* 본문 단(36em)보다 넓게. 화면이 좁으면 알아서 본문 폭으로 줄어든다. */ +.md-figure--wide { + width: min(calc(100vw - 2 * var(--space-5)), 46em); + max-width: none; + margin-left: 50%; + transform: translateX(-50%); +} + +.post__body strong { color: var(--text); font-weight: 700; } +.post__body em { color: var(--accent-3); font-style: italic; font-weight: 600; } +.post__body del { color: var(--text-mute); } + +.post__body table { + width: 100%; + border-collapse: collapse; + margin: var(--space-5) 0; + font-size: var(--step--1); +} + +.post__body th, +.post__body td { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--rule); + text-align: left; +} + +/* -------------------------------------------------------------------------- + 10. 버튼 / 폼 + -------------------------------------------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + border-radius: 99px; + border: 1px solid var(--rule-strong); + background: transparent; + color: var(--text); + font-size: var(--step--1); + font-weight: 700; + cursor: pointer; + white-space: nowrap; + text-decoration: none; + transition: background-color var(--dur) var(--ease), border-color var(--dur) var(--ease), + color var(--dur) var(--ease); +} + +.btn:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(109, 72, 240, 0.06); +} + +.btn--primary { + background: var(--text); + border-color: var(--text); + color: var(--accent-ink); +} + +.btn--primary:hover { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); +} + +.btn--ghost { + border-color: var(--rule); +} + +.btn--danger { + border-color: rgba(224, 74, 106, 0.4); + color: #C3324F; +} + +.btn--danger:hover { + border-color: #C3324F; + background: rgba(224, 74, 106, 0.08); + color: #A9203C; +} + +.btn--sm { + padding: var(--space-1) var(--space-4); +} + +.btn:disabled, +.btn[disabled] { + opacity: 0.45; + cursor: not-allowed; +} + +/* 폼 */ + +.field { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} + +.field__label { + font-family: var(--font-mono); + font-size: var(--step--2); + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-mute); +} + +.field__hint { + font-size: var(--step--1); + color: var(--text-mute); + line-height: 1.6; +} + +.field__hint.is-error { + color: #C3324F; +} + +.input, +.textarea, +.select { + width: 100%; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-sm); + border: 1px solid var(--rule); + background: var(--surface); + color: var(--text); + font-size: var(--step-0); + transition: border-color var(--dur) var(--ease); +} + +.input::placeholder, +.textarea::placeholder { + color: var(--text-mute); + opacity: 0.7; +} + +.input:hover, +.textarea:hover, +.select:hover { + border-color: var(--rule-strong); +} + +.input:focus, +.textarea:focus, +.select:focus { + outline: none; + border-color: var(--accent); + box-shadow: var(--ring); +} + +.input[type="color"] { + padding: var(--space-1); + height: 44px; + cursor: pointer; +} + +.input[type="number"] { + font-family: var(--font-mono); +} + +.textarea { + min-height: 420px; + flex: 1 1 auto; + max-height: 70vh; + resize: vertical; + font-family: var(--font-mono); + font-size: var(--step--1); + line-height: 1.75; + white-space: pre-wrap; + tab-size: 4; +} + +.select { + appearance: none; + -webkit-appearance: none; + padding-right: var(--space-6); + background-image: + linear-gradient(45deg, transparent 50%, var(--text-dim) 50%), + linear-gradient(135deg, var(--text-dim) 50%, transparent 50%); + background-position: + calc(100% - 20px) calc(50% + 2px), + calc(100% - 14px) calc(50% + 2px); + background-size: 6px 6px, 6px 6px; + background-repeat: no-repeat; + cursor: pointer; +} + +.select option { + background: var(--surface); + color: var(--text); +} + +.form-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: flex-end; +} + +.form-row > * { + flex: 1 1 140px; + min-width: 0; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); +} + +/* -------------------------------------------------------------------------- + 11. 관리자 + -------------------------------------------------------------------------- */ + +.admin { + padding: var(--space-6); + max-width: 440px; + margin: var(--space-8) auto; + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.admin__bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-3); + margin-bottom: var(--space-5); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--rule-strong); +} + +.admin__title { + font-size: var(--step-2); + letter-spacing: -0.03em; + color: var(--text); +} + +.editor { + display: flex; + flex-direction: column; + gap: var(--space-5); + padding: var(--space-5); +} + +.editor__panes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + align-items: stretch; +} + +.editor__pane { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} + +.editor__preview { + min-height: 420px; + max-height: 70vh; + overflow-y: auto; + padding: var(--space-4) var(--space-5); + border-radius: var(--radius-sm); + border: 1px solid var(--rule); + background: var(--surface); + max-width: none; +} + +.dropzone { + display: grid; + place-items: center; + gap: var(--space-1); + padding: var(--space-6) var(--space-4); + border-radius: var(--radius-sm); + border: 1px dashed var(--rule-strong); + background: transparent; + color: var(--text-mute); + text-align: center; + cursor: pointer; + transition: border-color var(--dur) var(--ease), background-color var(--dur) var(--ease), + color var(--dur) var(--ease); +} + +.dropzone:hover { + border-color: var(--accent); + color: var(--accent); +} + +.dropzone.is-over { + border-color: var(--accent); + border-style: solid; + background: rgba(109, 72, 240, 0.06); + color: var(--accent); +} + +.dropzone[data-progress]::after { + content: attr(data-progress) " 업로드 중…"; + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--accent); +} + +.thumbs { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--space-3); +} + +.thumb { + position: relative; + margin: 0; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--rule); + background: var(--surface); +} + +.thumb.is-cover { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.thumb.is-cover::before { + content: "대표"; + position: absolute; + top: var(--space-2); + left: var(--space-2); + z-index: 2; + padding: 1px var(--space-3); + border-radius: 99px; + background: var(--accent); + color: var(--accent-ink); + font-family: var(--font-mono); + font-size: var(--step--2); + font-weight: 700; +} + +.thumb__img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; +} + +.thumb__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + padding: var(--space-2); + border-top: 1px solid var(--rule); +} + +.thumb__actions .btn { + flex: 1 1 auto; + padding: 2px var(--space-2); +} + +.rows { + display: flex; + flex-direction: column; + border-top: 1px solid var(--rule); +} + +/* 관리자 목록도 같은 어휘: 카드가 아니라 괘선으로 나뉜 행 */ +.row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-4); + padding: var(--space-4) 0; + border-bottom: 1px solid var(--rule); + transition: padding-left var(--dur) var(--ease); +} + +.row:hover { + padding-left: var(--space-3); +} + +.row.glass { + padding: var(--space-4); + margin-bottom: var(--space-4); + border: 1px solid var(--rule); + border-radius: var(--radius); +} + +.row.glass:hover { + padding-left: var(--space-4); +} + +.row__main { + display: flex; + flex-direction: column; + gap: var(--space-2); + flex: 1 1 320px; + min-width: 0; +} + +.row__side { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +/* 관리자 목록이 쓰는 카드 계열 클래스 (홈은 더 이상 쓰지 않는다) */ +.card__cat { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-family: var(--font-mono); + font-size: var(--step--2); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--card-accent); +} + +.card__title { + font-size: var(--step-0); + font-weight: 700; + color: var(--text); + word-break: keep-all; +} + +.card__meta { + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--text-mute); +} + +.cat__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + flex: none; +} + +/* -------------------------------------------------------------------------- + 12. 피드백 + -------------------------------------------------------------------------- */ + +.progress { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 2px; + z-index: 90; + pointer-events: none; + opacity: 0; + transition: opacity 200ms linear; +} + +.progress > span { + display: block; + height: 100%; + width: 40%; + background: var(--grad-accent); + transform: translateX(-100%); +} + +.progress.is-busy { + opacity: 1; +} + +.progress.is-busy > span { + animation: progress-slide 1.1s var(--ease) infinite; +} + +@keyframes progress-slide { + from { transform: translateX(-100%); } + to { transform: translateX(350%); } +} + +.toast-root { + position: fixed; + right: var(--space-5); + bottom: var(--space-5); + z-index: 80; + display: flex; + flex-direction: column-reverse; + gap: var(--space-2); + pointer-events: none; + max-width: min(380px, calc(100vw - 2 * var(--space-5))); +} + +.toast { + pointer-events: auto; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-sm); + border: 1px solid var(--rule); + background: var(--surface); + box-shadow: var(--shadow-lg); + color: var(--text); + font-size: var(--step--1); + font-weight: 600; + line-height: 1.55; + border-left: 3px solid var(--text-mute); + animation: toast-in 220ms var(--ease); +} + +.toast--ok { + border-left-color: var(--accent-3); +} + +.toast--error { + border-left-color: #E04A6A; +} + +@keyframes toast-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.modal { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: var(--space-4); + background: rgba(42, 33, 64, 0.32); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + animation: fade-in 160ms var(--ease); +} + +.modal__card { + width: min(420px, 100%); + padding: var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-4); + background-color: var(--surface); + border: 1px solid var(--rule); + box-shadow: var(--shadow-lg); + animation: modal-in 220ms var(--ease); +} + +.modal__msg { + color: var(--text); + font-size: var(--step-0); + line-height: 1.65; +} + +.modal__msg:first-child { + font-weight: 700; + font-size: var(--step-1); +} + +.modal__actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + flex-wrap: wrap; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modal-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.empty { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-8) 0; + text-align: left; + color: var(--text-dim); +} + +.empty__icon { + font-family: var(--font-mono); + font-size: var(--step-1); + line-height: 1; + color: var(--text-mute); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 1px var(--space-3); + border-radius: 99px; + border: 1px solid var(--rule); + font-family: var(--font-mono); + font-size: var(--step--2); + color: var(--text-mute); +} + +.tagline { + font-size: var(--step--1); + color: var(--text-mute); + line-height: 1.65; +} + +.foot { + position: relative; + z-index: 1; + width: min(1080px, calc(100% - 2 * var(--space-5))); + margin: 0 auto var(--space-7); + padding: var(--space-5) 0 0; + display: flex; + flex-direction: column; + gap: var(--space-1); + border-radius: 0; + border-top: 1px solid var(--rule); + background: none; +} + +.foot.glass { + border: 0; + border-top: 1px solid var(--rule); + background: none; + -webkit-backdrop-filter: none; + backdrop-filter: none; + border-radius: 0; +} + +/* -------------------------------------------------------------------------- + 13. 유틸리티 + -------------------------------------------------------------------------- */ + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +a.sr-only:focus, +a.sr-only:focus-visible { + position: fixed; + top: var(--space-3); + left: var(--space-3); + z-index: 200; + width: auto; + height: auto; + margin: 0; + padding: var(--space-3) var(--space-4); + clip: auto; + clip-path: none; + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + box-shadow: var(--ring); +} + +.stack { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.row-between { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +.is-hidden { + display: none !important; +} + +/* -------------------------------------------------------------------------- + 14. 반응형 + -------------------------------------------------------------------------- */ + +@media (max-width: 860px) { + .editor__panes { + grid-template-columns: 1fr; + } + + .textarea { + min-height: 320px; + } + + .editor__preview { + min-height: 220px; + } +} + +@media (max-width: 719px) { + .shell { + padding: var(--space-7) 0; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .nav__links { + gap: var(--space-4); + } + + .lead { + grid-template-columns: 1fr; + gap: var(--space-4); + margin-bottom: var(--space-6); + } + + /* 좁은 화면에서는 메타를 제목 아래로 내린다 */ + .idx { + grid-template-columns: 2rem 64px 1fr; + gap: var(--space-3); + padding: var(--space-4) 0; + } + + .idx__thumb { + width: 64px; + } + + .idx__meta { + grid-column: 3; + gap: var(--space-3); + } + + .idx:hover { + padding-left: 0; + } + + .row { + align-items: flex-start; + } + + .row__side { + width: 100%; + } + + .toast-root { + left: var(--space-3); + right: var(--space-3); + max-width: none; + } +} + +@media (max-width: 380px) { + .thumbs { + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + } +} + +/* 포인터가 없는 기기(터치)에서는 요약을 항상 보여 준다 */ +@media (hover: none) { + .idx:hover { + padding-left: 0; + } +} + +/* -------------------------------------------------------------------------- + 15. 모션 최소화 + -------------------------------------------------------------------------- */ + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + + .idx:hover, + .row:hover { + padding-left: 0; + } + +} diff --git a/GameServer/wwwroot/index.html b/GameServer/wwwroot/index.html new file mode 100644 index 0000000..57ab1ba --- /dev/null +++ b/GameServer/wwwroot/index.html @@ -0,0 +1,43 @@ + + + + + + + + + 김낙준 · 게임 서버 개발자 포트폴리오 + + + + + + + + 본문으로 건너뛰기 + + + +
+ +
+

Unity 게임 Genesis의 서버와 이 사이트를 함께 만들고 있습니다.

+

.NET 9 · ASP.NET Core · MariaDB · 프레임워크 없는 순수 ES 모듈

+
+ +
+ + + + + diff --git a/GameServer/wwwroot/js/api.js b/GameServer/wwwroot/js/api.js new file mode 100644 index 0000000..cf46d7e --- /dev/null +++ b/GameServer/wwwroot/js/api.js @@ -0,0 +1,245 @@ +// api.js — 서버 REST API 얇은 래퍼 +// 모든 응답 본문은 ApiResponse ( data / count / message ) 형태다. +// 성공하면 Data 를 벗겨서 돌려주고, 실패하면 ApiError 를 던진다. + +const BASE = '/api/portfolio'; + +/** 서버가 2xx 가 아닌 응답을 줬을 때 던지는 오류 */ +export class ApiError extends Error { + constructor(status, message) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.message = message; + } +} + +/** 상태 코드별 한국어 기본 메시지 (서버 message 가 없을 때만 사용) */ +function fallbackMessage(status) { + if (status === 0) return '서버에 연결할 수 없습니다. 네트워크를 확인해 주세요.'; + if (status === 400) return '요청 내용이 올바르지 않습니다.'; + if (status === 401) return '로그인이 필요합니다.'; + if (status === 403) return '권한이 없습니다.'; + if (status === 404) return '요청한 데이터를 찾을 수 없습니다.'; + if (status === 409) return '이미 존재하거나 처리할 수 없는 요청입니다.'; + if (status === 413) return '파일 용량이 너무 큽니다.'; + if (status === 429) return '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.'; + if (status === 503) return '서비스를 사용할 수 없습니다.'; + if (status >= 500) return '서버 오류가 발생했습니다.'; + return '요청을 처리하지 못했습니다.'; +} + +/** 204 / 빈 본문 / JSON 이 아닌 본문에서도 절대 throw 하지 않는 파서 */ +async function readBody(res) { + if (res.status === 204 || res.status === 205) return null; + let text = ''; + try { + text = await res.text(); + } catch { + return null; + } + if (!text || !text.trim()) return null; + try { + return JSON.parse(text); + } catch { + // JSON 이 아니면 원문을 그대로 메시지 후보로 넘긴다. + return { message: text }; + } +} + +/** + * 본문 크기 초과처럼 ASP.NET 이 우리 형식(ApiResponse)을 거치지 않고 돌려주는 + * 영문 오류를 한국어로 바꾼다. 해당하지 않으면 빈 문자열. + */ +function translateFrameworkError(body) { + let text = ''; + try { + text = JSON.stringify(body); + } catch { + return ''; + } + if (/Request body too large|request body size/i.test(text)) { + const m = /(\d{6,})\s*bytes/.exec(text); + const mb = m ? Math.floor(Number(m[1]) / (1024 * 1024)) : 0; + return mb > 0 + ? '파일이 너무 큽니다. 최대 ' + mb + 'MB 까지 올릴 수 있습니다.' + : '파일이 너무 큽니다.'; + } + if (/Multipart body length limit/i.test(text)) { + return '파일이 너무 큽니다.'; + } + return ''; +} + +/** 응답 본문에서 사람이 읽을 메시지를 고른다. ApiResponse / ProblemDetails 모두 지원 */ +function pickMessage(body) { + if (!body || typeof body !== 'object') return ''; + + // 프레임워크가 직접 낸 영문 오류를 먼저 걸러 낸다. + const translated = translateFrameworkError(body); + if (translated) return translated; + + const candidates = [body.message, body.Message, body.detail, body.title, body.error]; + for (const value of candidates) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +/** ApiResponse 래퍼에서 Data 만 꺼낸다. 래퍼가 아니면 그대로 돌려준다. */ +function unwrapData(body) { + if (!body || typeof body !== 'object') return body; + if ('data' in body) return body.data; + if ('Data' in body) return body.Data; + return body; +} + +/** ApiResponse 의 Count 를 꺼낸다. 없으면 null */ +function unwrapCount(body) { + if (!body || typeof body !== 'object') return null; + if (typeof body.count === 'number') return body.count; + if (typeof body.Count === 'number') return body.Count; + return null; +} + +/** + * 공통 요청 함수. 성공 시 파싱된 본문(래핑된 상태)을 그대로 돌려준다. + * @param {string} path + * @param {{method?:string, body?:any, form?:FormData}} [options] + */ +async function request(path, options = {}) { + const method = options.method || 'GET'; + const init = { + method, + credentials: 'same-origin', + headers: { Accept: 'application/json' } + }; + + if (options.form) { + // multipart 는 브라우저가 boundary 를 붙여야 하므로 Content-Type 을 지정하지 않는다. + init.body = options.form; + } else if (options.body !== undefined) { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(options.body); + } + + let res; + try { + res = await fetch(path, init); + } catch { + throw new ApiError(0, fallbackMessage(0)); + } + + const body = await readBody(res); + if (!res.ok) { + throw new ApiError(res.status, pickMessage(body) || fallbackMessage(res.status)); + } + return body; +} + +/** 요청 후 Data 만 벗겨서 돌려준다. */ +async function requestData(path, options) { + const body = await request(path, options); + return unwrapData(body); +} + +/** 쿼리스트링을 만든다. null/undefined/빈문자열 값은 제외. */ +function queryString(params) { + const usp = new URLSearchParams(); + for (const key of Object.keys(params || {})) { + const value = params[key]; + if (value === null || value === undefined || value === '') continue; + usp.set(key, String(value)); + } + const text = usp.toString(); + return text ? '?' + text : ''; +} + +export const api = { + auth: { + /** 로그인 상태 조회. 401 을 던지지 않는다. → { authenticated: bool } */ + me() { + return requestData(BASE + '/auth/me'); + }, + /** 관리자 로그인 */ + login(password) { + return requestData(BASE + '/auth/login', { method: 'POST', body: { password } }); + }, + /** 관리자 로그아웃 */ + logout() { + return requestData(BASE + '/auth/logout', { method: 'POST', body: {} }); + } + }, + + categories: { + /** 카테고리 전체 목록 → CategoryDto[] */ + async list() { + const data = await requestData(BASE + '/categories'); + return Array.isArray(data) ? data : []; + }, + create(dto) { + return requestData(BASE + '/categories', { method: 'POST', body: dto }); + }, + update(no, dto) { + return requestData(BASE + '/categories/' + encodeURIComponent(no), { method: 'PUT', body: dto }); + }, + remove(no) { + return requestData(BASE + '/categories/' + encodeURIComponent(no), { method: 'DELETE' }); + } + }, + + posts: { + /** + * 게시물 목록. Count 는 페이지 크기가 아니라 전체 개수다. + * @returns {Promise<{items: Array, total: number}>} + */ + async list(opts = {}) { + const category = opts.category && opts.category !== 'all' ? opts.category : ''; + const qs = queryString({ category, page: opts.page, size: opts.size }); + const body = await request(BASE + '/posts' + qs); + const data = unwrapData(body); + const items = Array.isArray(data) ? data : []; + const count = unwrapCount(body); + return { items, total: typeof count === 'number' ? count : items.length }; + }, + /** 게시물 상세 → PostDetailDto */ + get(no) { + return requestData(BASE + '/posts/' + encodeURIComponent(no)); + }, + create(dto) { + return requestData(BASE + '/posts', { method: 'POST', body: dto }); + }, + update(no, dto) { + return requestData(BASE + '/posts/' + encodeURIComponent(no), { method: 'PUT', body: dto }); + }, + remove(no) { + return requestData(BASE + '/posts/' + encodeURIComponent(no), { method: 'DELETE' }); + } + }, + + uploads: { + /** + * 업로드 한도와 허용 확장자. + * @returns {Promise<{maxBytes:number, maxMegabytes:number, allowedExtensions:string[]}>} + */ + limit() { + return requestData(BASE + '/uploads/limit'); + }, + /** + * 이미지 업로드. 필드 이름은 'file', Content-Type 은 지정하지 않는다. + * @param {File} file + * @returns {Promise<{url:string, fileName:string, size:number}>} + */ + upload(file) { + const form = new FormData(); + form.append('file', file); + return requestData(BASE + '/uploads', { method: 'POST', form }); + }, + /** 업로드된 파일 삭제 (204 → null) */ + remove(url) { + return requestData(BASE + '/uploads' + queryString({ url }), { method: 'DELETE' }); + } + } +}; + +export default api; diff --git a/GameServer/wwwroot/js/app.js b/GameServer/wwwroot/js/app.js new file mode 100644 index 0000000..17e0d40 --- /dev/null +++ b/GameServer/wwwroot/js/app.js @@ -0,0 +1,87 @@ +// app.js — 포트폴리오 SPA 진입점 +// 라우트 표를 만들고 #app 에 라우터를 붙인다. +// 이 파일 하나만 index.html 에서 type="module" 로 읽어 들인다. + +import { createRouter, navigate } from './router.js'; +import { toast } from './ui.js'; +import { ApiError } from './api.js'; + +import { view as homeView } from './views/home.js'; +import { view as postView } from './views/post.js'; +import { view as aboutView } from './views/about.js'; +import { view as adminView, editorView, categoriesView } from './views/admin.js'; + +/** 라우트 표. 배열 순서대로 매칭하므로 하위 경로를 먼저 둔다. */ +const ROUTES = [ + { pattern: '#/', view: homeView, title: '김낙준 · 작업' }, + { pattern: '#/post/:postNo', view: postView, title: '김낙준 · 작업' }, + { pattern: '#/about', view: aboutView, title: '김낙준 · 소개' }, + + // '#/admin' 보다 먼저 와야 한다. 뒤에 두면 영원히 매칭되지 않는다. + { pattern: '#/admin/new', view: editorView, title: '김낙준 · 새 글' }, + { pattern: '#/admin/edit/:postNo', view: editorView, title: '김낙준 · 글 수정' }, + { pattern: '#/admin/categories', view: categoriesView, title: '김낙준 · 카테고리' }, + { pattern: '#/admin', view: adminView, title: '김낙준 · 관리자' }, + + // 알 수 없는 주소는 홈으로 흡수한다. + { pattern: '*', view: homeView, title: '김낙준 · 작업' } +]; + +/** 현재 경로에 해당하는 상단 내비 링크에 .is-active 를 붙인다. */ +function syncNav(path) { + const links = document.querySelectorAll('.nav__link'); + for (const link of links) { + const href = link.getAttribute('href') || ''; + const target = href.charAt(0) === '#' ? href.slice(1) : href; + const normalized = target === '/' || target === '' ? '/' : target; + + // 홈은 '/' 와 '/post/*' 를 모두 자기 것으로 본다. + const active = normalized === '/' + ? (path === '/' || path.indexOf('/post/') === 0) + : path === normalized || path.indexOf(normalized + '/') === 0; + + link.classList.toggle('is-active', active); + if (active) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + } +} + +/** ApiError 만 사용자에게 알린다. 그 밖의 오류는 콘솔에 맡긴다. */ +function reportUnhandled(reason) { + if (reason instanceof ApiError) { + toast(reason.message || '요청을 처리하지 못했습니다.', 'error'); + } +} + +function boot() { + const outlet = document.getElementById('app'); + if (!outlet) return; + + window.addEventListener('route:change', (ev) => { + const detail = (ev && ev.detail) || {}; + syncNav(String(detail.path || '/')); + }); + + const adminBtn = document.getElementById('nav-admin'); + if (adminBtn) { + adminBtn.addEventListener('click', () => navigate('#/admin')); + } + + window.addEventListener('unhandledrejection', (ev) => { + reportUnhandled(ev && ev.reason); + }); + window.addEventListener('error', (ev) => { + reportUnhandled(ev && ev.error); + }); + + createRouter(ROUTES, outlet); + syncNav(String((location.hash || '#/').replace(/^#/, '').split('?')[0] || '/')); +} + +console.log('%c김낙준 포트폴리오', 'color:#6D48F0;font-weight:700', '· ready'); + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot, { once: true }); +} else { + boot(); +} diff --git a/GameServer/wwwroot/js/markdown.js b/GameServer/wwwroot/js/markdown.js new file mode 100644 index 0000000..be8bc56 --- /dev/null +++ b/GameServer/wwwroot/js/markdown.js @@ -0,0 +1,632 @@ +// markdown.js — 의존성 없는 초경량 마크다운 렌더러 +// +// 보안 원칙 (매우 중요): +// 1) 원본을 "먼저 escape" 한 뒤에 HTML 을 조립한다. 따라서 작성자가 넣은 raw HTML 은 +// 절대 살아남지 못한다. +// 2) 펜스 코드 블록은 인라인 처리 이전에 먼저 뽑아내고, 그 내용은 다시 변환하지 않는다. +// 3) 링크/이미지 URL 은 화이트리스트(http:, https:, mailto:, '/' 로 시작하는 경로)만 통과시킨다. +// 4) 내부 자리표시자로 쓰는 PUA 문자는 입력 단계에서 제거해 위조를 막는다. + +/** 펜스 코드 블록 자리표시자 경계 문자 (U+E000, 사용자 입력에서는 제거된다) */ +const CODE_MARK = '\ue000'; +/** 인라인 보호 구간 자리표시자 경계 문자 (U+E001) */ +const SHIELD_MARK = '\ue001'; + +const CODE_PLACEHOLDER_RE = /\ue000(\d+)\ue000/g; +const CODE_PLACEHOLDER_LINE_RE = /^\s*\ue000(\d+)\ue000\s*$/; +const SHIELD_RE = /\ue001(\d+)\ue001/g; + +const HEADING_RE = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/; +const HR_RE = /^ {0,3}(?:-[ \t]*){3,}$|^ {0,3}(?:\*[ \t]*){3,}$|^ {0,3}(?:_[ \t]*){3,}$/; +const QUOTE_RE = /^ {0,3}>[ \t]?/; +const BULLET_RE = /^([ \t]*)([-*+])[ \t]+(.*)$/; +const ORDERED_RE = /^([ \t]*)(\d{1,9})[.)][ \t]+(.*)$/; + +const ESCAPE_MAP = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +}; + +/** HTML escape (markdown.js 는 자체 완결이므로 ui.js 를 import 하지 않는다) */ +function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str).replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]); +} + +/** 개행 통일, 탭 전개, 내부 자리표시자 문자 제거 */ +function normalizeSource(src) { + if (src === null || src === undefined) return ''; + return String(src) + .replace(/\x00/g, '') + .replace(/[\ue000\ue001]/g, '') + .replace(/\r\n?/g, '\n') + .replace(/\t/g, ' '); +} + +// --------------------------------------------------------------------------- +// URL 화이트리스트 +// --------------------------------------------------------------------------- + +/** 코드포인트를 안전한 문자로. 범위를 벗어나면 빈 문자열 */ +function safeFromCodePoint(code) { + if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return ''; + try { + return String.fromCodePoint(code); + } catch { + return ''; + } +} + +/** 검사 전용 엔티티 디코드 1회 (출력에는 절대 쓰지 않는다) */ +function decodeEntitiesOnce(text) { + return text + .replace(/&#x([0-9a-fA-F]{1,6});?/g, (m, hex) => safeFromCodePoint(parseInt(hex, 16))) + .replace(/&#(\d{1,7});?/g, (m, dec) => safeFromCodePoint(parseInt(dec, 10))) + .replace(/&(amp|lt|gt|quot|apos|tab|newline|colon|sol|nbsp);?/gi, (m, name) => { + const key = name.toLowerCase(); + if (key === 'amp') return '&'; + if (key === 'lt') return '<'; + if (key === 'gt') return '>'; + if (key === 'quot') return '"'; + if (key === 'apos') return "'"; + if (key === 'tab') return '\t'; + if (key === 'newline') return '\n'; + if (key === 'colon') return ':'; + if (key === 'sol') return '/'; + return ' '; + }); +} + +/** + * 링크/이미지 URL 허용 여부. + * 허용: http:, https:, mailto:, 그리고 '/' 로 시작하는 사이트 상대 경로. + * 거부: javascript:, data:, vbscript:, 프로토콜 상대 '//', 제어문자·공백·엔티티 우회. + * @param {string} escapedUrl 이미 escape 된 URL 문자열 + * @returns {boolean} + */ +function isAllowedUrl(escapedUrl) { + if (typeof escapedUrl !== 'string' || escapedUrl === '') return false; + + // 엔티티를 반복 디코드해 우회 시도를 드러낸다. + let probe = escapedUrl; + for (let i = 0; i < 3; i += 1) { + const next = decodeEntitiesOnce(probe); + if (next === probe) break; + probe = next; + } + + // 제어문자 · 공백(선행 공백 포함) · 삭제문자가 하나라도 있으면 거부 + if (/[\x00- \x7f-\xa0\u2028\u2029\ufeff]/.test(probe)) return false; + + const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(probe); + if (scheme) { + const name = scheme[1].toLowerCase(); + return name === 'http' || name === 'https' || name === 'mailto'; + } + // 스킴이 없으면 사이트 루트 상대 경로만 허용 ('//host' 형태는 프로토콜 상대이므로 거부) + return probe.charAt(0) === '/' && probe.charAt(1) !== '/'; +} + +/** http/https 절대 URL 인지 (새 탭으로 열지 결정) */ +function isExternalUrl(escapedUrl) { + return /^https?:/i.test(decodeEntitiesOnce(escapedUrl)); +} + +// --------------------------------------------------------------------------- +// 펜스 코드 블록: 인라인 처리 이전에 통째로 뽑아낸다 +// --------------------------------------------------------------------------- + +/** 언어 이름 정리 (클래스 이름에 들어가므로 문자 제한) */ +function normalizeLanguage(raw) { + if (!raw) return ''; + return String(raw) + .trim() + .toLowerCase() + .replace(/[^a-z0-9_+#.-]/g, '') + .slice(0, 32); +} + +/** 코드 블록 HTML 조립 (내용은 escape 만 하고 그 밖의 변환은 하지 않는다) */ +function buildCodeBlock(code, language) { + const lang = normalizeLanguage(language); + const openCode = lang ? '' : ''; + return '
' + openCode + escapeHtml(code) + '
'; +} + +/** + * ``` / ~~~ 펜스 블록을 자리표시자로 치환한다. + * @param {string} source 정규화된 원본 + * @param {string[]} store 블록 HTML 저장소 + * @returns {string} + */ +function extractFencedBlocks(source, store) { + const lines = source.split('\n'); + const out = []; + let i = 0; + + while (i < lines.length) { + const open = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)[ \t]*$/.exec(lines[i]); + if (!open) { + out.push(lines[i]); + i += 1; + continue; + } + + const fence = open[1]; + const fenceChar = fence.charAt(0); + const language = open[2] || ''; + const body = []; + i += 1; + + while (i < lines.length) { + const close = new RegExp('^ {0,3}\\' + fenceChar + '{' + fence.length + ',}[ \\t]*$'); + if (close.test(lines[i])) { + i += 1; + break; + } + body.push(lines[i]); + i += 1; + } + + store.push(buildCodeBlock(body.join('\n'), language)); + out.push(CODE_MARK + (store.length - 1) + CODE_MARK); + } + + return out.join('\n'); +} + +/** 남아 있는 코드 자리표시자를 실제 HTML 로 되돌린다 */ +function expandCodePlaceholders(html, store) { + return html.replace(CODE_PLACEHOLDER_RE, (m, idx) => { + const entry = store[Number(idx)]; + return entry === undefined ? '' : entry; + }); +} + +// --------------------------------------------------------------------------- +// 인라인 처리 +// --------------------------------------------------------------------------- + +/** 조각을 보호 구간으로 넣고 자리표시자를 돌려준다 */ +function shield(store, html) { + store.push(html); + return SHIELD_MARK + (store.length - 1) + SHIELD_MARK; +} + +/** 보호 구간 복원 (중첩 대비 최대 4회) */ +function restoreShields(text, store) { + let out = text; + for (let pass = 0; pass < 4; pass += 1) { + if (out.indexOf(SHIELD_MARK) === -1) break; + out = out.replace(SHIELD_RE, (m, idx) => { + const entry = store[Number(idx)]; + return entry === undefined ? '' : entry; + }); + } + return out; +} + +/** 자리표시자 흔적 제거 (alt / title 처럼 평문만 허용되는 곳에서 사용) */ +function stripMarks(text) { + return text.replace(SHIELD_RE, '').replace(CODE_PLACEHOLDER_RE, '').replace(/[\ue000\ue001]/g, ''); +} + +/** 인라인 코드(`code`)를 먼저 보호해 내부가 다시 변환되지 않도록 한다 */ +function shieldInlineCode(text, store) { + return text.replace(/(`+)([^`]|[\s\S]*?[^`])\1(?!`)/g, (match, ticks, body) => { + let code = body; + if (code.length > 1 && code.charAt(0) === ' ' && code.charAt(code.length - 1) === ' ') { + code = code.slice(1, -1); + } + // code 는 이미 escape 된 문자열이므로 그대로 사용한다. + return shield(store, '' + code + ''); + }); +} + +/** + * alt 끝에 붙은 '|크기' 지시자를 떼어 낸다. + * ![캡션|half](주소) → { caption: '캡션', size: 'half' } + * 허용값: small · half · wide · full · 두세 자리 숫자(px). 그 밖은 캡션의 일부로 본다. + */ +function parseAltSize(altText) { + const raw = String(altText === null || altText === undefined ? '' : altText); + const bar = raw.lastIndexOf('|'); + if (bar < 0) return { caption: raw.trim(), size: '' }; + + const hint = raw.slice(bar + 1).trim().toLowerCase(); + if (!/^(small|half|wide|full|[1-9]\d{1,3})$/.test(hint)) { + return { caption: raw.trim(), size: '' }; + } + return { caption: raw.slice(0, bar).trim(), size: hint }; +} + +/** 이미지: 항상 figure 로 감싼다. 캡션이 있으면 figcaption 도 붙인다. */ +function buildFigure(escapedUrl, altText, titleText) { + const parsed = parseAltSize(altText); + const caption = stripMarks(parsed.caption); + const title = titleText ? ' title="' + stripMarks(titleText) + '"' : ''; + + let cls = 'md-figure'; + let style = ''; + if (parsed.size === 'small' || parsed.size === 'half' || parsed.size === 'wide') { + cls += ' md-figure--' + parsed.size; + } else if (/^\d+$/.test(parsed.size)) { + // 숫자는 px 폭. 본문보다 넓어지지 않게 CSS 에서 한 번 더 자른다. + cls += ' md-figure--fixed'; + style = ' style="--md-img-w:' + parsed.size + 'px"'; + } + + return ( + '
' + + '' +
+        caption +
+        '' + + (caption ? '
' + caption + '
' : '') + + '
' + ); +} + +const IMAGE_RE = /!\[([^\]]*)\]\([ \t]*([^\s)]*)(?:[ \t]+"([^)]*?)")?[ \t]*\)/g; +const LINK_RE = /\[([^\]]*)\]\([ \t]*([^\s)]*)(?:[ \t]+"([^)]*?)")?[ \t]*\)/g; + +/** ![alt](url) → figure. URL 이 거부되면 alt 텍스트만 남긴다. */ +function renderImages(text, store) { + return text.replace(IMAGE_RE, (match, alt, url, title) => { + if (!isAllowedUrl(url)) return stripMarks(parseAltSize(alt).caption); + return shield(store, buildFigure(url, alt, title)); + }); +} + +/** [text](url) → a. URL 이 거부되면 링크를 버리고 텍스트만 남긴다. */ +function renderLinks(text, store) { + return text.replace(LINK_RE, (match, label, url, title) => { + if (!isAllowedUrl(url)) return label; + + const titleAttr = title ? ' title="' + stripMarks(title) + '"' : ''; + // 외부 링크만 새 탭으로 열고 rel 로 opener 를 끊는다. + const targetAttr = isExternalUrl(url) ? ' target="_blank" rel="noopener noreferrer"' : ''; + const open = ''; + + // 여는/닫는 태그만 보호하고 label 은 흐름에 남겨, + // 링크 글자 안의 **굵게** 같은 강조가 그대로 처리되게 한다. + return shield(store, open) + label + shield(store, ''); + }); +} + +/** 강조/취소선 */ +function renderEmphasis(text) { + let out = text; + out = out.replace(/~~([^\s~](?:[\s\S]*?[^\s~])?)~~/g, '$1'); + out = out.replace(/\*\*([^\s](?:[\s\S]*?[^\s])?)\*\*/g, '$1'); + out = out.replace(/(^|[^\w_])__([^\s_](?:[\s\S]*?[^\s_])?)__(?=[^\w]|$)/g, '$1$2'); + out = out.replace(/\*([^\s*](?:[^*]*[^\s*])?)\*/g, '$1'); + out = out.replace(/(^|[^\w_])_([^\s_](?:[^_]*[^\s_])?)_(?=[^\w]|$)/g, '$1$2'); + return out; +} + +/** + * 인라인 변환 파이프라인. 입력은 반드시 "이미 escape 된" 텍스트여야 한다. + * @param {string} escapedText + * @returns {string} + */ +function renderInline(escapedText) { + const store = []; + let out = shieldInlineCode(escapedText, store); + out = renderImages(out, store); + out = renderLinks(out, store); + out = renderEmphasis(out); + return restoreShields(out, store); +} + +// --------------------------------------------------------------------------- +// 블록 처리 +// --------------------------------------------------------------------------- + +const FIGURE_ONLY_RE = /
]*>[\s\S]*?<\/figure>/g; + +function hasFigure(html) { + return html.indexOf('
' + + '' + + '
' + ); +} + +/** 문단 HTML 조립 (figure 가 섞이면 p 대신 div 로 감싸 유효한 HTML 을 유지한다) */ +function buildParagraph(inlineHtml) { + if (!inlineHtml.trim()) return ''; + if (isFigureOnly(inlineHtml)) return inlineHtml; + if (hasFigure(inlineHtml)) return '
' + inlineHtml + '
'; + return '

' + inlineHtml + '

'; +} + +function leadingSpaces(line) { + const m = /^[ ]*/.exec(line); + return m ? m[0].length : 0; +} + +function matchListItem(line) { + const bullet = BULLET_RE.exec(line); + if (bullet) { + return { indent: bullet[1].length, ordered: false, start: 1, text: bullet[3] }; + } + const ordered = ORDERED_RE.exec(line); + if (ordered) { + return { indent: ordered[1].length, ordered: true, start: parseInt(ordered[2], 10), text: ordered[3] }; + } + return null; +} + +function isListLine(line) { + return matchListItem(line) !== null; +} + +/** 새 블록이 시작되는 줄인지 (문단 수집을 멈출 지점) */ +function isBlockStart(line) { + if (CODE_PLACEHOLDER_LINE_RE.test(line)) return true; + if (HR_RE.test(line)) return true; + if (HEADING_RE.test(line)) return true; + if (QUOTE_RE.test(line)) return true; + if (isListLine(line)) return true; + return false; +} + +/** 여러 줄을 하나의 문단 텍스트로 합친다. 끝에 공백 2개 이상이면 하드 브레이크. */ +function joinParagraphLines(lines) { + let text = ''; + for (let i = 0; i < lines.length; i += 1) { + const isLast = i === lines.length - 1; + const raw = lines[i]; + const trimmedEnd = raw.replace(/[ \t]+$/, ''); + if (isLast) { + text += trimmedEnd; + } else if (/[ \t]{2,}$/.test(raw) || /\\$/.test(trimmedEnd)) { + text += trimmedEnd.replace(/\\$/, '') + '
\n'; + } else { + text += trimmedEnd + '\n'; + } + } + return text; +} + +/** 목록 노드 트리를 HTML 로 */ +function renderListNodes(nodes, ordered, start) { + const tag = ordered ? 'ol' : 'ul'; + const cls = ordered ? 'md-ol' : 'md-ul'; + const startAttr = ordered && typeof start === 'number' && start !== 1 ? ' start="' + start + '"' : ''; + let html = '<' + tag + ' class="' + cls + '"' + startAttr + '>'; + for (const node of nodes) { + html += '
  • ' + renderInline(node.text); + if (node.children && node.children.length > 0) { + html += renderListNodes(node.children, node.childOrdered, node.childStart); + } + html += '
  • '; + } + html += ''; + return html; +} + +// --------------------------------------------------------------------------- +// 블록 파서 본체 +// --------------------------------------------------------------------------- + +function parseBlocks(lines, codeStore, depth) { + const out = []; + const level = typeof depth === 'number' ? depth : 0; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (!line.trim()) { + i += 1; + continue; + } + + // 1) 펜스 코드 블록 자리표시자 + const codeLine = CODE_PLACEHOLDER_LINE_RE.exec(line); + if (codeLine) { + const entry = codeStore[Number(codeLine[1])]; + if (entry !== undefined) out.push(entry); + i += 1; + continue; + } + + // 2) 수평선 + if (HR_RE.test(line)) { + out.push('
    '); + i += 1; + continue; + } + + // 3) ATX 제목 + const heading = HEADING_RE.exec(line); + if (heading) { + const lvl = heading[1].length; + const body = (heading[2] || '').replace(/[ \t]+#+[ \t]*$/, '').trim(); + out.push('' + renderInline(body) + ''); + i += 1; + continue; + } + + // 4) 인용문 (escape 후이므로 '>' 는 '>') + if (QUOTE_RE.test(line)) { + const inner = []; + while (i < lines.length) { + const cur = lines[i]; + if (QUOTE_RE.test(cur)) { + inner.push(cur.replace(QUOTE_RE, '')); + i += 1; + continue; + } + // 게으른 이어쓰기: 빈 줄이 아니고 새 블록 시작도 아니면 인용 안으로 + if (cur.trim() && !isBlockStart(cur) && inner.length > 0) { + inner.push(cur); + i += 1; + continue; + } + break; + } + const innerHtml = level < 4 ? parseBlocks(inner, codeStore, level + 1) : buildParagraph(renderInline(inner.join('\n'))); + out.push('
    ' + innerHtml + '
    '); + continue; + } + + // 5) 목록 (한 단계 중첩까지) + if (isListLine(line)) { + const items = []; + while (i < lines.length) { + const cur = lines[i]; + if (!cur.trim()) { + // 빈 줄 뒤에도 목록이 이어지면 계속 + let j = i + 1; + while (j < lines.length && !lines[j].trim()) j += 1; + if (j < lines.length && (isListLine(lines[j]) || leadingSpaces(lines[j]) >= 2)) { + i = j; + continue; + } + break; + } + const item = matchListItem(cur); + if (item) { + items.push(item); + i += 1; + continue; + } + if (items.length > 0 && leadingSpaces(cur) >= 2) { + const last = items[items.length - 1]; + last.text += '\n' + cur.trim(); + i += 1; + continue; + } + break; + } + + const baseIndent = items[0].indent; + const nodes = []; + for (const item of items) { + if (item.indent >= baseIndent + 2 && nodes.length > 0) { + const parent = nodes[nodes.length - 1]; + if (!parent.children) { + parent.children = []; + parent.childOrdered = item.ordered; + parent.childStart = item.start; + } + parent.children.push({ text: item.text, children: null }); + } else { + nodes.push({ text: item.text, children: null, childOrdered: false, childStart: 1 }); + } + } + out.push(renderListNodes(nodes, items[0].ordered, items[0].start)); + continue; + } + + // 6) 문단 + const buf = [line]; + i += 1; + while (i < lines.length) { + const cur = lines[i]; + if (!cur.trim()) break; + if (isBlockStart(cur)) break; + buf.push(cur); + i += 1; + } + // URL 한 줄짜리 문단이면 유튜브 재생기로 바꾼다. + const embed = buf.length === 1 ? parseYouTube(buf[0]) : null; + if (embed) { + out.push(buildYouTubeEmbed(embed)); + } else { + out.push(buildParagraph(renderInline(joinParagraphLines(buf)))); + } + } + + return out.join('\n'); +} + +// --------------------------------------------------------------------------- +// 공개 API +// --------------------------------------------------------------------------- + +/** + * 마크다운을 HTML 문자열로 변환한다. 반환값은 구조적으로 안전한(escape 완료) HTML 이다. + * @param {string} src + * @returns {string} + */ +export function renderMarkdown(src) { + const normalized = normalizeSource(src); + if (!normalized.trim()) return ''; + + // 1) 펜스 코드 먼저 분리 (내용은 escape 만 하고 더 이상 건드리지 않는다) + const codeStore = []; + const withPlaceholders = extractFencedBlocks(normalized, codeStore); + + // 2) 남은 본문을 통째로 escape → 이후 단계에서 raw HTML 이 생길 수 없다 + const escaped = escapeHtml(withPlaceholders); + + // 3) 블록 파싱 → 인라인 파싱 + const html = parseBlocks(escaped.split('\n'), codeStore, 0); + + // 4) 목록/문단 안쪽에 남은 코드 자리표시자 마무리 + return expandCodePlaceholders(html, codeStore); +} + +export default renderMarkdown; diff --git a/GameServer/wwwroot/js/router.js b/GameServer/wwwroot/js/router.js new file mode 100644 index 0000000..bbcd9b7 --- /dev/null +++ b/GameServer/wwwroot/js/router.js @@ -0,0 +1,311 @@ +// router.js — 해시 기반 클라이언트 라우터 +// 라우트 표는 app.js 가 만들고, 각 뷰는 async (params, outlet) => cleanup 형태다. +// +// 규칙 +// · 라우트는 배열 순서대로 매칭한다. 마지막에 '*' 폴백을 둔다. +// · params 는 쿼리값(#/?cat=game)과 경로 캡처(#/post/12)를 합친 객체다. +// · 이전 뷰의 cleanup 을 반드시 먼저 부르고 나서 다음 뷰를 그린다. +// · 비동기 뷰가 그리는 도중 해시가 또 바뀌면, 늦게 끝난 뷰는 화면에 손대지 않는다. + +import { setBusy } from './ui.js'; + +/** 모듈 단위 라우터 상태 (앱당 라우터 하나) */ +let routeTable = []; +let outletEl = null; +let started = false; + +/** 지금 화면에 떠 있는 라우트 정보 */ +let current = null; +/** 지금 화면에 떠 있는 뷰의 cleanup */ +let currentCleanup = null; +/** 렌더 순번. 늦게 끝난 렌더를 버리는 데 쓴다. */ +let renderToken = 0; + +// --------------------------------------------------------------------------- +// 해시 파싱 +// --------------------------------------------------------------------------- + +/** decodeURIComponent 가 깨진 이스케이프에서 던지지 않도록 감싼다. */ +function safeDecode(text) { + try { + return decodeURIComponent(String(text).replace(/\+/g, ' ')); + } catch { + return String(text); + } +} + +/** 'a=1&b=2' → { a: '1', b: '2' } */ +function parseQuery(text) { + const out = {}; + if (!text) return out; + for (const chunk of String(text).split('&')) { + if (!chunk) continue; + const eq = chunk.indexOf('='); + const rawKey = eq >= 0 ? chunk.slice(0, eq) : chunk; + const rawVal = eq >= 0 ? chunk.slice(eq + 1) : ''; + const key = safeDecode(rawKey); + if (!key) continue; + out[key] = safeDecode(rawVal); + } + return out; +} + +/** 경로 문자열을 세그먼트 배열로. '/' → [], '/post/12' → ['post','12'] */ +function toSegments(path) { + return String(path).split('/').filter((s) => s.length > 0); +} + +/** + * location.hash 를 { path, query } 로 나눈다. + * '#/?cat=game' → { path: '/', query: { cat: 'game' } } + */ +function parseHash(raw) { + let text = typeof raw === 'string' ? raw : ''; + if (text.charAt(0) === '#') text = text.slice(1); + + const q = text.indexOf('?'); + let path = q >= 0 ? text.slice(0, q) : text; + const queryText = q >= 0 ? text.slice(q + 1) : ''; + + if (!path) path = '/'; + if (path.charAt(0) !== '/') path = '/' + path; + // 끝의 '/' 는 무시한다 ('#/about/' 도 '#/about' 로 취급). + if (path.length > 1 && path.charAt(path.length - 1) === '/') path = path.slice(0, -1); + + return { path, query: parseQuery(queryText) }; +} + +// --------------------------------------------------------------------------- +// 패턴 매칭 +// --------------------------------------------------------------------------- + +/** '#/post/:postNo' → { wildcard:false, segments:['post',':postNo'] } */ +function compilePattern(pattern) { + const raw = String(pattern === null || pattern === undefined ? '' : pattern); + if (raw === '*') return { wildcard: true, segments: [] }; + + let text = raw.charAt(0) === '#' ? raw.slice(1) : raw; + const q = text.indexOf('?'); + if (q >= 0) text = text.slice(0, q); + return { wildcard: false, segments: toSegments(text) }; +} + +/** + * 세그먼트 배열끼리 맞춰 본다. + * @returns {Object|null} 캡처값 객체 또는 null + */ +function matchSegments(patternSegments, pathSegments) { + if (patternSegments.length !== pathSegments.length) return null; + const captured = {}; + for (let i = 0; i < patternSegments.length; i += 1) { + const p = patternSegments[i]; + const v = pathSegments[i]; + if (p.charAt(0) === ':') { + const name = p.slice(1); + if (!name) return null; + captured[name] = safeDecode(v); + } else if (p !== v) { + return null; + } + } + return captured; +} + +/** 라우트 표를 순서대로 훑어 첫 일치 항목을 돌려준다. */ +function matchRoute(path) { + const pathSegments = toSegments(path); + for (const entry of routeTable) { + if (entry.compiled.wildcard) return { entry, params: {} }; + const captured = matchSegments(entry.compiled.segments, pathSegments); + if (captured) return { entry, params: captured }; + } + return null; +} + +// --------------------------------------------------------------------------- +// 화면 그리기 +// --------------------------------------------------------------------------- + +/** 이전 뷰 정리. cleanup 이 던져도 라우팅은 계속되어야 한다. */ +function runPreviousCleanup() { + const fn = currentCleanup; + currentCleanup = null; + if (typeof fn !== 'function') return; + try { + fn(); + } catch (err) { + // 정리 중 오류는 다음 화면을 막을 이유가 되지 않는다. + console.error('[router] cleanup 오류', err); + } +} + +/** 아무 라우트도 못 찾았을 때 (라우트 표에 '*' 가 없는 경우에만 보인다) */ +function renderNotFound(outlet, path) { + outlet.innerHTML = + '
    ' + + '' + + '

    페이지를 찾을 수 없습니다.

    ' + + '

    ' + escapeAttr(path) + '

    ' + + '홈으로' + + '
    '; +} + +/** 뷰가 던졌을 때. 화면을 비워 두지 않고 다시 시도할 길을 준다. */ +function renderViewError(outlet, err) { + const message = (err && err.message) || '알 수 없는 오류가 발생했습니다.'; + outlet.innerHTML = + '
    ' + + '' + + '

    화면을 불러오지 못했습니다.

    ' + + '

    ' + escapeAttr(message) + '

    ' + + '홈으로' + + '
    '; + console.error('[router] 뷰 오류', err); +} + +/** 이 모듈 안에서만 쓰는 최소 escape (ui.js 를 순환 참조하지 않기 위해 별도로 둔다) */ +function escapeAttr(value) { + return String(value === null || value === undefined ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * 현재 해시에 맞는 뷰를 그린다. + * @param {boolean} isInitial 최초 렌더 여부 (스크롤을 건드리지 않는다) + */ +async function render(isInitial) { + if (!outletEl) return; + + const token = (renderToken += 1); + const parsed = parseHash(location.hash); + const found = matchRoute(parsed.path); + + // 이전 화면을 먼저 정리하고 비운다. + runPreviousCleanup(); + outletEl.replaceChildren(); + + if (!isInitial) { + try { + window.scrollTo(0, 0); + } catch { + // 스크롤 실패는 무시한다. + } + } + + if (!found) { + current = { pattern: null, path: parsed.path, params: parsed.query }; + renderNotFound(outletEl, parsed.path); + return; + } + + // 쿼리값 위에 경로 캡처를 덮어쓴다. + const params = Object.assign({}, parsed.query, found.params); + const routeInfo = { pattern: found.entry.pattern, path: parsed.path, params }; + current = routeInfo; + + if (found.entry.title) document.title = found.entry.title; + + setBusy(true); + try { + const cleanup = await found.entry.view(params, outletEl); + + // 그리는 사이에 해시가 또 바뀌었으면 이 결과는 버린다. + if (token !== renderToken) { + if (typeof cleanup === 'function') { + try { + cleanup(); + } catch { + // 버려지는 뷰의 정리 오류는 삼킨다. + } + } + return; + } + + currentCleanup = typeof cleanup === 'function' ? cleanup : null; + + window.dispatchEvent(new CustomEvent('route:change', { + detail: { pattern: routeInfo.pattern, path: routeInfo.path, params: routeInfo.params } + })); + } catch (err) { + if (token !== renderToken) return; + renderViewError(outletEl, err); + } finally { + if (token === renderToken) setBusy(false); + } +} + +function onHashChange() { + render(false); +} + +// --------------------------------------------------------------------------- +// 공개 API +// --------------------------------------------------------------------------- + +/** + * 라우터를 만들고 첫 화면을 그린다. + * @param {Array<{pattern:string, view:Function, title?:string}>} routes + * @param {HTMLElement} outlet + * @returns {{destroy: Function, refresh: Function}} + */ +export function createRouter(routes, outlet) { + if (!outlet) throw new Error('createRouter: outlet 엘리먼트가 필요합니다.'); + + routeTable = (Array.isArray(routes) ? routes : []) + .filter((r) => r && typeof r.view === 'function') + .map((r) => ({ pattern: String(r.pattern), view: r.view, title: r.title, compiled: compilePattern(r.pattern) })); + + outletEl = outlet; + + if (!started) { + window.addEventListener('hashchange', onHashChange); + started = true; + } + + // 해시가 비어 있으면 '#/' 로 본다 (주소창은 건드리지 않는다). + render(true); + + return { + destroy() { + window.removeEventListener('hashchange', onHashChange); + started = false; + runPreviousCleanup(); + routeTable = []; + outletEl = null; + current = null; + }, + refresh() { + render(false); + } + }; +} + +/** + * 해시를 바꿔 화면을 이동한다. 이미 같은 해시면 강제로 다시 그린다. + * @param {string} hash '#/admin' 같은 값 + */ +export function navigate(hash) { + let target = String(hash === null || hash === undefined ? '' : hash); + if (!target) target = '#/'; + if (target.charAt(0) !== '#') target = '#' + (target.charAt(0) === '/' ? target : '/' + target); + + if (location.hash === target || (!location.hash && target === '#/')) { + render(false); + return; + } + location.hash = target; +} + +/** + * 지금 화면에 떠 있는 라우트 정보. + * @returns {{pattern:string|null, path:string, params:Object}|null} + */ +export function currentRoute() { + return current; +} + +export default createRouter; diff --git a/GameServer/wwwroot/js/ui.js b/GameServer/wwwroot/js/ui.js new file mode 100644 index 0000000..76ffc32 --- /dev/null +++ b/GameServer/wwwroot/js/ui.js @@ -0,0 +1,325 @@ +// ui.js — 포트폴리오 프론트엔드 공용 UI 헬퍼 모듈 +// 의존성 없음. 모든 사용자/DB 텍스트는 escapeHtml() 또는 textContent 로만 DOM 에 넣는다. + +/** HTML 특수문자 → 엔티티 매핑 테이블 */ +const ESCAPE_MAP = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +}; + +/** + * 문자열을 HTML 안전 문자열로 변환한다. null/undefined 는 빈 문자열. + * @param {*} str + * @returns {string} + */ +export function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str).replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]); +} + +/** 자식 노드 하나를 부모에 붙인다. null/undefined/불리언 은 건너뛴다. */ +function appendChild(parent, child) { + if (child === null || child === undefined || child === false || child === true) return; + if (Array.isArray(child)) { + for (const item of child) appendChild(parent, item); + return; + } + if (child instanceof Node) { + parent.appendChild(child); + return; + } + parent.appendChild(document.createTextNode(String(child))); +} + +/** attrs 의 특수 키(class/text/html/dataset/style/on*)를 처리한다. */ +function applyAttribute(node, key, value) { + if (value === null || value === undefined || value === false) return; + + if (key === 'class' || key === 'className') { + node.setAttribute('class', Array.isArray(value) ? value.filter(Boolean).join(' ') : String(value)); + return; + } + if (key === 'text') { + node.textContent = String(value); + return; + } + if (key === 'html') { + // 호출자가 신뢰 가능한(이미 escape 된) HTML 만 넘겨야 한다. + node.innerHTML = String(value); + return; + } + if (key === 'dataset') { + for (const dk of Object.keys(value)) { + const dv = value[dk]; + if (dv === null || dv === undefined) continue; + node.dataset[dk] = String(dv); + } + return; + } + if (key === 'style') { + if (typeof value === 'object') Object.assign(node.style, value); + else node.setAttribute('style', String(value)); + return; + } + if (key.length > 2 && key.slice(0, 2) === 'on' && typeof value === 'function') { + node.addEventListener(key.slice(2).toLowerCase(), value); + return; + } + if (value === true) { + node.setAttribute(key, ''); + return; + } + node.setAttribute(key, String(value)); +} + +/** + * 엘리먼트 생성 헬퍼. + * @param {string} tag 태그 이름 + * @param {Object} [attrs] class / text / html / dataset / style / on* / 그 밖의 속성 + * @param {...any} children 문자열·노드·배열 (null·undefined 는 무시) + * @returns {HTMLElement} + */ +export function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + if (attrs && typeof attrs === 'object') { + for (const key of Object.keys(attrs)) applyAttribute(node, key, attrs[key]); + } + for (const child of children) appendChild(node, child); + return node; +} + +const TOAST_LIFETIME_MS = 2800; +const TOAST_FADE_MS = 220; + +/** #toast-root 를 찾고, 없으면 body 에 하나 만든다. */ +function toastRoot() { + let root = document.getElementById('toast-root'); + if (!root) { + root = el('div', { id: 'toast-root', class: 'toast-root', 'aria-live': 'polite' }); + document.body.appendChild(root); + } + return root; +} + +/** + * 토스트 알림. 약 2.8초 뒤 자동으로 사라지며 여러 개가 위로 쌓인다. + * @param {string} message + * @param {'info'|'ok'|'error'} [kind] + * @returns {HTMLElement} + */ +export function toast(message, kind = 'info') { + const cls = kind === 'ok' ? 'toast toast--ok' : kind === 'error' ? 'toast toast--error' : 'toast'; + const text = message === null || message === undefined ? '' : String(message); + const node = el('div', { class: cls, role: 'status', text }); + const root = toastRoot(); + root.appendChild(node); + + let removed = false; + const remove = () => { + if (removed) return; + removed = true; + node.style.opacity = '0'; + window.setTimeout(() => node.remove(), TOAST_FADE_MS); + }; + window.setTimeout(remove, TOAST_LIFETIME_MS); + node.addEventListener('click', remove); + return node; +} + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])' +].join(','); + +/** 컨테이너 안에서 실제로 포커스 가능한 요소 목록 */ +function focusableIn(container) { + const all = Array.prototype.slice.call(container.querySelectorAll(FOCUSABLE_SELECTOR)); + return all.filter((node) => node.offsetParent !== null || node === document.activeElement); +} + +/** #modal-root 를 찾고, 없으면 body 에 하나 만든다. */ +function modalRoot() { + let root = document.getElementById('modal-root'); + if (!root) { + root = el('div', { id: 'modal-root' }); + document.body.appendChild(root); + } + return root; +} + +/** + * 확인/취소 모달. Esc 또는 배경 클릭 시 false 로 resolve 된다. + * 모달이 열려 있는 동안 Tab 포커스는 모달 안에 갇힌다. + * @param {string} message + * @param {{okText?:string, cancelText?:string, title?:string, danger?:boolean}} [opts] + * @returns {Promise} + */ +export function confirmDialog(message, opts = {}) { + const okText = opts.okText || '확인'; + const cancelText = opts.cancelText || '취소'; + const danger = opts.danger === true; + const msgText = message === null || message === undefined ? '' : String(message); + + return new Promise((resolve) => { + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + + const cancelBtn = el('button', { type: 'button', class: 'btn btn--ghost', text: cancelText }); + const okBtn = el('button', { + type: 'button', + class: danger ? 'btn btn--danger' : 'btn btn--primary', + text: okText + }); + + const card = el( + 'div', + { class: 'modal__card glass', role: 'dialog', 'aria-modal': 'true' }, + opts.title ? el('p', { class: 'modal__msg', text: String(opts.title) }) : null, + el('p', { class: 'modal__msg', text: msgText }), + el('div', { class: 'modal__actions' }, cancelBtn, okBtn) + ); + const overlay = el('div', { class: 'modal' }, card); + + let settled = false; + const close = (result) => { + if (settled) return; + settled = true; + document.removeEventListener('keydown', onKeydown, true); + overlay.remove(); + if (previousFocus && document.contains(previousFocus)) previousFocus.focus(); + resolve(result); + }; + + function onKeydown(ev) { + if (ev.key === 'Escape') { + ev.preventDefault(); + close(false); + return; + } + if (ev.key !== 'Tab') return; + + // 포커스 트랩: 모달 밖으로 Tab 이 빠져나가지 못하게 한다. + const items = focusableIn(card); + if (items.length === 0) { + ev.preventDefault(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + const active = document.activeElement; + if (!card.contains(active)) { + ev.preventDefault(); + first.focus(); + return; + } + if (ev.shiftKey && active === first) { + ev.preventDefault(); + last.focus(); + } else if (!ev.shiftKey && active === last) { + ev.preventDefault(); + first.focus(); + } + } + + overlay.addEventListener('mousedown', (ev) => { + if (ev.target === overlay) close(false); + }); + cancelBtn.addEventListener('click', () => close(false)); + okBtn.addEventListener('click', () => close(true)); + document.addEventListener('keydown', onKeydown, true); + + modalRoot().appendChild(overlay); + okBtn.focus(); + }); +} + +/** 서버 DateTime 은 UTC 기준이지만 오프셋이 없을 수 있어 Z 를 보정해 준다. */ +function toLocalDate(value) { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + if (value === null || value === undefined) return null; + let text = String(value).trim(); + if (!text) return null; + const hasZone = /(?:Z|z|[+-]\d{2}:?\d{2})$/.test(text); + if (!hasZone && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(text)) { + text = text.replace(' ', 'T') + 'Z'; + } + const date = new Date(text); + return Number.isNaN(date.getTime()) ? null : date; +} + +function pad2(n) { + return String(n).padStart(2, '0'); +} + +/** + * UTC 시각을 로컬 기준 'YYYY. MM. DD.' 로 표시한다. + * @param {string|Date} iso + * @returns {string} + */ +export function formatDate(iso) { + const date = toLocalDate(iso); + if (!date) return ''; + return date.getFullYear() + '. ' + pad2(date.getMonth() + 1) + '. ' + pad2(date.getDate()) + '.'; +} + +/** + * 디바운스. 반환된 함수에는 cancel() 이 붙어 있다. + * @param {Function} fn + * @param {number} ms + * @returns {Function} + */ +export function debounce(fn, ms) { + const delay = typeof ms === 'number' && ms >= 0 ? ms : 0; + let timer = null; + function wrapped(...args) { + if (timer !== null) window.clearTimeout(timer); + const self = this; + timer = window.setTimeout(() => { + timer = null; + fn.apply(self, args); + }, delay); + } + wrapped.cancel = () => { + if (timer !== null) window.clearTimeout(timer); + timer = null; + }; + return wrapped; +} + +/** + * 상단 진행 표시줄(#app-progress) 토글. + * @param {boolean} isBusy + */ +export function setBusy(isBusy) { + const bar = document.getElementById('app-progress'); + if (!bar) return; + bar.classList.toggle('is-busy', !!isBusy); +} + +/** + * 로딩 중 표시할 인덱스 행 스켈레톤 HTML 문자열. + * 구조는 views/home.js 의 실제 행(.idx)과 같게 맞춘다. + * @param {number} n + * @returns {string} + */ +export function skeletonRows(n) { + const count = Math.max(0, Math.floor(Number(n) || 0)); + let html = ''; + for (let i = 0; i < count; i += 1) { + html += + ''; + } + return html; +} diff --git a/GameServer/wwwroot/js/views/about.js b/GameServer/wwwroot/js/views/about.js new file mode 100644 index 0000000..0d8949c --- /dev/null +++ b/GameServer/wwwroot/js/views/about.js @@ -0,0 +1,73 @@ +// views/about.js — 소개 화면 +// 라우트: #/about +// 본문은 아래 ABOUT_MD 상수 하나만 고치면 된다. + +import { renderMarkdown } from '../markdown.js'; + +/* 여기 내용을 자유롭게 수정하세요 */ +const ABOUT_MD = ` +## 안녕하세요, 게임 서버를 만드는 개발자입니다. + +Unity 클라이언트가 기대는 **서버**를 설계하고 만듭니다. +게임이 재미있으려면 그 아래가 조용히 잘 돌아가야 한다고 생각합니다. +지금은 개인 프로젝트 **Genesis**의 서버를 혼자 만들면서, +캐릭터 데이터 구조부터 API 설계, 배포까지 전부 직접 다루고 있습니다. + +--- + +### 지금 하고 있는 것 + +- **Genesis 게임 서버** — Unity 게임용 REST API 서버. + 캐릭터 생성과 성장, 유저별 보유 캐릭터 관리를 담당합니다. +- **이 포트폴리오 사이트** — 같은 서버 위에서 함께 돌아갑니다. + 게임 API와 한 프로세스를 쓰면서 정적 SPA로 화면을 그립니다. + +### 쓰는 기술 + +- **서버** — C# / .NET 9, ASP.NET Core, Entity Framework Core +- **데이터베이스** — MariaDB (Pomelo EF Core 프로바이더) +- **클라이언트** — Unity, C# +- **프론트엔드** — 프레임워크 없는 순수 ES 모듈, CSS 디자인 시스템 + +### 일하는 방식 + +> 돌아가게 만든 다음, 왜 돌아가는지 설명할 수 있을 때까지 다듬습니다. + +빌드 한 번으로 끝나는 배포를 좋아하고, +남이 읽을 코드라고 생각하고 씁니다. +문제가 생겼을 때 로그만 보고 원인을 짚을 수 있는 구조를 지향합니다. + +--- + +### 연락 + +작업 문의나 협업 제안은 언제든 환영합니다. +왼쪽 위 **작업** 탭에서 만든 것들을 먼저 봐 주세요. +`; + +/** + * 소개 뷰. + * @param {Object} params 사용하지 않음 + * @param {HTMLElement} outlet + * @returns {Function} cleanup + */ +export async function view(params, outlet) { + outlet.innerHTML = + '
    ' + + '
    ' + + '

    소개

    ' + + '

    무엇을 만들고, 어떻게 일하는지.

    ' + + '
    ' + + '
    ' + + '
    ' + renderMarkdown(ABOUT_MD) + '
    ' + + '
    ' + + '
    '; + + document.title = '김낙준 · 소개'; + + return () => { + // 붙인 리스너가 없어 따로 정리할 것이 없다. + }; +} + +export default view; diff --git a/GameServer/wwwroot/js/views/admin.js b/GameServer/wwwroot/js/views/admin.js new file mode 100644 index 0000000..078196d --- /dev/null +++ b/GameServer/wwwroot/js/views/admin.js @@ -0,0 +1,1002 @@ +// views/admin.js — 관리자 화면 +// 라우트: #/admin · #/admin/new · #/admin/edit/:postNo · #/admin/categories +// +// 공개 화면과 달리 여기서만 글을 쓰고 고친다. +// 서버는 쿠키 세션으로 권한을 확인하므로, 401 이 오면 곧바로 로그인 화면으로 되돌린다. + +import { api, ApiError } from '../api.js'; +import { confirmDialog, debounce, el, escapeHtml, formatDate, setBusy, toast } from '../ui.js'; +import { renderMarkdown } from '../markdown.js'; +import { navigate } from '../router.js'; + +/** 대시보드에서 한 번에 긁어오는 게시물 수 */ +const DASH_PAGE_SIZE = 50; +/** 미리보기 갱신 지연 */ +const PREVIEW_DEBOUNCE_MS = 150; + +// --------------------------------------------------------------------------- +// 공용 헬퍼 +// --------------------------------------------------------------------------- + +/** 정수 안전 변환 */ +function toInt(value) { + const n = Number(value); + return Number.isFinite(n) ? Math.trunc(n) : 0; +} + +/** '12' 처럼 양의 정수인지 확인한다. */ +function parsePostNo(value) { + const text = String(value === null || value === undefined ? '' : value).trim(); + if (!/^\d{1,9}$/.test(text)) return 0; + const n = Number(text); + return n > 0 ? n : 0; +} + +/** accent 헥사 색상만 통과 */ +function safeAccent(value) { + const text = typeof value === 'string' ? value.trim() : ''; + return /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(text) ? text : ''; +} + +/** http(s) 또는 사이트 상대경로만 허용 */ +function safeUrl(value) { + const text = typeof value === 'string' ? value.trim() : ''; + if (!text) return ''; + if (text.slice(0, 2) === '//') return ''; + if (text.charAt(0) === '/') return text; + return /^https?:\/\//i.test(text) ? text : ''; +} + +/** + * 관리자 화면 공통 오류 처리. + * 401 이면 로그인 화면으로 돌려보내고 true 를 반환한다. + * @returns {boolean} 호출부가 더 진행하면 안 되는 경우 true + */ +function handleAuthError(err) { + if (err instanceof ApiError && err.status === 401) { + toast('로그인이 필요합니다.', 'error'); + navigate('#/admin'); + return true; + } + return false; +} + +/** 오류를 토스트로 알린다 (401 은 위에서 처리). */ +function reportError(err, fallback) { + if (handleAuthError(err)) return; + toast((err && err.message) || fallback || '요청을 처리하지 못했습니다.', 'error'); +} + +/** 관리자 화면 상단 바 */ +function adminBar(titleText, buttons) { + const bar = el('div', { class: 'admin__bar' }, + el('h2', { class: 'admin__title', text: titleText }) + ); + const side = el('div', { class: 'row-between' }); + for (const btn of buttons) if (btn) side.appendChild(btn); + bar.appendChild(side); + return bar; +} + +// --------------------------------------------------------------------------- +// #/admin — 로그인 또는 대시보드 +// --------------------------------------------------------------------------- + +/** 로그인 카드를 그린다. */ +function renderLogin(outlet, options) { + const notConfigured = options && options.notConfigured === true; + + const input = el('input', { + class: 'input', + type: 'password', + id: 'admin-pw', + autocomplete: 'current-password', + placeholder: '관리자 비밀번호' + }); + const hint = el('p', { class: 'field__hint' }); + const submit = el('button', { class: 'btn btn--primary', type: 'submit', text: '로그인' }); + + function showHint(message, isError) { + hint.textContent = message || ''; + hint.classList.toggle('is-error', isError === true); + } + + if (notConfigured) { + showHint('관리자 비밀번호가 아직 설정되지 않았습니다. GameServer/appsettings.json 의 ' + + 'Portfolio:AdminPassword 값을 CHANGE_ME 에서 바꾼 뒤 서버를 다시 시작해 주세요.', true); + } + + const form = el('form', { class: 'stack', novalidate: true }, + el('div', { class: 'field' }, + el('label', { class: 'field__label', for: 'admin-pw', text: '비밀번호' }), + input + ), + hint, + submit + ); + + form.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const password = input.value; + if (!password) { + showHint('비밀번호를 입력해 주세요.', true); + input.focus(); + return; + } + submit.disabled = true; + submit.textContent = '확인 중…'; + setBusy(true); + try { + await api.auth.login(password); + toast('로그인했습니다.', 'ok'); + navigate('#/admin'); + } catch (err) { + const status = err && err.status; + if (status === 503) { + showHint('관리자 비밀번호가 설정되지 않았습니다. GameServer/appsettings.json 의 ' + + 'Portfolio:AdminPassword 값을 바꾼 뒤 서버를 다시 시작해 주세요.', true); + } else if (status === 429) { + showHint('로그인 시도가 너무 많습니다. 몇 분 뒤에 다시 시도해 주세요.', true); + } else { + showHint((err && err.message) || '로그인하지 못했습니다.', true); + } + input.select(); + } finally { + submit.disabled = false; + submit.textContent = '로그인'; + setBusy(false); + } + }); + + const card = el('div', { class: 'glass admin' }, + el('h2', { class: 'admin__title', text: '관리자 로그인' }), + el('p', { class: 'tagline', text: '글을 쓰거나 고치려면 로그인이 필요합니다.' }), + form + ); + + outlet.appendChild(el('section', { class: 'section' }, card)); + input.focus(); +} + +/** 대시보드에 쓸 게시물 전체를 페이지 단위로 모은다. */ +async function loadAllPosts() { + const all = []; + let page = 1; + let total = 0; + for (;;) { + const res = await api.posts.list({ category: 'all', page, size: DASH_PAGE_SIZE }); + const items = res && Array.isArray(res.items) ? res.items : []; + total = toInt(res && res.total); + all.push(...items); + if (!items.length || all.length >= total || page > 200) break; + page += 1; + } + return all; +} + +/** 대시보드의 게시물 한 줄 */ +function postRow(post, onDeleted) { + const postNo = toInt(post && post.postNo); + const title = String((post && post.title) || '(제목 없음)'); + const catName = String((post && post.categoryName) || '미분류'); + const accent = safeAccent(post && post.categoryAccent); + + const del = el('button', { class: 'btn btn--danger btn--sm', type: 'button', text: '삭제' }); + del.addEventListener('click', async () => { + const ok = await confirmDialog('"' + title + '" 게시물을 삭제할까요?', { + okText: '삭제', danger: true + }); + if (!ok) return; + del.disabled = true; + setBusy(true); + try { + await api.posts.remove(postNo); + toast('삭제했습니다.', 'ok'); + onDeleted(postNo); + } catch (err) { + del.disabled = false; + reportError(err, '삭제하지 못했습니다.'); + } finally { + setBusy(false); + } + }); + + return el('div', { class: 'row' }, + el('div', { class: 'row__main' }, + el('span', { + class: 'card__cat', + html: '' + escapeHtml(catName) + }), + el('strong', { class: 'card__title', text: title }), + el('span', { class: 'card__meta', text: formatDate(post && post.createdAt) }) + ), + el('div', { class: 'row__side' }, + el('a', { class: 'btn btn--ghost btn--sm', href: '#/post/' + postNo, text: '보기' }), + el('a', { class: 'btn btn--ghost btn--sm', href: '#/admin/edit/' + postNo, text: '수정' }), + del + ) + ); +} + +/** 로그인된 관리자 대시보드 */ +async function renderDashboard(outlet) { + const logout = el('button', { class: 'btn btn--ghost btn--sm', type: 'button', text: '로그아웃' }); + logout.addEventListener('click', async () => { + logout.disabled = true; + try { + await api.auth.logout(); + toast('로그아웃했습니다.', 'ok'); + } catch { + // 실패해도 화면은 로그인으로 되돌린다. + } finally { + navigate('#/admin'); + } + }); + + const section = el('section', { class: 'section' }, + adminBar('관리자', [ + el('a', { class: 'btn btn--primary btn--sm', href: '#/admin/new', text: '새 글' }), + el('a', { class: 'btn btn--ghost btn--sm', href: '#/admin/categories', text: '카테고리 관리' }), + logout + ]) + ); + const rows = el('div', { class: 'rows' }, + el('p', { class: 'tagline', text: '게시물을 불러오는 중…' }) + ); + section.appendChild(rows); + outlet.appendChild(section); + + setBusy(true); + try { + const posts = await loadAllPosts(); + rows.replaceChildren(); + if (!posts.length) { + rows.appendChild(el('div', { class: 'empty' }, + el('div', { class: 'empty__icon', 'aria-hidden': 'true', text: '✦' }), + el('p', { text: '아직 게시물이 없습니다.' }), + el('a', { class: 'btn btn--primary', href: '#/admin/new', text: '첫 글 쓰기' }) + )); + return; + } + const onDeleted = (postNo) => { + const remaining = posts.filter((p) => toInt(p.postNo) !== postNo); + posts.length = 0; + posts.push(...remaining); + rows.replaceChildren(); + if (!posts.length) { + rows.appendChild(el('p', { class: 'tagline', text: '게시물이 모두 삭제되었습니다.' })); + return; + } + for (const p of posts) rows.appendChild(postRow(p, onDeleted)); + }; + for (const p of posts) rows.appendChild(postRow(p, onDeleted)); + } catch (err) { + rows.replaceChildren(); + if (handleAuthError(err)) return; + rows.appendChild(el('p', { class: 'tagline', text: (err && err.message) || '목록을 불러오지 못했습니다.' })); + } finally { + setBusy(false); + } +} + +/** + * #/admin — 로그인 상태에 따라 로그인 카드 또는 대시보드. + * @param {Object} params + * @param {HTMLElement} outlet + * @returns {Function} cleanup + */ +export async function view(params, outlet) { + document.title = '김낙준 · 관리자'; + let disposed = false; + + setBusy(true); + let me = null; + try { + me = await api.auth.me(); + } catch { + // me() 는 401 을 던지지 않지만, 서버가 죽어 있으면 여기로 온다. + me = null; + } finally { + setBusy(false); + } + if (disposed) return () => { disposed = true; }; + + // 비밀번호 미설정(503) 안내는 로그인 시도 시점에 보여준다. + // me() 응답의 Message 는 api.js 가 Data 만 벗겨 주므로 여기서는 볼 수 없다. + if (!(me && me.authenticated)) renderLogin(outlet, null); + else await renderDashboard(outlet); + + return () => { + disposed = true; + setBusy(false); + }; +} + +// --------------------------------------------------------------------------- +// #/admin/new, #/admin/edit/:postNo — 에디터 +// --------------------------------------------------------------------------- + +/** 텍스트영역 커서 위치에 문자열을 끼워 넣는다 (선택/스크롤 보존). */ +function insertAtCaret(textarea, text) { + const start = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : textarea.value.length; + const end = typeof textarea.selectionEnd === 'number' ? textarea.selectionEnd : start; + const scroll = textarea.scrollTop; + const value = textarea.value; + textarea.value = value.slice(0, start) + text + value.slice(end); + const caret = start + text.length; + textarea.selectionStart = caret; + textarea.selectionEnd = caret; + textarea.scrollTop = scroll; + textarea.focus(); +} + +/** + * 에디터 뷰. + * @param {Object} params postNo (수정일 때만) + * @param {HTMLElement} outlet + * @returns {Function} cleanup + */ +export async function editorView(params, outlet) { + const postNo = parsePostNo(params && params.postNo); + const isEdit = postNo > 0; + document.title = isEdit ? '김낙준 · 글 수정' : '김낙준 · 새 글'; + + let disposed = false; + let dirty = false; + /** 이번 편집 세션에서 올린 이미지 URL (삭제 시 서버 파일도 지운다) */ + const sessionUploads = new Set(); + /** 화면에 붙어 있는 이미지 목록 */ + let images = []; + let thumbnailUrl = ''; + + // ── 로그인 확인 ─────────────────────────────────────────────── + setBusy(true); + try { + const me = await api.auth.me(); + if (!me || !me.authenticated) { + setBusy(false); + toast('로그인이 필요합니다.', 'error'); + navigate('#/admin'); + return () => { disposed = true; }; + } + } catch { + setBusy(false); + navigate('#/admin'); + return () => { disposed = true; }; + } + + // ── 데이터 로드 ─────────────────────────────────────────────── + let categories = []; + let post = null; + try { + categories = await api.categories.list(); + if (isEdit) post = await api.posts.get(postNo); + } catch (err) { + setBusy(false); + if (disposed) return () => { disposed = true; }; + if (handleAuthError(err)) return () => { disposed = true; }; + outlet.innerHTML = + '
    ' + + '

    ' + escapeHtml((err && err.message) || '불러오지 못했습니다.') + '

    ' + + '관리자로
    '; + return () => { disposed = true; }; + } finally { + setBusy(false); + } + if (disposed) return () => { disposed = true; }; + + if (!categories.length) { + outlet.innerHTML = + '
    ' + + '

    카테고리가 하나도 없습니다.

    ' + + '

    글을 쓰려면 카테고리를 먼저 만들어야 합니다.

    ' + + '카테고리 만들기
    '; + return () => { disposed = true; }; + } + + // 기존 이미지 목록 채우기 + if (post && Array.isArray(post.images)) { + images = post.images + .map((img) => ({ + imageUrl: safeUrl(img && img.imageUrl), + caption: img && img.caption ? String(img.caption) : '', + sortOrder: toInt(img && img.sortOrder) + })) + .filter((img) => img.imageUrl); + } + thumbnailUrl = safeUrl(post && post.thumbnailUrl); + + // ── 폼 구성 ─────────────────────────────────────────────────── + const selCategory = el('select', { class: 'select', id: 'ed-cat' }); + for (const cat of categories) { + selCategory.appendChild(el('option', { + value: String(toInt(cat.categoryNo)), + text: String(cat.name || cat.slug || '') + })); + } + selCategory.value = String(toInt(post ? post.categoryNo : categories[0].categoryNo)); + + const inTitle = el('input', { + class: 'input', id: 'ed-title', type: 'text', maxlength: '200', + placeholder: '제목', value: post ? String(post.title || '') : '' + }); + const inSummary = el('input', { + class: 'input', id: 'ed-sum', type: 'text', maxlength: '500', + placeholder: '목록에 보일 한 줄 요약', value: post && post.summary ? String(post.summary) : '' + }); + const taBody = el('textarea', { + class: 'textarea', id: 'ed-body', spellcheck: 'false', + placeholder: '마크다운으로 작성하세요.' + }); + taBody.value = post && post.contentMd ? String(post.contentMd) : ''; + + const preview = el('div', { class: 'editor__preview post__body' }); + const thumbs = el('div', { class: 'thumbs' }); + const dropzone = el('div', { + class: 'dropzone', tabindex: '0', role: 'button', + 'aria-label': '이미지 추가' + }, + el('p', { text: '이미지를 끌어다 놓거나 클릭해서 고르세요' }), + el('p', { class: 'field__hint', text: 'Ctrl+V 로 붙여넣기도 됩니다 · png jpg gif webp avif' }) + ); + const fileInput = el('input', { + type: 'file', accept: 'image/png,image/jpeg,image/gif,image/webp,image/avif', + multiple: true, class: 'sr-only' + }); + + const btnSave = el('button', { class: 'btn btn--primary', type: 'button', text: isEdit ? '수정 저장' : '저장' }); + const btnCancel = el('a', { class: 'btn btn--ghost btn--sm', href: '#/admin', text: '취소' }); + + // ── 미리보기 ────────────────────────────────────────────────── + const updatePreview = () => { + preview.innerHTML = taBody.value.trim() + ? renderMarkdown(taBody.value) + : '

    여기에 미리보기가 나타납니다.

    '; + }; + const debouncedPreview = debounce(updatePreview, PREVIEW_DEBOUNCE_MS); + + // ── 더러움 표시 ─────────────────────────────────────────────── + function markDirty() { + if (dirty) return; + dirty = true; + window.addEventListener('beforeunload', onBeforeUnload); + } + function clearDirty() { + dirty = false; + window.removeEventListener('beforeunload', onBeforeUnload); + } + function onBeforeUnload(ev) { + ev.preventDefault(); + ev.returnValue = ''; + return ''; + } + + // ── 썸네일 목록 ─────────────────────────────────────────────── + function renderThumbs() { + thumbs.replaceChildren(); + if (!images.length) { + thumbs.appendChild(el('p', { class: 'field__hint', text: '아직 이미지가 없습니다.' })); + return; + } + images.forEach((img, index) => { + const isCover = thumbnailUrl && img.imageUrl === thumbnailUrl; + + const btnCover = el('button', { + class: 'btn btn--ghost btn--sm', type: 'button', + text: isCover ? '대표 ✓' : '대표' + }); + btnCover.addEventListener('click', () => { + thumbnailUrl = isCover ? '' : img.imageUrl; + markDirty(); + renderThumbs(); + }); + + const btnInsert = el('button', { + class: 'btn btn--ghost btn--sm', type: 'button', text: '본문삽입' + }); + btnInsert.addEventListener('click', () => { + insertAtCaret(taBody, '\n![](' + img.imageUrl + ')\n'); + markDirty(); + updatePreview(); + }); + + const btnDel = el('button', { + class: 'btn btn--danger btn--sm', type: 'button', text: '삭제' + }); + btnDel.addEventListener('click', async () => { + const url = img.imageUrl; + images = images.filter((_, i) => i !== index); + if (thumbnailUrl === url) thumbnailUrl = ''; + markDirty(); + renderThumbs(); + // 이번 세션에 올린 파일만 서버에서도 지운다. + if (sessionUploads.has(url)) { + sessionUploads.delete(url); + try { + await api.uploads.remove(url); + } catch { + // 파일 정리 실패는 편집을 막지 않는다. + } + } + }); + + thumbs.appendChild(el('figure', { class: isCover ? 'thumb is-cover' : 'thumb' }, + el('img', { + class: 'thumb__img', src: img.imageUrl, alt: img.caption || '이미지 ' + (index + 1), + loading: 'lazy', decoding: 'async' + }), + el('div', { class: 'thumb__actions' }, btnCover, btnInsert, btnDel) + )); + }); + } + + // ── 업로드 ──────────────────────────────────────────────────── + let uploading = false; + // 서버가 알려 주는 한도. 한 번만 받아서 재사용한다. + let uploadLimit = null; + + async function getUploadLimit() { + if (uploadLimit) return uploadLimit; + try { + uploadLimit = await api.uploads.limit(); + } catch { + uploadLimit = null; + } + return uploadLimit; + } + + /** + * 보내기 전에 걸러 낸다. + * 한도를 넘긴 본문은 서버가 연결을 끊어 버려서 브라우저에서는 원인 모를 + * 네트워크 오류로만 보인다. 100MB 를 다 올린 뒤에 그러면 시간 낭비가 크다. + * @returns {string} 문제가 있으면 사용자에게 보여 줄 메시지, 없으면 '' + */ + function checkBeforeUpload(file, limit) { + if (!limit) return ''; + if (typeof limit.maxBytes === 'number' && file.size > limit.maxBytes) { + const mb = (file.size / (1024 * 1024)).toFixed(1); + const max = Math.floor(limit.maxMegabytes || (limit.maxBytes / (1024 * 1024))); + return file.name + ' 은 ' + mb + 'MB 라 올릴 수 없습니다. (최대 ' + max + 'MB)'; + } + const exts = Array.isArray(limit.allowedExtensions) ? limit.allowedExtensions : []; + if (exts.length) { + const dot = file.name.lastIndexOf('.'); + const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''; + if (!exts.includes(ext)) { + return file.name + ' 은 올릴 수 없는 형식입니다. (' + exts.join(' ') + ')'; + } + } + return ''; + } + + async function uploadFiles(fileList) { + const files = Array.from(fileList || []).filter((f) => f && f.size > 0); + if (!files.length || uploading) return; + + uploading = true; + dropzone.classList.remove('is-over'); + setBusy(true); + let okCount = 0; + + const limit = await getUploadLimit(); + + // 순차 업로드: 하나가 실패해도 나머지는 계속한다. + for (let i = 0; i < files.length; i += 1) { + const file = files[i]; + dropzone.setAttribute('data-progress', (i + 1) + ' / ' + files.length); + + // 보내기 전에 한도를 확인한다. + const problem = checkBeforeUpload(file, limit); + if (problem) { + toast(problem, 'error'); + continue; + } + + try { + const res = await api.uploads.upload(file); + if (disposed) break; + const url = safeUrl(res && res.url); + if (!url) throw new ApiError(0, '서버가 올바른 주소를 돌려주지 않았습니다.'); + sessionUploads.add(url); + images.push({ imageUrl: url, caption: '', sortOrder: images.length }); + if (!thumbnailUrl) thumbnailUrl = url; + okCount += 1; + markDirty(); + renderThumbs(); + } catch (err) { + if (handleAuthError(err)) { + uploading = false; + setBusy(false); + return; + } + toast(file.name + ': ' + ((err && err.message) || '업로드 실패'), 'error'); + } + } + + dropzone.removeAttribute('data-progress'); + uploading = false; + setBusy(false); + if (okCount > 0) toast(okCount + '장 올렸습니다.', 'ok'); + } + + function onDragOver(ev) { + ev.preventDefault(); + dropzone.classList.add('is-over'); + } + function onDragLeave(ev) { + ev.preventDefault(); + dropzone.classList.remove('is-over'); + } + function onDrop(ev) { + ev.preventDefault(); + dropzone.classList.remove('is-over'); + if (ev.dataTransfer && ev.dataTransfer.files) uploadFiles(ev.dataTransfer.files); + } + function onDropzoneClick() { + fileInput.click(); + } + function onDropzoneKey(ev) { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + fileInput.click(); + } + } + function onFileChange() { + uploadFiles(fileInput.files); + fileInput.value = ''; + } + function onPaste(ev) { + const items = ev.clipboardData && ev.clipboardData.items; + if (!items) return; + const files = []; + for (const item of items) { + if (item.kind === 'file') { + const f = item.getAsFile(); + if (f && /^image\//i.test(f.type)) files.push(f); + } + } + if (files.length) { + ev.preventDefault(); + uploadFiles(files); + } + } + + // ── 저장 ────────────────────────────────────────────────────── + async function save() { + const title = inTitle.value.trim(); + if (!title) { + toast('제목을 입력해 주세요.', 'error'); + inTitle.focus(); + return; + } + const categoryNo = toInt(selCategory.value); + if (!categoryNo) { + toast('카테고리를 선택해 주세요.', 'error'); + selCategory.focus(); + return; + } + + const dto = { + categoryNo, + title, + summary: inSummary.value.trim() || null, + contentMd: taBody.value || null, + thumbnailUrl: thumbnailUrl || null, + images: images.map((img, i) => ({ + imageUrl: img.imageUrl, + caption: img.caption || null, + sortOrder: i + })) + }; + + btnSave.disabled = true; + btnSave.textContent = '저장 중…'; + setBusy(true); + try { + if (isEdit) await api.posts.update(postNo, dto); + else await api.posts.create(dto); + + // 저장에 성공했으니 이동 전에 이탈 경고를 먼저 푼다. + clearDirty(); + toast(isEdit ? '수정했습니다.' : '저장했습니다.', 'ok'); + navigate('#/admin'); + } catch (err) { + // 입력값은 그대로 두고 버튼만 되살린다. + btnSave.disabled = false; + btnSave.textContent = isEdit ? '수정 저장' : '저장'; + reportError(err, '저장하지 못했습니다.'); + } finally { + setBusy(false); + } + } + + // ── 조립 ────────────────────────────────────────────────────── + const section = el('section', { class: 'section' }, + adminBar(isEdit ? '글 수정' : '새 글', [btnCancel, btnSave]) + ); + + const form = el('div', { class: 'editor glass' }, + el('div', { class: 'form-grid' }, + el('div', { class: 'field' }, + el('label', { class: 'field__label', for: 'ed-cat', text: '카테고리' }), + selCategory + ), + el('div', { class: 'field' }, + el('label', { class: 'field__label', for: 'ed-title', text: '제목' }), + inTitle + ) + ), + el('div', { class: 'field' }, + el('label', { class: 'field__label', for: 'ed-sum', text: '요약' }), + inSummary, + el('p', { class: 'field__hint', text: '목록 카드에 보입니다. 비워 두어도 됩니다.' }) + ), + el('div', { class: 'editor__panes' }, + el('div', { class: 'editor__pane' }, + el('label', { class: 'field__label', for: 'ed-body', text: '본문 (마크다운)' }), + taBody + ), + el('div', { class: 'editor__pane' }, + el('span', { class: 'field__label', text: '미리보기' }), + preview + ) + ), + el('div', { class: 'field' }, + el('span', { class: 'field__label', text: '이미지' }), + dropzone, + fileInput, + thumbs + ) + ); + + section.appendChild(form); + outlet.appendChild(section); + + renderThumbs(); + updatePreview(); + + // ── 리스너 ──────────────────────────────────────────────────── + const onBodyInput = () => { markDirty(); debouncedPreview(); }; + const onFieldInput = () => { markDirty(); }; + + taBody.addEventListener('input', onBodyInput); + inTitle.addEventListener('input', onFieldInput); + inSummary.addEventListener('input', onFieldInput); + selCategory.addEventListener('change', onFieldInput); + btnSave.addEventListener('click', save); + + dropzone.addEventListener('click', onDropzoneClick); + dropzone.addEventListener('keydown', onDropzoneKey); + dropzone.addEventListener('dragover', onDragOver); + dropzone.addEventListener('dragenter', onDragOver); + dropzone.addEventListener('dragleave', onDragLeave); + dropzone.addEventListener('drop', onDrop); + fileInput.addEventListener('change', onFileChange); + form.addEventListener('paste', onPaste); + + inTitle.focus(); + + return () => { + disposed = true; + clearDirty(); + debouncedPreview.cancel(); + taBody.removeEventListener('input', onBodyInput); + inTitle.removeEventListener('input', onFieldInput); + inSummary.removeEventListener('input', onFieldInput); + selCategory.removeEventListener('change', onFieldInput); + btnSave.removeEventListener('click', save); + dropzone.removeEventListener('click', onDropzoneClick); + dropzone.removeEventListener('keydown', onDropzoneKey); + dropzone.removeEventListener('dragover', onDragOver); + dropzone.removeEventListener('dragenter', onDragOver); + dropzone.removeEventListener('dragleave', onDragLeave); + dropzone.removeEventListener('drop', onDrop); + fileInput.removeEventListener('change', onFileChange); + form.removeEventListener('paste', onPaste); + setBusy(false); + }; +} + +// --------------------------------------------------------------------------- +// #/admin/categories — 카테고리 관리 +// --------------------------------------------------------------------------- + +/** + * 카테고리 관리 뷰. + * @param {Object} params + * @param {HTMLElement} outlet + * @returns {Function} cleanup + */ +export async function categoriesView(params, outlet) { + document.title = '김낙준 · 카테고리'; + let disposed = false; + + setBusy(true); + try { + const me = await api.auth.me(); + if (!me || !me.authenticated) { + setBusy(false); + toast('로그인이 필요합니다.', 'error'); + navigate('#/admin'); + return () => { disposed = true; }; + } + } catch { + setBusy(false); + navigate('#/admin'); + return () => { disposed = true; }; + } finally { + setBusy(false); + } + + const section = el('section', { class: 'section' }, + adminBar('카테고리 관리', [ + el('a', { class: 'btn btn--ghost btn--sm', href: '#/admin', text: '관리자로' }) + ]) + ); + const rows = el('div', { class: 'rows' }); + section.appendChild(rows); + outlet.appendChild(section); + + /** 한 줄을 편집 가능한 폼으로 만든다. */ + function categoryRow(cat, reload) { + const categoryNo = toInt(cat.categoryNo); + + const inSlug = el('input', { class: 'input', type: 'text', value: String(cat.slug || ''), maxlength: '64' }); + const inName = el('input', { class: 'input', type: 'text', value: String(cat.name || ''), maxlength: '100' }); + const inDesc = el('input', { + class: 'input', type: 'text', value: cat.description ? String(cat.description) : '', maxlength: '255' + }); + const inAccent = el('input', { class: 'input', type: 'color', value: safeAccent(cat.accent) || '#67e8f9' }); + const inSort = el('input', { class: 'input', type: 'number', value: String(toInt(cat.sortOrder)) }); + + const btnSave = el('button', { class: 'btn btn--ghost btn--sm', type: 'button', text: '저장' }); + btnSave.addEventListener('click', async () => { + btnSave.disabled = true; + setBusy(true); + try { + await api.categories.update(categoryNo, { + slug: inSlug.value.trim().toLowerCase(), + name: inName.value.trim(), + description: inDesc.value.trim() || null, + accent: inAccent.value, + sortOrder: toInt(inSort.value) + }); + toast('저장했습니다.', 'ok'); + reload(); + } catch (err) { + btnSave.disabled = false; + reportError(err, '저장하지 못했습니다.'); + } finally { + setBusy(false); + } + }); + + const btnDel = el('button', { class: 'btn btn--danger btn--sm', type: 'button', text: '삭제' }); + btnDel.addEventListener('click', async () => { + const ok = await confirmDialog('"' + String(cat.name || cat.slug) + '" 카테고리를 삭제할까요?', { + okText: '삭제', danger: true + }); + if (!ok) return; + btnDel.disabled = true; + setBusy(true); + try { + await api.categories.remove(categoryNo); + toast('삭제했습니다.', 'ok'); + reload(); + } catch (err) { + btnDel.disabled = false; + reportError(err, '삭제하지 못했습니다.'); + } finally { + setBusy(false); + } + }); + + return el('div', { class: 'row' }, + el('div', { class: 'row__main' }, + el('div', { class: 'form-grid' }, + el('div', { class: 'field' }, el('span', { class: 'field__label', text: 'slug' }), inSlug), + el('div', { class: 'field' }, el('span', { class: 'field__label', text: '이름' }), inName) + ), + el('div', { class: 'form-grid' }, + el('div', { class: 'field' }, el('span', { class: 'field__label', text: '설명' }), inDesc), + el('div', { class: 'form-row' }, + el('div', { class: 'field' }, el('span', { class: 'field__label', text: '색' }), inAccent), + el('div', { class: 'field' }, el('span', { class: 'field__label', text: '정렬' }), inSort) + ) + ), + el('p', { class: 'field__hint', text: '게시물 ' + toInt(cat.postCount) + '개' }) + ), + el('div', { class: 'row__side' }, btnSave, btnDel) + ); + } + + /** 새 카테고리 추가 폼 */ + function addForm(reload) { + const inSlug = el('input', { class: 'input', type: 'text', placeholder: 'game', maxlength: '64' }); + const inName = el('input', { class: 'input', type: 'text', placeholder: '게임', maxlength: '100' }); + const inDesc = el('input', { class: 'input', type: 'text', placeholder: '설명 (선택)', maxlength: '255' }); + const inAccent = el('input', { class: 'input', type: 'color', value: '#67e8f9' }); + const inSort = el('input', { class: 'input', type: 'number', value: '0' }); + const btnAdd = el('button', { class: 'btn btn--primary btn--sm', type: 'button', text: '추가' }); + + btnAdd.addEventListener('click', async () => { + const slug = inSlug.value.trim().toLowerCase(); + const name = inName.value.trim(); + if (!/^[a-z0-9-]{1,64}$/.test(slug)) { + toast('slug 는 영문 소문자·숫자·하이픈만 쓸 수 있습니다.', 'error'); + inSlug.focus(); + return; + } + if (!name) { + toast('이름을 입력해 주세요.', 'error'); + inName.focus(); + return; + } + btnAdd.disabled = true; + setBusy(true); + try { + await api.categories.create({ + slug, name, + description: inDesc.value.trim() || null, + accent: inAccent.value, + sortOrder: toInt(inSort.value) + }); + toast('추가했습니다.', 'ok'); + reload(); + } catch (err) { + btnAdd.disabled = false; + reportError(err, '추가하지 못했습니다.'); + } finally { + setBusy(false); + } + }); + + return el('div', { class: 'row glass' }, + el('div', { class: 'row__main' }, + el('strong', { class: 'card__title', text: '새 카테고리' }), + el('div', { class: 'form-grid' }, + el('div', { class: 'field' }, el('span', { class: 'field__label', text: 'slug' }), inSlug), + el('div', { class: 'field' }, el('span', { class: 'field__label', text: '이름' }), inName) + ), + el('div', { class: 'form-grid' }, + el('div', { class: 'field' }, el('span', { class: 'field__label', text: '설명' }), inDesc), + el('div', { class: 'form-row' }, + el('div', { class: 'field' }, el('span', { class: 'field__label', text: '색' }), inAccent), + el('div', { class: 'field' }, el('span', { class: 'field__label', text: '정렬' }), inSort) + ) + ) + ), + el('div', { class: 'row__side' }, btnAdd) + ); + } + + async function load() { + if (disposed) return; + rows.replaceChildren(el('p', { class: 'tagline', text: '불러오는 중…' })); + setBusy(true); + try { + const list = await api.categories.list(); + if (disposed) return; + rows.replaceChildren(); + rows.appendChild(addForm(load)); + for (const cat of list) rows.appendChild(categoryRow(cat, load)); + } catch (err) { + if (disposed) return; + rows.replaceChildren(); + if (handleAuthError(err)) return; + rows.appendChild(el('p', { class: 'tagline', text: (err && err.message) || '불러오지 못했습니다.' })); + } finally { + setBusy(false); + } + } + + await load(); + + return () => { + disposed = true; + setBusy(false); + }; +} + +export default view; diff --git a/GameServer/wwwroot/js/views/home.js b/GameServer/wwwroot/js/views/home.js new file mode 100644 index 0000000..da1090d --- /dev/null +++ b/GameServer/wwwroot/js/views/home.js @@ -0,0 +1,431 @@ +// views/home.js — 홈(작업 인덱스) 화면 +// 라우트: #/ · #/?cat= +// 구성: 히어로 → 인라인 카테고리 필터 → 대표 작업 한 건 → 번호 매긴 인덱스 +// +// 카드 그리드 대신 '작품집 목차' 형태를 쓰되, 이미지가 늘 자리를 갖도록 +// 맨 앞 글은 크게(lead) 보여 주고 나머지 행에는 작은 썸네일을 붙인다. +// 이미지가 없는 글은 제목 첫 글자를 넣은 파스텔 판으로 대신한다. + +import { api } from '../api.js'; +import { el, escapeHtml, formatDate, setBusy, skeletonRows, toast } from '../ui.js'; + +/** 한 번에 불러오는 게시물 수 */ +const PAGE_SIZE = 20; +/** 카테고리 캐시 수명 (ms) */ +const CATEGORY_TTL_MS = 60000; + +// 카테고리 목록은 모듈 스코프에 캐시한다. +// 필터만 바뀌는 경우(#/ ↔ #/?cat=game) 뷰가 다시 마운트돼도 다시 요청하지 않는다. +let catCache = null; +let catCachedAt = 0; +let catInflight = null; + +/** + * 카테고리 목록을 가져온다. 캐시가 살아 있으면 즉시 반환한다. + * @returns {Promise} + */ +function getCategories() { + if (catCache && Date.now() - catCachedAt < CATEGORY_TTL_MS) { + return Promise.resolve(catCache); + } + if (catInflight) return catInflight; + + catInflight = api.categories.list().then( + (list) => { + catCache = Array.isArray(list) ? list : []; + catCachedAt = Date.now(); + catInflight = null; + return catCache; + }, + (err) => { + catInflight = null; + throw err; + } + ); + return catInflight; +} + +/** params.cat 정규화. 비었거나 형식이 이상하면 'all' */ +function normalizeSlug(value) { + const text = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (!text || text === 'all') return 'all'; + return /^[a-z0-9-]{1,64}$/.test(text) ? text : 'all'; +} + +/** accent 는 CSS 값으로 들어가므로 헥사 색상만 통과시킨다. */ +function safeAccent(value) { + const text = typeof value === 'string' ? value.trim() : ''; + return /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(text) ? text : ''; +} + +/** http(s) 또는 사이트 상대경로만 허용. 그 외에는 빈 문자열 */ +function safeUrl(value) { + const text = typeof value === 'string' ? value.trim() : ''; + if (!text) return ''; + if (text.slice(0, 2) === '//') return ''; + if (text.charAt(0) === '/') return text; + return /^https?:\/\//i.test(text) ? text : ''; +} + +/** 정수 안전 변환 */ +function toInt(value) { + const n = Number(value); + return Number.isFinite(n) ? Math.trunc(n) : 0; +} + +/** 01, 02 … 형태의 두 자리 번호 */ +function padNo(n) { + return String(n).padStart(2, '0'); +} + +/** 'YYYY. MM. DD.' → 'YY.MM' (인덱스에서는 짧게 쓴다) */ +function shortDate(iso) { + const full = formatDate(iso); + const m = /^(\d{4})\.\s*(\d{2})\./.exec(full); + return m ? m[1].slice(2) + '.' + m[2] : full; +} + +/** 썸네일이 없을 때 쓸 첫 글자 (서로게이트 페어 안전) */ +function firstChar(title) { + const text = String(title || '').trim(); + if (!text) return '·'; + return Array.from(text)[0]; +} + +/** + * 이미지 자리. 그림이 있으면 img, 없으면 첫 글자를 넣은 판. + * @param {string} cls 바깥 figure 의 클래스 + * @param {string} url 안전하게 걸러진 이미지 주소 ('' 가능) + * @param {string} title alt / 대체 글자용 + */ +function mediaHtml(cls, url, title) { + if (url) { + return ( + '
    ' + + '' + escapeHtml(title) + '' + + '
    ' + ); + } + return ( + '' + ); +} + +/** + * 카테고리 필터 한 칸. 칩이 아니라 글자 링크다. + */ +function filterHtml(slug, name, count, accent, active) { + const href = slug === 'all' ? '#/' : '#/?cat=' + encodeURIComponent(slug); + const style = accent ? ' style="--card-accent:' + accent + '"' : ''; + return ( + '' + + escapeHtml(name) + + '' + toInt(count) + '' + + '' + ); +} + +/** + * 대표 작업(가장 최근 글) 한 건을 크게 보여 준다. + * @param {Object} post PostSummaryDto + */ +function leadHtml(post) { + const postNo = toInt(post && post.postNo); + const title = String((post && post.title) || '(제목 없음)'); + const catName = String((post && post.categoryName) || '미분류'); + const summary = post && post.summary ? String(post.summary) : ''; + const accent = safeAccent(post && post.categoryAccent); + const thumb = safeUrl(post && post.thumbnailUrl); + + return ( + '' + + mediaHtml('lead__media', thumb, title) + + '
    ' + + '최신 작업' + + '' + escapeHtml(catName) + '' + + '

    ' + escapeHtml(title) + '

    ' + + (summary ? '

    ' + escapeHtml(summary) + '

    ' : '') + + '' + + escapeHtml(formatDate(post && post.createdAt)) + + '' + + '자세히 보기' + + '
    ' + + '
    ' + ); +} + +/** + * 인덱스 행 하나. + * @param {Object} post PostSummaryDto + * @param {number} order 화면상의 순번 (1 부터) + */ +function rowHtml(post, order) { + const postNo = toInt(post && post.postNo); + const title = String((post && post.title) || '(제목 없음)'); + const catName = String((post && post.categoryName) || '미분류'); + const summary = post && post.summary ? String(post.summary) : ''; + const accent = safeAccent(post && post.categoryAccent); + const thumb = safeUrl(post && post.thumbnailUrl); + + return ( + '
  • ' + + '' + + '' + padNo(order) + '' + + mediaHtml('idx__thumb', thumb, title) + + '' + + '' + escapeHtml(title) + '' + + (summary ? '' + escapeHtml(summary) + '' : '') + + '' + + '' + + '' + escapeHtml(catName) + '' + + '' + escapeHtml(shortDate(post && post.createdAt)) + '' + + '' + + '' + + '
  • ' + ); +} + +/** 화면 뼈대 (API 데이터가 섞이지 않는 정적 마크업) */ +const SHELL_HTML = + '
    ' + + '

    게임을 만듭니다

    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '

    작업

    ' + + '' + + '
    ' + + '
    ' + + '
      ' + + '
      ' + + '
      ' + + '
      '; + +/** + * 홈 뷰. + * @param {Object} params 라우터가 넘겨주는 캡처/쿼리 값 (cat) + * @param {HTMLElement} outlet + * @returns {Function} cleanup + */ +export async function view(params, outlet) { + const query = params || {}; + const activeSlug = normalizeSlug(query.cat); + + let disposed = false; + let page = 1; + let loaded = 0; + let total = 0; + + outlet.insertAdjacentHTML('beforeend', SHELL_HTML); + + const statsEl = outlet.querySelector('#home-stats'); + const catsEl = outlet.querySelector('#home-cats'); + const leadEl = outlet.querySelector('#home-lead'); + const listEl = outlet.querySelector('#home-grid'); + const stateEl = outlet.querySelector('#home-state'); + const moreEl = outlet.querySelector('#home-more'); + + // ── 카테고리 필터 ───────────────────────────────────────────── + function renderFilters(cats) { + const list = Array.isArray(cats) ? cats : []; + const totalPosts = list.reduce((sum, c) => sum + toInt(c && c.postCount), 0); + + let html = filterHtml('all', '전체', totalPosts, '', activeSlug === 'all'); + for (const cat of list) { + const slug = String((cat && cat.slug) || ''); + if (!slug) continue; + html += filterHtml( + slug, + String((cat && cat.name) || slug), + toInt(cat && cat.postCount), + safeAccent(cat && cat.accent), + slug.toLowerCase() === activeSlug + ); + } + catsEl.innerHTML = html; + catsEl.setAttribute('aria-busy', 'false'); + } + + function renderStats(cats) { + const list = Array.isArray(cats) ? cats : []; + const totalPosts = list.reduce((sum, c) => sum + toInt(c && c.postCount), 0); + statsEl.innerHTML = + '작업 ' + totalPosts + '' + + '카테고리 ' + list.length + '' + + 'Unity · .NET · MariaDB'; + } + + // 필터를 누르면 해시에 기록한다. + // 보조 버튼·수정키 클릭은 브라우저 기본 동작(새 탭 등)에 맡긴다. + function onCatsClick(ev) { + if (ev.defaultPrevented) return; + if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return; + const target = ev.target instanceof Element ? ev.target.closest('a.filter') : null; + if (!target || !catsEl.contains(target)) return; + ev.preventDefault(); + const next = target.getAttribute('href'); + if (next && next !== location.hash) location.hash = next; + } + + // ── 상태 표시 ───────────────────────────────────────────────── + function clearState() { + stateEl.innerHTML = ''; + } + + function showEmpty() { + leadEl.innerHTML = ''; + listEl.innerHTML = ''; + listEl.classList.add('is-hidden'); + moreEl.innerHTML = ''; + const filtered = activeSlug !== 'all'; + stateEl.innerHTML = + '
      ' + + '

      ' + (filtered ? '이 카테고리에는 아직 작업이 없습니다.' : '아직 등록된 작업이 없습니다.') + '

      ' + + '

      ' + (filtered ? '다른 카테고리도 살펴보세요.' : '관리자로 로그인해 첫 글을 써 보세요.') + '

      ' + + (filtered ? '전체 보기' + : '관리자') + + '
      '; + } + + function showError(err) { + leadEl.innerHTML = ''; + listEl.innerHTML = ''; + listEl.classList.add('is-hidden'); + moreEl.innerHTML = ''; + stateEl.innerHTML = + '
      ' + + '

      작업 목록을 불러오지 못했습니다.

      ' + + '

      ' + escapeHtml((err && err.message) || '알 수 없는 오류가 발생했습니다.') + '

      ' + + '
      '; + const box = stateEl.querySelector('.empty'); + if (box) { + box.appendChild( + el('button', { + class: 'btn btn--ghost', + type: 'button', + text: '다시 시도', + onclick: () => { loadFirstPage(); } + }) + ); + } + } + + // ── 목록 ────────────────────────────────────────────────────── + /** 첫 페이지: 맨 앞 글은 lead 로, 나머지는 인덱스 행으로. */ + function drawFirst(items) { + leadEl.innerHTML = leadHtml(items[0]); + const rest = items.slice(1); + listEl.innerHTML = rest.map((p, i) => rowHtml(p, i + 2)).join(''); + listEl.classList.toggle('is-hidden', rest.length === 0); + } + + function drawMore(items) { + const startAt = loaded - items.length; + listEl.classList.remove('is-hidden'); + listEl.insertAdjacentHTML('beforeend', items.map((p, i) => rowHtml(p, startAt + i + 1)).join('')); + } + + function renderMoreButton() { + moreEl.innerHTML = ''; + if (loaded >= total) return; + const btn = el('button', { + class: 'btn btn--ghost', + type: 'button', + text: '더 보기', + onclick: () => { loadNextPage(btn); } + }); + moreEl.appendChild(btn); + } + + async function loadFirstPage() { + page = 1; + loaded = 0; + total = 0; + clearState(); + moreEl.innerHTML = ''; + leadEl.innerHTML = ''; + listEl.classList.remove('is-hidden'); + listEl.innerHTML = skeletonRows(4); + setBusy(true); + try { + const res = await api.posts.list({ category: activeSlug, page: 1, size: PAGE_SIZE }); + if (disposed) return; + const items = res && Array.isArray(res.items) ? res.items : []; + loaded = items.length; + total = Math.max(toInt(res && res.total), loaded); + if (!items.length) { + showEmpty(); + return; + } + drawFirst(items); + renderMoreButton(); + } catch (err) { + if (disposed) return; + showError(err); + } finally { + setBusy(false); + } + } + + async function loadNextPage(btn) { + if (btn.disabled) return; + btn.disabled = true; + btn.textContent = '불러오는 중…'; + setBusy(true); + try { + const res = await api.posts.list({ category: activeSlug, page: page + 1, size: PAGE_SIZE }); + if (disposed) return; + const items = res && Array.isArray(res.items) ? res.items : []; + page += 1; + loaded += items.length; + total = Math.max(toInt(res && res.total), loaded); + // 빈 페이지가 오면 더 이상 없는 것으로 본다(무한 클릭 방지). + if (!items.length) total = loaded; + else drawMore(items); + renderMoreButton(); + } catch (err) { + if (disposed) return; + // 이미 그려진 행은 그대로 두고 버튼만 되돌린다. + btn.disabled = false; + btn.textContent = '더 보기'; + toast((err && err.message) || '더 불러오지 못했습니다.', 'error'); + } finally { + setBusy(false); + } + } + + catsEl.addEventListener('click', onCatsClick); + + // 카테고리와 첫 페이지는 서로를 기다리지 않는다. + getCategories().then( + (cats) => { + if (disposed) return; + renderFilters(cats); + renderStats(cats); + }, + () => { + if (disposed) return; + // 카테고리를 못 받아도 목록 자체는 보여준다. + renderFilters([]); + statsEl.innerHTML = 'Unity · .NET · MariaDB'; + } + ); + + // await 하지 않는다: 라우터가 곧바로 cleanup 을 받아 갈 수 있게 한다. + loadFirstPage(); + + return () => { + disposed = true; + catsEl.removeEventListener('click', onCatsClick); + setBusy(false); + }; +} + +export default view; diff --git a/GameServer/wwwroot/js/views/post.js b/GameServer/wwwroot/js/views/post.js new file mode 100644 index 0000000..4aad7b1 --- /dev/null +++ b/GameServer/wwwroot/js/views/post.js @@ -0,0 +1,135 @@ +// views/post.js — 게시물 상세 화면 +// 라우트: #/post/:postNo +// 구성: 카테고리 → 제목 → 요약 → 날짜 → 마크다운 본문 +// +// 표지 이미지와 하단 갤러리는 두지 않는다. +// 이미지는 본문 안에서 마크다운으로 직접 배치한다. ( ![캡션|half](주소) ) + +import { api } from '../api.js'; +import { escapeHtml, formatDate, setBusy, toast } from '../ui.js'; +import { renderMarkdown } from '../markdown.js'; + +/** accent 는 CSS 값으로 들어가므로 헥사 색상만 통과시킨다. */ +function safeAccent(value) { + const text = typeof value === 'string' ? value.trim() : ''; + return /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(text) ? text : ''; +} + +/** 정수 안전 변환 */ +function toInt(value) { + const n = Number(value); + return Number.isFinite(n) ? Math.trunc(n) : 0; +} + +/** '12' 처럼 양의 정수인지 확인한다. */ +function parsePostNo(value) { + const text = String(value === null || value === undefined ? '' : value).trim(); + if (!/^\d{1,9}$/.test(text)) return 0; + const n = Number(text); + return n > 0 ? n : 0; +} + +/** 찾을 수 없음 / 오류 화면 */ +function stateHtml(icon, title, detail) { + return ( + '
      ' + + '' + + '

      ' + escapeHtml(title) + '

      ' + + (detail ? '

      ' + escapeHtml(detail) + '

      ' : '') + + '작업 목록으로' + + '
      ' + ); +} + +/** + * 게시물 본문 마크업. + * 사용자 데이터는 모두 escapeHtml 을 거치고, 본문만 renderMarkdown 결과를 넣는다. + */ +function postHtml(post) { + const title = String((post && post.title) || '(제목 없음)'); + const catName = String((post && post.categoryName) || '미분류'); + const catSlug = String((post && post.categorySlug) || ''); + const accent = safeAccent(post && post.categoryAccent); + const summary = post && post.summary ? String(post.summary) : ''; + const created = formatDate(post && post.createdAt); + const updated = formatDate(post && post.updatedAt); + + const catHref = catSlug ? '#/?cat=' + encodeURIComponent(catSlug) : '#/'; + + let html = + '
      ' + + '
      ' + + '' + + '' + + escapeHtml(catName) + + '' + + '

      ' + escapeHtml(title) + '

      ' + + (summary ? '

      ' + escapeHtml(summary) + '

      ' : '') + + '' + + '
      '; + + const body = post && post.contentMd ? renderMarkdown(post.contentMd) : ''; + html += '
      ' + (body || '

      본문이 아직 없습니다.

      ') + '
      '; + + html += + '' + + '
      '; + + return html; +} + +/** + * 게시물 상세 뷰. + * @param {Object} params 라우터 캡처값 (postNo) + * @param {HTMLElement} outlet + * @returns {Function} cleanup + */ +export async function view(params, outlet) { + const postNo = parsePostNo(params && params.postNo); + let disposed = false; + + if (!postNo) { + outlet.innerHTML = stateHtml('✦', '게시물을 찾을 수 없습니다.', '주소가 올바르지 않습니다.'); + return () => { disposed = true; }; + } + + outlet.innerHTML = + '
      ' + + '

      불러오는 중…

      '; + + setBusy(true); + try { + const post = await api.posts.get(postNo); + if (disposed) return () => { disposed = true; }; + + outlet.innerHTML = postHtml(post); + document.title = '김낙준 · ' + String((post && post.title) || '작업'); + } catch (err) { + if (!disposed) { + const status = err && err.status; + if (status === 404) { + outlet.innerHTML = stateHtml('✦', '게시물을 찾을 수 없습니다.', '삭제되었거나 주소가 바뀌었을 수 있습니다.'); + } else { + outlet.innerHTML = stateHtml('⚠', '게시물을 불러오지 못했습니다.', + (err && err.message) || '알 수 없는 오류가 발생했습니다.'); + toast((err && err.message) || '게시물을 불러오지 못했습니다.', 'error'); + } + } + } finally { + setBusy(false); + } + + return () => { + disposed = true; + setBusy(false); + }; +} + +export default view; diff --git a/GameServer/wwwroot/uploads/.gitkeep b/GameServer/wwwroot/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 80d5694..7e6f1db 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,181 @@ # Genesis_GameServer -유니티 게임(Genesis) 서버 +유니티 게임(Genesis) 서버 + 개인 포트폴리오 사이트. +하나의 ASP.NET Core 프로세스가 두 가지를 함께 서빙합니다. + +| 경로 | 내용 | +|---|---| +| `/myGame/*` | 유니티 게임 클라이언트가 쓰는 기존 API (변경 없음) | +| `/api/portfolio/*` | 포트폴리오 REST API | +| `/` 그 밖의 모든 경로 | 포트폴리오 웹사이트 (정적 SPA) | + +--- + +## 1. 처음 실행하기 + +### 관리자 비밀번호부터 바꾸세요 + +`GameServer/appsettings.json` 의 `Portfolio.AdminPassword` 가 `CHANGE_ME` 로 되어 있으면 +**로그인 자체가 막혀 있습니다**(503). 원하는 비밀번호로 바꾸고 서버를 다시 시작하세요. + +```json +"Portfolio": { + "AdminPassword": "여기에-원하는-비밀번호", + "SiteTitle": "사이트 이름", + "SiteTagline": "한 줄 소개", + "MaxUploadBytes": 10485760 +} +``` + +> `SiteTitle` · `SiteTagline` 은 지금 어느 코드도 읽지 않습니다. +> 화면에 나오는 이름과 문구는 `GameServer/wwwroot/index.html` 에 직접 들어 있습니다. + +파일에 비밀번호를 두고 싶지 않다면 환경변수가 우선합니다. + +```bash +# Windows (PowerShell) +$env:Portfolio__AdminPassword = "비밀번호" +# Linux / macOS +export Portfolio__AdminPassword="비밀번호" +``` + +### 실행 + +```bash +dotnet run --project GameServer/GameServer.csproj +``` + +브라우저에서 `http://localhost:5281` 을 열면 포트폴리오가 뜹니다. +우측 상단 **관리자** 버튼 → 비밀번호 입력 → 글을 쓸 수 있습니다. + +> 이 프로젝트는 `net9.0` 을 대상으로 합니다. .NET 9 런타임이 없고 상위 버전만 있다면 +> `DOTNET_ROLL_FORWARD=LatestMajor` 를 주고 실행하거나, .NET 9 런타임을 설치하세요. + +--- + +## 2. 데이터베이스 + +포트폴리오 테이블은 **서버가 시작할 때 자동으로 만들어집니다** +(`Data/PortfolioSchemaInitializer.cs`, `CREATE TABLE IF NOT EXISTS`). +따로 마이그레이션을 돌릴 필요가 없습니다. + +- `tb_portfolio_category` — 카테고리 (처음 한 번 `게임 / 개발 / 아트 / 기록` 4개가 시드됩니다) +- `tb_portfolio_post` — 게시물 +- `tb_portfolio_post_image` — 게시물 이미지 + +수동으로 만들고 싶다면 `GameServer/sql/portfolio_schema.sql` 을 쓰세요. +DB 연결이 실패해도 **게임 서버 부팅은 막지 않습니다**(로그만 남기고 넘어갑니다). + +--- + +## 3. 화면 + +| 주소 | 화면 | +|---|---| +| `#/` | 작업 인덱스 (히어로 + 카테고리 필터 + 번호 매긴 목록) | +| `#/?cat=game` | 카테고리로 걸러진 목록 | +| `#/post/12` | 게시물 상세 (마크다운 본문 + 이미지 갤러리 + 라이트박스) | +| `#/about` | 소개 | +| `#/admin` | 관리자 로그인 / 대시보드 | +| `#/admin/new` | 새 글 | +| `#/admin/edit/12` | 글 수정 | +| `#/admin/categories` | 카테고리 관리 | + +### 글쓰기 + +본문은 **마크다운**입니다. 왼쪽에 쓰면 오른쪽에 바로 미리보기가 나옵니다. + +이미지는 세 가지 방법으로 올립니다. + +1. 이미지 영역을 **클릭**해서 파일 고르기 +2. 파일을 **끌어다 놓기** +3. 클립보드에서 **Ctrl+V** 로 붙여넣기 + +올린 이미지마다 세 개의 버튼이 있습니다. + +- **대표** — 목록에서 커서를 따라 뜨는 썸네일과 상세 표지로 지정 +- **본문삽입** — 커서 위치에 `![](주소)` 를 끼워 넣기 +- **삭제** — 목록에서 빼기 (이번에 올린 파일이면 서버에서도 지웁니다) + +저장하지 않고 페이지를 벗어나려 하면 경고가 뜹니다. + +### 화면 구성 + +목록은 카드 그리드가 아니라 **작품집 목차(인덱스)** 형태입니다. +번호 → 제목 → 요약 → 카테고리 · 날짜 순으로 한 줄씩 늘어놓고, +행에 마우스를 올리면 행이 살짝 밀려 들어오면서 대표 이미지가 커서 옆에 떠오릅니다 +(마우스가 있는 기기에서만. 터치 기기에서는 뜨지 않습니다). + +배경 장식은 쓰지 않습니다. 위계는 글자 크기 대비와 얇은 괘선으로 만들고, +색은 카테고리 강조색으로만 들어옵니다. 한글은 산세리프, 숫자·라벨은 모노스페이스입니다. + +카테고리 색은 `#/admin/categories` 에서 바꿀 수 있고, 그 색이 목록·상세·관리자 화면에 +그대로 반영됩니다. + +### 샘플 글 지우기 + +화면이 어떻게 보이는지 확인할 수 있도록 제목이 `[샘플]` 로 시작하는 글 5건을 +자리표시 이미지와 함께 넣어 두었습니다. **직접 쓴 글이 아니니 지우고 시작하세요.** + +`#/admin` 에서 로그인한 뒤 각 줄의 **삭제** 를 누르면 됩니다. +글을 지워도 업로드된 자리표시 이미지 파일은 남으므로, 함께 비우려면 +`GameServer/wwwroot/uploads/` 아래 폴더를 통째로 지우면 됩니다 +(`.gitkeep` 은 남겨 두세요). + +### 소개 화면 내용 바꾸기 + +`GameServer/wwwroot/js/views/about.js` 의 `ABOUT_MD` 상수 하나만 고치면 됩니다. +빌드가 필요 없고 새로고침하면 바로 반영됩니다. + +--- + +## 4. 구조 + +``` +GameServer/ +├─ Controllers/ +│ ├─ CharacterController.cs 게임 API (기존) +│ ├─ UserController.cs 게임 API (기존) +│ └─ Portfolio/ +│ ├─ PortfolioAuthController.cs 로그인 · 로그아웃 · 상태 +│ ├─ PortfolioCategoryController.cs 카테고리 CRUD +│ ├─ PortfolioPostController.cs 게시물 CRUD +│ └─ PortfolioUploadController.cs 이미지 업로드 · 삭제 +├─ Models/Portfolio/ 모델 + DTO +├─ Services/PortfolioAdminAuth.cs 관리자 비밀번호 검증 +├─ Data/PortfolioSchemaInitializer.cs 테이블 자동 생성 + 시드 +├─ sql/portfolio_schema.sql 참고용 DDL +└─ wwwroot/ 포트폴리오 사이트 (빌드 단계 없음) + ├─ index.html + ├─ css/style.css 디자인 시스템 한 파일 + ├─ js/ + │ ├─ app.js 진입점 · 라우트 표 + │ ├─ router.js 해시 라우터 + │ ├─ api.js REST 래퍼 + │ ├─ ui.js 토스트 · 모달 · 헬퍼 + │ ├─ markdown.js 자체 제작 마크다운 렌더러 + │ └─ views/ home · post · about · admin + └─ uploads/ 업로드된 이미지 (git 에서 제외됨) +``` + +프론트엔드에 **빌드 단계가 없습니다**. npm 도, 번들러도, 외부 CDN 요청도 없습니다. +배포는 그대로 `dotnet publish` 하나로 끝납니다. + +--- + +## 5. 보안 메모 + +- 조회는 누구나, **작성 · 수정 · 삭제는 로그인한 관리자만** 가능합니다 (쿠키 세션). +- 로그인은 IP별 **5분에 8회**로 제한됩니다. +- 업로드는 확장자뿐 아니라 **매직 바이트**까지 검사합니다. 파일명은 서버가 GUID 로 새로 짓습니다. +- 이미지 삭제는 경로를 절대경로로 해석해 **`uploads` 폴더 밖이면 거부**합니다. +- 마크다운은 **먼저 escape 한 뒤 HTML 을 조립**하므로 본문에 넣은 태그가 실행되지 않습니다. + 링크는 `http` · `https` · `mailto` 와 사이트 내부 경로만 허용합니다. + +### 아직 남은 것 + +`appsettings.json` 에 **DB 비밀번호가 평문으로 들어 있고 git 에 커밋돼 있습니다.** +이 저장소를 공개할 계획이 있다면 연결 문자열을 환경변수 +(`ConnectionStrings__DefaultConnection`)나 사용자 비밀로 옮기고, +노출된 DB 비밀번호를 교체하는 것을 권합니다.