1using Dreamine.Identity;
2using Dreamine.Identity.Options;
3using DreamineVMS.Web.Blazor;
8using Microsoft.AspNetCore.Components.Server;
9using Microsoft.AspNetCore.HttpOverrides;
11var
builder = WebApplication.CreateBuilder(args);
12builder.Configuration.AddUserSecrets(
"codemaru-oauth-2ba4e1b2");
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"));
22 .AddInteractiveServerComponents();
24builder.Services.Configure<CircuitOptions>(o => o.DetailedErrors =
true);
38var
provider =
new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider();
39provider.Mappings[
".exe"] =
"application/octet-stream";
40app.UseForwardedHeaders();
41app.UseStaticFiles(
new StaticFileOptions { ContentTypeProvider =
provider });
43app.UseAuthentication();
44app.UseAuthorization();
49app.Use(async (ctx, next) =>
51 var ua = ctx.Request.Headers.UserAgent.ToString();
55 bool looksLikeBrowser = ua.Contains(
"AppleWebKit") &&
56 (ua.Contains(
"Chrome") || ua.Contains(
"Safari") || ua.Contains(
"Mobile"));
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)
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)
72 if (!isCrawler) { await next();
return; }
74 var path = ctx.Request.Path.Value ??
"";
77 if (path ==
"/" || path ==
"")
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($
"""
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}
"/>
98 <body><p><a href="{siteUrl}/
">{title}</a></p></body>
105 var parts = path.Trim(
'/').Split(
'/');
106 if (parts.Length == 2 && parts[1] ==
"live")
109 var userSvc = ctx.RequestServices.GetRequiredService<
VmsUserService>();
111 if (user is not
null)
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"
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"
125 ctx.Response.ContentType =
"text/html; charset=utf-8";
126 await ctx.Response.WriteAsync($
"""
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}
"/>
140 <body><p><a href="{pageUrl}
">{title}</a></p></body>
156 var (ok, _, user) = await users.
LoginAsync(req.Email, req.Password);
157 if (!ok || user is
null)
return Results.Unauthorized();
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))
166 var hlsStore = ctx.RequestServices.GetRequiredService<
HlsSegmentStore>();
167 foreach (var cam
in cameras)
174app.MapPost(
"/api/agent/sync-cameras",
177 var user = tokens.
Validate(req.Token);
178 if (user is
null)
return Results.Unauthorized();
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;
183 foreach (var cam
in req.Cameras)
191 RtspUrl = cam.RtspUrl,
192 DisplayOrder = existing.ContainsKey(cam.Id)
193 ? existing[cam.Id].DisplayOrder
196 AutoReconnect = cam.AutoReconnect,
197 IsPublic = cam.IsPublic
200 if (existing.ContainsKey(cam.Id))
210app.MapPost(
"/api/agent/hls/{token}/{cameraId}/{filename}",
211 async (
string token,
string cameraId,
string filename,
213 ILoggerFactory loggerFactory) =>
215 var log = loggerFactory.CreateLogger(
"AgentPush");
219 log.LogWarning(
"[Push] 토큰 인증 실패: {cam}/{file}", cameraId, filename);
220 return Results.Unauthorized();
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);
228app.MapGet(
"/hls/{tenantId}/{cameraId}/{filename}",
229 (
string tenantId,
string cameraId,
string filename,
HlsSegmentStore store,
231 ILoggerFactory loggerFactory) =>
233 var log = loggerFactory.CreateLogger(
"HlsServe");
234 var (stream, ct) = store.
GetFile(tenantId, cameraId, filename);
237 log.LogWarning(
"[Serve] 404 {cam}/{file}", cameraId, filename);
238 return Results.NotFound();
241 if (filename.EndsWith(
".m3u8", StringComparison.OrdinalIgnoreCase))
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";
249 ctx.Response.Headers.CacheControl =
"public, max-age=60, immutable";
252 return Results.Stream(stream, ct);
256app.MapPost(
"/api/upload/og-image",
260 var sessionToken = ctx.Request.Headers[
"X-Session-Token"].ToString();
261 if (
string.IsNullOrWhiteSpace(sessionToken))
return Results.Unauthorized();
265 if (user is
null)
return Results.Unauthorized();
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");
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(
"허용되지 않는 파일 형식입니다.");
276 if (file.Length > 5 * 1024 * 1024)
return Results.BadRequest(
"파일이 너무 큽니다 (최대 5MB).");
278 var saveDir = Path.Combine(env.WebRootPath,
"images",
"og");
279 Directory.CreateDirectory(saveDir);
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);
286 var url = $
"https://{ctx.Request.Host}/images/og/{fileName}";
287 return Results.Ok(
new { url });
291app.MapRazorComponents<AppShell>()
292 .AddInteractiveServerRenderMode();
296#pragma warning disable CS1587
333 if (
string.IsNullOrWhiteSpace(configuredPath))
338 return Path.IsPathRooted(configuredPath)
340 : Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, configuredPath));
342#pragma warning restore CS1587
361record
AgentCameraDto(
string Id,
string Name,
string Host,
string RtspUrl,
bool AutoReconnect,
bool IsPublic);
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)
static string ResolvePath(string? configuredPath, string fallback)
VmsUser? Validate(string? token)
string Issue(VmsUser user)
VmsUser? ValidateToken(string? token)
async Task< VmsUser?> FindBySlugAsync(string slug)
async Task<(bool Ok, string Error, VmsUser? User)> LoginAsync(string email, string password)
Task UpdateAsync(CameraDevice camera)
IReadOnlyList< CameraDevice > GetAll()
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)