diff --git a/dist/README.txt b/dist/README.txt index f6c31d3..f653698 100644 --- a/dist/README.txt +++ b/dist/README.txt @@ -1,5 +1,5 @@ UbisamBase 플랫폼 배포 폴더 (빌드할 때 자동 생성 - 직접 고치지 마세요) -갱신 시각 : 2026-09-21 22:04:42 +갱신 시각 : 2026-09-21 22:06:55 빌드 구성 : Release 원본 소스 : D:\업무\03_개발\Source\용접기\08_UbisamPlatform\ ---- diff --git a/dist/UbisamBase.Core.dll b/dist/UbisamBase.Core.dll index 735fad7..9efcf65 100644 Binary files a/dist/UbisamBase.Core.dll and b/dist/UbisamBase.Core.dll differ diff --git a/dist/UbisamBase.Launcher.exe b/dist/UbisamBase.Launcher.exe index 1cecfa8..3c93597 100644 Binary files a/dist/UbisamBase.Launcher.exe and b/dist/UbisamBase.Launcher.exe differ diff --git a/dist/UbisamBase.Shell.exe b/dist/UbisamBase.Shell.exe index 007a217..a4639b1 100644 Binary files a/dist/UbisamBase.Shell.exe and b/dist/UbisamBase.Shell.exe differ diff --git a/dist/platform-version.json b/dist/platform-version.json index 1772d2c..cc83585 100644 --- a/dist/platform-version.json +++ b/dist/platform-version.json @@ -1,4 +1,4 @@ { -"version": "2026-09-21 22:04:42", +"version": "2026-09-21 22:06:55", "configuration": "Release" } diff --git a/doc/history/changelog.md b/doc/history/changelog.md index f3da83d..f12ebb4 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -13,3 +13,5 @@ | 2026-09-21 | `ICloseGuard` 추가 — 저장 안 한 변경 확인을 셸이 단일 통로로 처리(하단 "종료"도 이 경로) (건의 4) | | 2026-09-21 | 플랫폼 스크롤바 색 조정(트랙=테마 판 색, 막대=accent) | | 2026-09-21 | 플랫폼 자동 업데이트 추가 — Launcher가 켤 때 깃 저장소의 `dist`와 비교해 새 버전이면 받아서 실행(UI Setting에서 사용/자동 여부 선택) | +| 2026-09-21 | 업데이트 안내창을 플랫폼 디자인으로 교체(Launcher 자체 XAML, 시스템 테마 따름) | +| 2026-09-21 | `ApiService` 추가 — 사내 API를 앱이 대신 호출(토큰은 설정 파일에만, 호출 간격 제한·타임아웃 포함) | diff --git a/doc/platform-guide.md b/doc/platform-guide.md index 4127d53..08a8e6d 100644 --- a/doc/platform-guide.md +++ b/doc/platform-guide.md @@ -686,3 +686,54 @@ Launcher(`PlatformUpdater.Settings`)가 같은 이름으로 읽고 쓴다. **이 - 업데이트는 **덮어쓰기**다. 쓰지 않게 된 옛 파일까지 정리하려면 `publish-platform.ps1`로 `bin`을 비우고 다시 채운 뒤 `dist`를 올리면 된다. +--- + +## 13. 사내 API 호출 — `UbisamBase.Core.Net.ApiService` + +토큰이 필요한 사내 API를 **앱이 대신 호출해** 응답 본문(JSON 문자열)만 돌려준다. 화면(WebView2 등)이 +직접 부르면 토큰이 그 문서에 섞여 들어가므로, 앱이 부르고 값만 넘기는 구조를 공용으로 둔 것이다. +브라우저에서 막히는 CORS도 이 경로로는 문제가 되지 않는다. + +```csharp +using UbisamBase.Core.Net; + +var json = await ApiService.Current.FetchAsync(url); // 실패하면 예외 +var (ok, body, error) = await ApiService.Current.TryFetchAsync(url); // 예외 대신 결과로 받기 +``` + +- 인증 헤더는 서비스 안에서 붙는다 — **부르는 쪽은 토큰을 몰라도 된다.** +- 같은 주소를 `MinIntervalMs`(기본 1초) 안에 다시 부르면 서버를 때리지 않고 직전 응답을 돌려준다. + 화면 갱신 주기가 짧아도 호출 제한에 걸리지 않게 하기 위한 것이다. +- 로그에는 주소의 `?` 앞부분만 남긴다(쿼리에 토큰이 섞인 경우 대비). 응답 본문도 남기지 않는다. + +### 13-1. 설정 — `D:\UbisamConfig\Settings\ApiSettings.json` + +```json +{ + "AuthMode": "BearerHeader", + "HeaderName": "X-Api-Key", + "Token": "...", + "MinIntervalMs": 1000, + "TimeoutSeconds": 10 +} +``` + +| AuthMode | 붙는 헤더 | +| --- | --- | +| `None` | 없음(사내망 IP 제한 등) | +| `BearerHeader` | `Authorization: Bearer {Token}` | +| `CustomHeader` | `{HeaderName}: {Token}` | +| `Cookie` | `Cookie: {Token}` | + +**토큰은 이 파일에만 있다.** 내보낸 HTML·`.ub` 같은 산출물에는 들어가지 않는다. + +### 13-2. 내보낸 파일에서는 인증 API를 못 부른다 + +앱 밖으로 내보낸 HTML은 혼자 도는 파일이라 이 서비스가 없다. 그래서: + +- **앱 안에서 볼 때** → `ApiService`로 최신 값 +- **내보낸 파일을 남에게 줄 때** → 인증이 필요한 API는 호출 불가, 내보낼 때 담긴 마지막 값이 보인다 + (인증이 없는 공개 API라면 내보낸 파일에서도 실시간으로 돈다) + +이는 제약이라기보다 의도된 동작이다 — 토큰이 파일에 들어가지 않기 때문이다. + diff --git a/src/UbisamBase.Core/Net/ApiService.cs b/src/UbisamBase.Core/Net/ApiService.cs new file mode 100644 index 0000000..f1fd3ab --- /dev/null +++ b/src/UbisamBase.Core/Net/ApiService.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using UbisamBase.Core.Bootstrap; +using UbisamBase.Core.Logging; + +namespace UbisamBase.Core.Net; + +/// 인증을 어떻게 붙이는지. +public enum ApiAuthMode +{ + /// 인증 없음(사내망 IP 제한 등). 주소만으로 부른다. + None, + + /// Authorization: Bearer {토큰} + BearerHeader, + + /// {HeaderName}: {토큰} — 사내 API가 고유 헤더를 쓰는 경우. + CustomHeader, + + /// Cookie: {토큰} — 세션 쿠키를 그대로 넘기는 경우. + Cookie +} + +/// +/// 사내 API를 대신 호출해 응답 본문(JSON 문자열)만 돌려주는 공용 서비스. +/// +/// 왜 플랫폼에 있나 — 토큰이 필요한 API는 화면(WebView2 등)에서 직접 부르면 토큰이 그 +/// 문서에 섞여 들어간다. 앱이 대신 부르고 숫자만 넘기면 토큰은 앱 설정 파일에만 남는다. +/// 내보낸 HTML·.ub 같은 파일에는 절대 들어가지 않는다. 브라우저에서 막히는 CORS도 우회된다. +/// +/// 토큰은 D:\UbisamConfig\Settings\ApiSettings.json에만 저장되고, 로그에도 남기지 않는다. +/// +public sealed class ApiService +{ + private static readonly Logger Log = new("Api"); + private static readonly HttpClient Http = new(); + private static ApiService? current; + + private readonly Dictionary cache = new(StringComparer.OrdinalIgnoreCase); + + public static ApiService Current => current ??= new ApiService(); + + public ApiSettings Settings { get; private set; } = ApiSettings.Load(); + + /// 설정 파일을 다시 읽는다(설정 화면에서 토큰을 바꾼 뒤 등). + public void ReloadSettings() => Settings = ApiSettings.Load(); + + /// + /// 주소를 호출해 응답 본문을 그대로 돌려준다. 인증 헤더는 설정에 따라 이 안에서 붙으므로 + /// 부르는 쪽(모듈·편집기)은 토큰을 몰라도 된다. + /// + /// 같은 주소를 안에 다시 부르면 서버를 때리지 않고 + /// 직전 응답을 돌려준다 — 화면 갱신 주기가 짧아도 호출 제한에 걸리지 않게 하기 위한 것이다. + /// 실패하면 예외를 던진다(메시지는 사용자에게 보여줘도 되는 내용만 담는다). + /// + public async Task FetchAsync(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("주소가 비어 있습니다.", nameof(url)); + } + + if (TryGetCached(url, out var cached)) + { + return cached; + } + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + ApplyAuth(request); + + try + { + using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(Math.Max(1, Settings.TimeoutSeconds))); + using var response = await Http.SendAsync(request, cts.Token).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + // 본문에 토큰이 되비칠 수 있으므로 상태 코드만 남긴다. + Log.W($"API 호출 실패 ({(int)response.StatusCode}) : {Safe(url)}"); + throw new InvalidOperationException($"서버가 {(int)response.StatusCode} 응답을 돌려줬습니다."); + } + + lock (cache) + { + cache[url] = new CacheEntry(body, DateTime.UtcNow); + } + + return body; + } + catch (OperationCanceledException) + { + Log.W($"API 호출 시간 초과 : {Safe(url)}"); + throw new InvalidOperationException($"{Settings.TimeoutSeconds}초 안에 응답이 오지 않았습니다."); + } + catch (HttpRequestException ex) + { + Log.W($"API 호출 실패 : {Safe(url)} — {ex.Message}"); + throw new InvalidOperationException("서버에 연결하지 못했습니다."); + } + } + + /// 예외 대신 성공 여부로 받고 싶을 때. 실패하면 에 사유가 담긴다. + public async Task<(bool Ok, string Body, string Error)> TryFetchAsync(string url) + { + try + { + return (true, await FetchAsync(url).ConfigureAwait(false), string.Empty); + } + catch (Exception ex) + { + return (false, string.Empty, ex.Message); + } + } + + private bool TryGetCached(string url, out string body) + { + lock (cache) + { + if (cache.TryGetValue(url, out var entry) && + (DateTime.UtcNow - entry.At).TotalMilliseconds < Math.Max(0, Settings.MinIntervalMs)) + { + body = entry.Body; + return true; + } + } + + body = string.Empty; + return false; + } + + private void ApplyAuth(HttpRequestMessage request) + { + var token = Settings.Token; + if (string.IsNullOrEmpty(token)) + { + return; + } + + switch (Settings.AuthMode) + { + case ApiAuthMode.BearerHeader: + request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token); + break; + + case ApiAuthMode.CustomHeader: + var name = string.IsNullOrWhiteSpace(Settings.HeaderName) ? "X-Api-Key" : Settings.HeaderName; + request.Headers.TryAddWithoutValidation(name, token); + break; + + case ApiAuthMode.Cookie: + request.Headers.TryAddWithoutValidation("Cookie", token); + break; + } + } + + /// 로그에 남길 주소 — 쿼리에 토큰이 섞여 있을 수 있으므로 물음표 앞까지만 남긴다. + private static string Safe(string url) + { + var index = url.IndexOf('?'); + return index < 0 ? url : url.Substring(0, index) + "?..."; + } + + private readonly struct CacheEntry + { + public CacheEntry(string body, DateTime at) + { + Body = body; + At = at; + } + + public string Body { get; } + + public DateTime At { get; } + } +} + +/// ApiService 설정. 토큰이 들어 있으므로 앱 설정 폴더에만 저장한다. +public sealed class ApiSettings +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public static string FilePath => Path.Combine(UbisamAppBuilder.ConfigRoot, "Settings", "ApiSettings.json"); + + public ApiAuthMode AuthMode { get; set; } = ApiAuthMode.None; + + /// AuthMode가 CustomHeader일 때 쓸 헤더 이름. + public string HeaderName { get; set; } = "X-Api-Key"; + + /// 토큰/키/쿠키 값. 이 파일 밖으로 나가지 않는다. + public string Token { get; set; } = string.Empty; + + /// 같은 주소를 이 시간 안에 다시 부르면 서버 대신 직전 응답을 쓴다(호출 제한 대비). + public int MinIntervalMs { get; set; } = 1000; + + public int TimeoutSeconds { get; set; } = 10; + + public static ApiSettings Load() + { + try + { + if (File.Exists(FilePath)) + { + return JsonSerializer.Deserialize(File.ReadAllText(FilePath), JsonOptions) ?? new ApiSettings(); + } + } + catch + { + // 손상된 파일은 무시하고 기본값(인증 없음)으로 간다. + } + + return new ApiSettings(); + } + + public void Save() + { + var dir = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions)); + } +} diff --git a/src/UbisamBase.Core/UbisamBase.Core.csproj b/src/UbisamBase.Core/UbisamBase.Core.csproj index 1a4a6a5..79fcd19 100644 --- a/src/UbisamBase.Core/UbisamBase.Core.csproj +++ b/src/UbisamBase.Core/UbisamBase.Core.csproj @@ -12,6 +12,8 @@ + +