154 lines
6.4 KiB
C#
154 lines
6.4 KiB
C#
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.WebUtilities;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.FileProviders;
|
|
|
|
namespace VideoDownloader.Server;
|
|
|
|
/// <summary>
|
|
/// M0 원격 접근 서버: 데스크톱 앱에 내장되는 Kestrel.
|
|
/// QR 페어링 토큰(Bearer) 인증 + 큐 API + 라이브러리/Range 스트리밍 + 제로 설치 웹 컴패니언.
|
|
/// LAN 직결(계정/클라우드 없음) — MOBILE_PLAN §3 아키텍처.
|
|
/// </summary>
|
|
public sealed class ServerHost : IAsyncDisposable
|
|
{
|
|
private readonly WebApplication _app;
|
|
private readonly string _token;
|
|
private readonly ServerOptions _options;
|
|
|
|
public int Port { get; }
|
|
/// <summary>QR 에 담을 페어링 URL 형식: http://<lan-ip>:<port>/#t=<token></summary>
|
|
public string Token => _token;
|
|
|
|
private ServerHost(WebApplication app, int port, string token, ServerOptions options)
|
|
{
|
|
_app = app;
|
|
Port = port;
|
|
_token = token;
|
|
_options = options;
|
|
}
|
|
|
|
public static async Task<ServerHost> StartAsync(ServerOptions options)
|
|
{
|
|
var token = string.IsNullOrEmpty(options.Token)
|
|
? WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32))
|
|
: options.Token;
|
|
|
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = new[] { "--urls", $"http://0.0.0.0:{(options.Port == 0 ? GetFreePort() : options.Port)}" } });
|
|
foreach (var prov in builder.Services.Where(sd => sd.ServiceType.FullName!.Contains("LoggerProvider")).ToList())
|
|
builder.Services.Remove(prov);
|
|
|
|
var app = builder.Build();
|
|
|
|
// Bearer 토큰 인증(상수시간 비교) — / 와 /api/* 전체 적용
|
|
app.Use(async (ctx, next) =>
|
|
{
|
|
if (!ctx.Request.Path.StartsWithSegments("/api/pair") &&
|
|
(ctx.Request.Path.StartsWithSegments("/api") || ctx.Request.Path.StartsWithSegments("/stream") || ctx.Request.Path.StartsWithSegments("/file")))
|
|
{
|
|
var auth = ctx.Request.Headers.Authorization.ToString();
|
|
var provided = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth[7..].Trim() : "";
|
|
// <video>/<a> 태그는 헤더를 못 실음 → 쿼리 토큰도 허용(LAN 한정)
|
|
if (provided.Length == 0 && ctx.Request.Query.TryGetValue("t", out var q)) provided = q.ToString();
|
|
if (!FixedTimeEquals(provided, token))
|
|
{
|
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
return;
|
|
}
|
|
}
|
|
await next();
|
|
});
|
|
|
|
// ── 페어링 ──
|
|
app.MapPost("/api/pair", async (HttpContext ctx) =>
|
|
{
|
|
var body = await ctx.Request.ReadFromJsonAsync<PairRequest>();
|
|
if (body == null || !FixedTimeEquals(body.Token ?? "", token))
|
|
{
|
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
return;
|
|
}
|
|
await ctx.Response.WriteAsJsonAsync(new { ok = true, device = options.DeviceName });
|
|
});
|
|
|
|
// ── 큐 ──
|
|
var queue = options.Queue;
|
|
app.MapGet("/api/queue", async (HttpContext ctx) =>
|
|
await ctx.Response.WriteAsJsonAsync(await queue.GetItemsAsync()));
|
|
|
|
app.MapPost("/api/queue", async (HttpContext ctx) =>
|
|
{
|
|
var body = await ctx.Request.ReadFromJsonAsync<QueueAddRequest>();
|
|
if (body == null || !Uri.TryCreate(body.Url, UriKind.Absolute, out _))
|
|
{
|
|
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
return;
|
|
}
|
|
var id = await queue.AddAsync(body.Url, body.Title);
|
|
await ctx.Response.WriteAsJsonAsync(new { id });
|
|
});
|
|
|
|
app.MapPost("/api/queue/{id}/pause", async (string id) => { await queue.PauseAsync(id); Results.Ok(); });
|
|
app.MapPost("/api/queue/{id}/resume", async (string id) => { await queue.ResumeAsync(id); Results.Ok(); });
|
|
app.MapPost("/api/queue/{id}/cancel", async (string id) => { await queue.CancelAsync(id); Results.Ok(); });
|
|
|
|
// ── 라이브러리 / 스트리밍 ──
|
|
app.MapGet("/api/library", async (HttpContext ctx) =>
|
|
await ctx.Response.WriteAsJsonAsync(MediaLibrary.List(options.LibraryFolder)));
|
|
|
|
app.MapGet("/stream/{id}", (string id) =>
|
|
{
|
|
var file = MediaLibrary.Find(options.LibraryFolder, id);
|
|
return file == null
|
|
? Results.NotFound()
|
|
: Results.File(file.Value.FullPath, "video/mp4", enableRangeProcessing: true); // 206 Partial Content → 시크 지원
|
|
});
|
|
|
|
app.MapGet("/file/{id}", (string id) =>
|
|
{
|
|
var file = MediaLibrary.Find(options.LibraryFolder, id);
|
|
return file == null ? Results.NotFound() : Results.File(file.Value.FullPath, "application/octet-stream", file.Value.FileName);
|
|
});
|
|
|
|
// ── 웹 컴패니언 (제로 설치 클라이언트) ──
|
|
app.MapGet("/", () => Results.Text(WebCompanion.Html, "text/html; charset=utf-8"));
|
|
|
|
await app.StartAsync();
|
|
var actualPort = options.Port;
|
|
if (actualPort == 0)
|
|
{
|
|
var m = app.Urls.SelectMany(u => System.Text.RegularExpressions.Regex.Matches(u, @":(\d+)"))
|
|
.Select(m2 => int.Parse(m2.Groups[1].Value)).FirstOrDefault();
|
|
actualPort = m;
|
|
}
|
|
return new ServerHost(app, actualPort, token, options);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _app.StopAsync();
|
|
await _app.DisposeAsync();
|
|
}
|
|
|
|
private static int GetFreePort()
|
|
{
|
|
using var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
|
l.Start();
|
|
var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
|
|
l.Stop();
|
|
return port;
|
|
}
|
|
|
|
private static bool FixedTimeEquals(string a, string b)
|
|
{
|
|
var ha = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(a));
|
|
var hb = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(b));
|
|
return CryptographicOperations.FixedTimeEquals(ha, hb);
|
|
}
|
|
|
|
private sealed record PairRequest(string? Token, string? Device);
|
|
private sealed record QueueAddRequest(string Url, string? Title);
|
|
}
|