DreamineVMS.Web 1.0.0.0
DreamineVMS 에이전트가 제공하는 HLS 카메라 영상을 브라우저에서 관리·재생하는 원격 CCTV 웹 서비스입니다.
로딩중...
검색중...
일치하는것 없음
Program.cs
이 파일의 문서화 페이지로 가기
1using Dreamine.Identity;
2using Dreamine.Identity.Options;
3using DreamineVMS.Web.Blazor;
8using Microsoft.AspNetCore.Components.Server;
9using Microsoft.AspNetCore.HttpOverrides;
10
11var builder = WebApplication.CreateBuilder(args);
12builder.Configuration.AddUserSecrets("codemaru-oauth-2ba4e1b2");
13
14AuthOptions authOptions =
15 builder.Configuration.GetSection(AuthOptions.SectionName).Get<AuthOptions>() ?? new AuthOptions();
17 builder.Configuration[$"{AuthOptions.SectionName}:UsersDbPath"],
18 Path.Combine(AppContext.BaseDirectory, "App_Data", "codemaru.db"));
19
20builder.Services
21 .AddRazorComponents()
22 .AddInteractiveServerComponents();
23
24builder.Services.Configure<CircuitOptions>(o => o.DetailedErrors = true);
25builder.Services.AddHttpClient();
26builder.Services.AddDreamineIdentityWeb(authOptions, usersDbPath);
27
28builder.Services.AddSingleton<VmsDatabase>();
29builder.Services.AddSingleton<VmsUserService>();
30builder.Services.AddSingleton<VmsSessionService>();
31builder.Services.AddScoped<VmsAuthState>();
33builder.Services.AddSingleton<AgentTokenService>();
34builder.Services.AddSingleton<HlsSegmentStore>();
35
36var app = builder.Build();
37
38var provider = new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider();
39provider.Mappings[".exe"] = "application/octet-stream";
40app.UseForwardedHeaders();
41app.UseStaticFiles(new StaticFileOptions { ContentTypeProvider = provider });
42app.UseRouting();
43app.UseAuthentication();
44app.UseAuthorization();
45app.UseAntiforgery();
46
47// 크롤러 봇에게 OG 태그가 포함된 정적 HTML을 반환합니다.
48// Blazor Server는 JS 없이 렌더링이 안 되므로 봇은 빈 페이지만 봅니다.
49app.Use(async (ctx, next) =>
50{
51 var ua = ctx.Request.Headers.UserAgent.ToString();
52
53 // AppleWebKit + Chrome/Safari = 실제 브라우저 (카카오톡 인앱, 삼성 인터넷 등 포함)
54 // 이 경우 크롤러로 처리하지 않음
55 bool looksLikeBrowser = ua.Contains("AppleWebKit") &&
56 (ua.Contains("Chrome") || ua.Contains("Safari") || ua.Contains("Mobile"));
57
58 bool isCrawler = !looksLikeBrowser && (
59 ua.Contains("Kakaotalk", StringComparison.OrdinalIgnoreCase) ||
60 ua.Contains("kakaostory", StringComparison.OrdinalIgnoreCase) ||
61 ua.Contains("Naverbot", StringComparison.OrdinalIgnoreCase) ||
62 ua.Contains("DaumApps", StringComparison.OrdinalIgnoreCase)
63 ) || (
64 // 이 UA들은 AppleWebKit 포함해도 항상 봇
65 ua.Contains("facebookexternalhit", StringComparison.OrdinalIgnoreCase) ||
66 ua.Contains("Twitterbot", StringComparison.OrdinalIgnoreCase) ||
67 ua.Contains("LinkedInBot", StringComparison.OrdinalIgnoreCase) ||
68 ua.Contains("Slackbot", StringComparison.OrdinalIgnoreCase) ||
69 ua.Contains("Discordbot", StringComparison.OrdinalIgnoreCase)
70 );
71
72 if (!isCrawler) { await next(); return; }
73
74 var path = ctx.Request.Path.Value ?? "";
75
76 // / 홈페이지 OG
77 if (path == "/" || path == "")
78 {
79 var siteUrl = $"https://{ctx.Request.Host}";
80 const string title = "CodeMaru CCTV Viewer";
81 const string desc = "카메라 주인은 앱 하나 설치, 보는 사람은 링크만으로 실시간 시청";
82 var image = $"{siteUrl}/images/cctvviewer-og.png";
83 ctx.Response.ContentType = "text/html; charset=utf-8";
84 await ctx.Response.WriteAsync($"""
85 <!DOCTYPE html>
86 <html lang="ko">
87 <head>
88 <meta charset="utf-8"/>
89 <title>{title}</title>
90 <meta property="og:type" content="website"/>
91 <meta property="og:url" content="{siteUrl}/"/>
92 <meta property="og:title" content="{title}"/>
93 <meta property="og:description" content="{desc}"/>
94 <meta property="og:image" content="{image}"/>
95 <meta property="og:site_name" content="CodeMaru CCTV Viewer"/>
96 <meta name="description" content="{desc}"/>
97 </head>
98 <body><p><a href="{siteUrl}/">{title}</a></p></body>
99 </html>
100 """);
101 return;
102 }
103
104 // /{slug}/live 패턴 감지
105 var parts = path.Trim('/').Split('/');
106 if (parts.Length == 2 && parts[1] == "live")
107 {
108 var slug = parts[0];
109 var userSvc = ctx.RequestServices.GetRequiredService<VmsUserService>();
110 var user = await userSvc.FindBySlugAsync(slug);
111 if (user is not null)
112 {
113 var siteUrl = $"https://{ctx.Request.Host}";
114 var pageUrl = $"{siteUrl}/{slug}/live";
115 var title = string.IsNullOrWhiteSpace(user.OgTitle)
116 ? $"{user.DisplayName} CCTV Live"
117 : user.OgTitle;
118 var desc = string.IsNullOrWhiteSpace(user.OgDescription)
119 ? $"{user.DisplayName}의 실시간 카메라 스트림입니다."
120 : user.OgDescription;
121 var image = string.IsNullOrWhiteSpace(user.OgImage)
122 ? $"{siteUrl}/img/og-default.png"
123 : user.OgImage;
124
125 ctx.Response.ContentType = "text/html; charset=utf-8";
126 await ctx.Response.WriteAsync($"""
127 <!DOCTYPE html>
128 <html lang="ko">
129 <head>
130 <meta charset="utf-8"/>
131 <title>{title}</title>
132 <meta property="og:type" content="website"/>
133 <meta property="og:url" content="{pageUrl}"/>
134 <meta property="og:title" content="{title}"/>
135 <meta property="og:description" content="{desc}"/>
136 <meta property="og:image" content="{image}"/>
137 <meta property="og:site_name" content="CodeMaru CCTV Viewer"/>
138 <meta name="description" content="{desc}"/>
139 </head>
140 <body><p><a href="{pageUrl}">{title}</a></p></body>
141 </html>
142 """);
143 return;
144 }
145 }
146
147 await next();
148});
149
150// ── 에이전트 API ──────────────────────────────────────────────────────────
151
152// 로그인 → 토큰 + 카메라 목록
153app.MapPost("/api/agent/login", async (AgentLoginRequest req, HttpContext ctx,
155{
156 var (ok, _, user) = await users.LoginAsync(req.Email, req.Password);
157 if (!ok || user is null) return Results.Unauthorized();
158
159 var token = tokens.Issue(user);
160 var cameras = repo.GetAll()
161 .Where(c => c.TenantId == user.Id && c.Enabled && !c.IsDirectHls)
162 .Select(c => new AgentCameraDto(c.Id, c.Name, c.Host, c.RtspUrl, c.AutoReconnect, c.IsPublic))
163 .ToList();
164
165 // 로그인(재연결) 시에만 낡은 HLS 파일 초기화
166 var hlsStore = ctx.RequestServices.GetRequiredService<HlsSegmentStore>();
167 foreach (var cam in cameras)
168 hlsStore.ClearCameraHls(user.Id, cam.Id);
169
170 return Results.Ok(new AgentLoginResponse(token, user.Id, cameras));
171});
172
173// 에이전트 → 서버: 로컬 카메라 목록 동기화 (에이전트가 보유한 카메라를 서버 DB에 등록)
174app.MapPost("/api/agent/sync-cameras",
176{
177 var user = tokens.Validate(req.Token);
178 if (user is null) return Results.Unauthorized();
179
180 var existing = repo.GetAll().Where(c => c.TenantId == user.Id).ToDictionary(c => c.Id);
181 int order = existing.Count > 0 ? existing.Values.Max(c => c.DisplayOrder) + 1 : 1;
182
183 foreach (var cam in req.Cameras)
184 {
186 {
187 Id = cam.Id,
188 TenantId = user.Id,
189 Name = cam.Name,
190 Host = cam.Host,
191 RtspUrl = cam.RtspUrl,
192 DisplayOrder = existing.ContainsKey(cam.Id)
193 ? existing[cam.Id].DisplayOrder
194 : order++,
195 Enabled = true,
196 AutoReconnect = cam.AutoReconnect,
197 IsPublic = cam.IsPublic
198 };
199
200 if (existing.ContainsKey(cam.Id))
201 await repo.UpdateAsync(device);
202 else
203 await repo.AddAsync(device);
204 }
205
206 return Results.Ok();
207});
208
209// 에이전트 → 서버: HLS 세그먼트 업로드
210app.MapPost("/api/agent/hls/{token}/{cameraId}/{filename}",
211 async (string token, string cameraId, string filename,
212 HttpContext ctx, AgentTokenService tokens, HlsSegmentStore store,
213 ILoggerFactory loggerFactory) =>
214{
215 var log = loggerFactory.CreateLogger("AgentPush");
216 var user = tokens.Validate(token);
217 if (user is null)
218 {
219 log.LogWarning("[Push] 토큰 인증 실패: {cam}/{file}", cameraId, filename);
220 return Results.Unauthorized();
221 }
222 await store.SaveSegmentAsync(user.Id, cameraId, filename, ctx.Request.Body);
223 log.LogDebug("[Push] OK {cam}/{file} ({bytes}B)", cameraId, filename, ctx.Request.ContentLength ?? -1);
224 return Results.Ok();
225});
226
227// 브라우저 → 서버: HLS 재생
228app.MapGet("/hls/{tenantId}/{cameraId}/{filename}",
229 (string tenantId, string cameraId, string filename, HlsSegmentStore store,
230 HttpContext ctx,
231 ILoggerFactory loggerFactory) =>
232{
233 var log = loggerFactory.CreateLogger("HlsServe");
234 var (stream, ct) = store.GetFile(tenantId, cameraId, filename);
235 if (stream is null)
236 {
237 log.LogWarning("[Serve] 404 {cam}/{file}", cameraId, filename);
238 return Results.NotFound();
239 }
240
241 if (filename.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase))
242 {
243 ctx.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate, max-age=0";
244 ctx.Response.Headers.Pragma = "no-cache";
245 ctx.Response.Headers.Expires = "0";
246 }
247 else
248 {
249 ctx.Response.Headers.CacheControl = "public, max-age=60, immutable";
250 }
251
252 return Results.Stream(stream, ct);
253});
254
255// ── OG 이미지 업로드 ──────────────────────────────────────────────────────
256app.MapPost("/api/upload/og-image",
257 async (HttpContext ctx, AgentTokenService tokens, IWebHostEnvironment env) =>
258{
259 // 세션 토큰으로 인증 (쿠키에서 읽기)
260 var sessionToken = ctx.Request.Headers["X-Session-Token"].ToString();
261 if (string.IsNullOrWhiteSpace(sessionToken)) return Results.Unauthorized();
262
263 var sessSvc = ctx.RequestServices.GetRequiredService<VmsSessionService>();
264 var user = sessSvc.ValidateToken(sessionToken);
265 if (user is null) return Results.Unauthorized();
266
267 if (!ctx.Request.HasFormContentType) return Results.BadRequest("multipart form required");
268 var form = await ctx.Request.ReadFormAsync();
269 var file = form.Files.GetFile("file");
270 if (file is null || file.Length == 0) return Results.BadRequest("no file");
271
272 var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
273 if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp" or ".gif"))
274 return Results.BadRequest("허용되지 않는 파일 형식입니다.");
275
276 if (file.Length > 5 * 1024 * 1024) return Results.BadRequest("파일이 너무 큽니다 (최대 5MB).");
277
278 var saveDir = Path.Combine(env.WebRootPath, "images", "og");
279 Directory.CreateDirectory(saveDir);
280
281 var fileName = $"{user.PublicSlug}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}{ext}";
282 var savePath = Path.Combine(saveDir, fileName);
283 await using var fs = File.Create(savePath);
284 await file.CopyToAsync(fs);
285
286 var url = $"https://{ctx.Request.Host}/images/og/{fileName}";
287 return Results.Ok(new { url });
288});
289
290// ── Blazor ────────────────────────────────────────────────────────────────
291app.MapRazorComponents<AppShell>()
292 .AddInteractiveServerRenderMode();
293
294app.Run();
295
296#pragma warning disable CS1587
331static string ResolvePath(string? configuredPath, string fallback)
332{
333 if (string.IsNullOrWhiteSpace(configuredPath))
334 {
335 return fallback;
336 }
337
338 return Path.IsPathRooted(configuredPath)
339 ? configuredPath
340 : Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, configuredPath));
341}
342#pragma warning restore CS1587
343
352record AgentLoginRequest(string Email, string Password);
361record AgentCameraDto(string Id, string Name, string Host, string RtspUrl, bool AutoReconnect, bool IsPublic);
370record AgentLoginResponse(string Token, string TenantId, List<AgentCameraDto> Cameras);
379record AgentSyncRequest(string Token, List<AgentCameraDto> Cameras);
var builder
Definition Program.cs:11
var provider
Definition Program.cs:38
AuthOptions authOptions
Definition Program.cs:14
var app
Definition Program.cs:36
record AgentCameraDto(string Id, string Name, string Host, string RtspUrl, bool AutoReconnect, bool IsPublic)
record AgentSyncRequest(string Token, List< AgentCameraDto > Cameras)
record AgentLoginResponse(string Token, string TenantId, List< AgentCameraDto > Cameras)
record AgentLoginRequest(string Email, string Password)
string usersDbPath
Definition Program.cs:16
static string ResolvePath(string? configuredPath, string fallback)
Definition Program.cs:331
async Task< VmsUser?> FindBySlugAsync(string slug)
async Task<(bool Ok, string Error, VmsUser? User)> LoginAsync(string email, string password)
Task< CameraDevice > AddAsync(CameraDevice camera)
Stream? string ContentType GetFile(string tenantId, string cameraId, string filename)
async Task SaveSegmentAsync(string tenantId, string cameraId, string filename, Stream data)
void ClearCameraHls(string tenantId, string cameraId)