From 4ef20f7dc78f6cb97eae5123741b280f11b431bd Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Tue, 9 Sep 2025 22:16:34 +0800 Subject: [PATCH] [feat] Live --- .idea/.idea.Abyss/.idea/workspace.xml | 15 +- Abyss/Abyss.csproj | 4 - .../Controllers/Media/LiveController.cs | 66 ++++++ .../Controllers/Security/UserController.cs | 23 +++ Abyss/Components/Services/ResourceService.cs | 189 +++++++++++++++--- Abyss/Components/Services/UserService.cs | 8 + Abyss/Components/Static/Helpers.cs | 24 +++ 7 files changed, 293 insertions(+), 36 deletions(-) create mode 100644 Abyss/Components/Controllers/Media/LiveController.cs diff --git a/.idea/.idea.Abyss/.idea/workspace.xml b/.idea/.idea.Abyss/.idea/workspace.xml index fec12a1..d5f002a 100644 --- a/.idea/.idea.Abyss/.idea/workspace.xml +++ b/.idea/.idea.Abyss/.idea/workspace.xml @@ -10,8 +10,13 @@ + - + + + + + @@ -79,7 +88,7 @@ "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", "RunOnceActivity.git.unshallow": "true", "XThreadsFramesViewSplitterKey": "0.30266345", - "git-widget-placeholder": "main", + "git-widget-placeholder": "dev-live", "last_opened_file_path": "/storage/Images/31/summary.json", "node.js.detected.package.eslint": "true", "node.js.detected.package.tslint": "true", diff --git a/Abyss/Abyss.csproj b/Abyss/Abyss.csproj index b27990d..62c35d5 100644 --- a/Abyss/Abyss.csproj +++ b/Abyss/Abyss.csproj @@ -17,8 +17,4 @@ - - - - diff --git a/Abyss/Components/Controllers/Media/LiveController.cs b/Abyss/Components/Controllers/Media/LiveController.cs new file mode 100644 index 0000000..82bd74a --- /dev/null +++ b/Abyss/Components/Controllers/Media/LiveController.cs @@ -0,0 +1,66 @@ +using Abyss.Components.Services; +using Abyss.Components.Static; +using Microsoft.AspNetCore.Mvc; + +namespace Abyss.Components.Controllers.Media; + +[ApiController] +[Route("api/[controller]")] +public class LiveController(ILogger logger, ResourceService rs, ConfigureService config): Controller +{ + public readonly string LiveFolder = Path.Combine(config.MediaRoot, "Live"); + + [HttpPost("{id}")] + public async Task AddLive(string id, string token, string owner) + { + var d = Helpers.SafePathCombine(LiveFolder, [id]); + if (d == null) return StatusCode(403, new { message = "403 Denied" }); + + bool r = await rs.Include(d, token, Ip, owner, "rw,--,--"); + + return r ? Ok("Success") : BadRequest(); + } + + [HttpDelete("{id}")] + public async Task RemoveLive(string id, string token) + { + var d = Helpers.SafePathCombine(LiveFolder, [id]); + if (d == null) + return StatusCode(403, new { message = "403 Denied" }); + + bool r = await rs.Exclude(d, token, Ip); + + return r ? Ok("Success") : BadRequest(); + } + + [HttpGet("{id}/{item}")] + public async Task GetLive(string id, string? token, string item) + { + var d = Helpers.SafePathCombine(LiveFolder, [id, item]); + var f = Helpers.SafePathCombine(LiveFolder, [id]); + if (d == null || f == null) return BadRequest(); + + // TODO: ffplay does not add the m3u8 query parameter in ts requests, so special treatment is given to ts here + // TODO: It should be pointed out that this implementation is not secure and should be modified in subsequent updates + if (d.EndsWith(".ts")) + { + if(System.IO.File.Exists(d)) + return PhysicalFile(d, Helpers.GetContentType(d)); + else + return NotFound(); + } + + if(token == null) + return StatusCode(403, new { message = "403 Denied" }); + + bool r = await rs.Valid(f, token, OperationType.Read, Ip); + if(!r) return StatusCode(403, new { message = "403 Denied" }); + + if(System.IO.File.Exists(d)) + return PhysicalFile(d, Helpers.GetContentType(d)); + else + return NotFound(); + } + + private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; +} \ No newline at end of file diff --git a/Abyss/Components/Controllers/Security/UserController.cs b/Abyss/Components/Controllers/Security/UserController.cs index aedda20..2b54f45 100644 --- a/Abyss/Components/Controllers/Security/UserController.cs +++ b/Abyss/Components/Controllers/Security/UserController.cs @@ -96,6 +96,29 @@ public class UserController(UserService user, ILogger logger) : return Ok("Success"); } + [HttpGet("{user}/open")] + public async Task Open(string user, [FromQuery] string token, [FromQuery] string? bindIp = null) + { + var caller = _user.Validate(token, Ip); + if (caller == null || caller != "root") + { + return StatusCode(403, new { message = "Access forbidden" }); + } + + var target = await _user.QueryUser(user); + if (target == null) + { + return StatusCode(404, new { message = "User not found" }); + } + + var ipToBind = string.IsNullOrWhiteSpace(bindIp) ? Ip : bindIp; + + var t = _user.CreateToken(user, ipToBind, TimeSpan.FromHours(1)); + + _logger.LogInformation("Root created 1h token for {User}, bound to {BindIp}, request from {ReqIp}", user, ipToBind, Ip); + return Ok(new { token = t, user, boundIp = ipToBind }); + } + public static bool IsAlphanumeric(string input) { if (string.IsNullOrEmpty(input)) diff --git a/Abyss/Components/Services/ResourceService.cs b/Abyss/Components/Services/ResourceService.cs index 3fef5f0..280cf26 100644 --- a/Abyss/Components/Services/ResourceService.cs +++ b/Abyss/Components/Services/ResourceService.cs @@ -1,4 +1,3 @@ - // ResourceService.cs using System.Text; @@ -13,8 +12,8 @@ namespace Abyss.Components.Services; public enum OperationType { - Read, // Query, Read - Write, // Write, Delete + Read, // Query, Read + Write, // Write, Delete Security // Chown, Chmod } @@ -25,8 +24,8 @@ public class ResourceService private readonly IMemoryCache _cache; private readonly UserService _user; private readonly SQLiteAsyncConnection _database; - - private static readonly Regex PermissionRegex = + + private static readonly Regex PermissionRegex = new(@"^([r-][w-]),([r-][w-]),([r-][w-])$", RegexOptions.Compiled); public ResourceService(ILogger logger, ConfigureService config, IMemoryCache cache, @@ -41,9 +40,16 @@ public class ResourceService _database.CreateTableAsync().Wait(); var tasksPath = Helpers.SafePathCombine(_config.MediaRoot, "Tasks"); - if(tasksPath != null) + if (tasksPath != null) + { InsertRaRow(tasksPath, "root", "rw,r-,r-", true).Wait(); + } + var livePath = Helpers.SafePathCombine(_config.MediaRoot, "Live"); + if (livePath != null) + { + InsertRaRow(livePath, "root", "rw,r-,r-", true).Wait(); + } } // Create UID only for resources, without considering advanced hash security such as adding salt @@ -59,16 +65,16 @@ public class ResourceService // Path is abs path here, due to Helpers.SafePathCombine if (!path.StartsWith(Path.GetFullPath(_config.MediaRoot), StringComparison.OrdinalIgnoreCase)) return false; - + path = Path.GetRelativePath(_config.MediaRoot, path); - + string? username = _user.Validate(token, ip); if (username == null) { // No permission granted for invalid tokens _logger.LogError($"Invalid token: {token}"); return false; - } + } User? user = await _user.QueryUser(username); if (user == null || user.Name != username) @@ -76,7 +82,7 @@ public class ResourceService _logger.LogError($"Verification failed: {token}"); return false; // Two-factor authentication } - + var parts = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) .Where(p => !string.IsNullOrEmpty(p)) .ToArray(); @@ -107,7 +113,7 @@ public class ResourceService _logger.LogError($"Permission check failed: User: {username}, Resource: {path}, Type: {type.ToString()} "); return false; } - + var l = await CheckPermission(user, ra, type); if (!l) { @@ -120,8 +126,8 @@ public class ResourceService private async Task CheckPermission(User? user, ResourceAttribute? ra, OperationType type) { if (user == null || ra == null) return false; - - if(!PermissionRegex.IsMatch(ra.Permission)) return false; + + if (!PermissionRegex.IsMatch(ra.Permission)) return false; var perms = ra.Permission.Split(','); if (perms.Length != 3) return false; @@ -154,7 +160,7 @@ public class ResourceService public async Task Query(string path, string token, string ip) { - if(!await Valid(path, token, OperationType.Read, ip)) + if (!await Valid(path, token, OperationType.Read, ip)) return null; if (Helpers.GetPathType(path) != PathType.Directory) @@ -183,9 +189,11 @@ public class ResourceService var requester = _user.Validate(token, ip); if (requester != "root") { - _logger.LogWarning($"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to initialize resources."); + _logger.LogWarning( + $"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to initialize resources."); return false; } + debug: // 2. Validation: Ensure the target path and owner are valid if (!Directory.Exists(path)) @@ -212,8 +220,9 @@ public class ResourceService { var currentPath = Path.GetRelativePath(_config.MediaRoot, p); var uid = Uid(currentPath); - var existing = await _database.Table().Where(r => r.Uid == uid).FirstOrDefaultAsync(); - + var existing = await _database.Table().Where(r => r.Uid == uid) + .FirstOrDefaultAsync(); + // If it's not in the database, add it to our list for batch insertion if (existing == null) { @@ -231,11 +240,13 @@ public class ResourceService if (newResources.Any()) { await _database.InsertAllAsync(newResources); - _logger.LogInformation($"Successfully initialized {newResources.Count} new resources under '{path}' for user '{username}'."); + _logger.LogInformation( + $"Successfully initialized {newResources.Count} new resources under '{path}' for user '{username}'."); } else { - _logger.LogInformation($"No new resources to initialize under '{path}'. All items already exist in the database."); + _logger.LogInformation( + $"No new resources to initialize under '{path}'. All items already exist in the database."); } return true; @@ -256,12 +267,132 @@ public class ResourceService { throw new NotImplementedException(); } + + public async Task Exclude(string path, string token, string ip) + { + var requester = _user.Validate(token, ip); + if (requester != "root") + { + _logger.LogWarning($"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to exclude resource '{path}'."); + return false; + } + + try + { + var relPath = Path.GetRelativePath(_config.MediaRoot, path); + var uid = Uid(relPath); + + var resource = await _database.Table().Where(r => r.Uid == uid).FirstOrDefaultAsync(); + if (resource == null) + { + _logger.LogError($"Exclude failed: Resource '{relPath}' not found in database."); + return false; + } + + var deleted = await _database.DeleteAsync(resource); + if (deleted > 0) + { + _logger.LogInformation($"Successfully excluded resource '{relPath}' from management."); + return true; + } + else + { + _logger.LogError($"Failed to exclude resource '{relPath}' from database."); + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error excluding resource '{path}'."); + return false; + } + } + + public async Task Include(string path, string token, string ip, string owner, string permission) + { + var requester = _user.Validate(token, ip); + if (requester != "root") + { + _logger.LogWarning( + $"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to include resource '{path}'."); + return false; + } + + if (!PermissionRegex.IsMatch(permission)) + { + _logger.LogError($"Invalid permission format: {permission}"); + return false; + } + + var ownerUser = await _user.QueryUser(owner); + if (ownerUser == null) + { + _logger.LogError($"Include failed: Owner user '{owner}' does not exist."); + return false; + } + + try + { + var relPath = Path.GetRelativePath(_config.MediaRoot, path); + var uid = Uid(relPath); + + var existing = await _database.Table().Where(r => r.Uid == uid).FirstOrDefaultAsync(); + if (existing != null) + { + _logger.LogError($"Include failed: Resource '{relPath}' already exists in database."); + return false; + } + + var newResource = new ResourceAttribute + { + Uid = uid, + Name = relPath, + Owner = owner, + Permission = permission + }; + + var inserted = await _database.InsertAsync(newResource); + if (inserted > 0) + { + _logger.LogInformation( + $"Successfully included '{relPath}' into resource management (Owner={owner}, Permission={permission})."); + return true; + } + else + { + _logger.LogError($"Failed to include resource '{relPath}' into database."); + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error including resource '{path}'."); + return false; + } + } + + public async Task Exists(string path) + { + try + { + var relPath = Path.GetRelativePath(_config.MediaRoot, path); + var uid = Uid(relPath); + + var resource = await _database.Table().Where(r => r.Uid == uid).FirstOrDefaultAsync(); + return resource != null; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error checking existence of resource '{path}'."); + return false; + } + } public async Task Chmod(string path, string token, string permission, string ip) { - if(!await Valid(path, token, OperationType.Security, ip)) + if (!await Valid(path, token, OperationType.Security, ip)) return false; - + // Validate the permission format using the existing regex if (!PermissionRegex.IsMatch(permission)) { @@ -274,7 +405,7 @@ public class ResourceService path = Path.GetRelativePath(_config.MediaRoot, path); var uid = Uid(path); var resource = await _database.Table().Where(r => r.Uid == uid).FirstOrDefaultAsync(); - + if (resource == null) { _logger.LogError($"Resource not found: {path}"); @@ -283,7 +414,7 @@ public class ResourceService resource.Permission = permission; var rowsAffected = await _database.UpdateAsync(resource); - + if (rowsAffected > 0) { _logger.LogInformation($"Successfully changed permissions for '{path}' to '{permission}'"); @@ -305,9 +436,9 @@ public class ResourceService public async Task Chown(string path, string token, string owner, string ip) { - if(!await Valid(path, token, OperationType.Security, ip)) + if (!await Valid(path, token, OperationType.Security, ip)) return false; - + // Validate that the new owner exists var newOwner = await _user.QueryUser(owner); if (newOwner == null) @@ -321,7 +452,7 @@ public class ResourceService path = Path.GetRelativePath(_config.MediaRoot, path); var uid = Uid(path); var resource = await _database.Table().Where(r => r.Uid == uid).FirstOrDefaultAsync(); - + if (resource == null) { _logger.LogError($"Resource not found: {path}"); @@ -330,7 +461,7 @@ public class ResourceService resource.Owner = owner; var rowsAffected = await _database.UpdateAsync(resource); - + if (rowsAffected > 0) { _logger.LogInformation($"Successfully changed ownership of '{path}' to '{owner}'"); @@ -356,9 +487,9 @@ public class ResourceService _logger.LogError($"Invalid permission format: {permission}"); return false; } - + var path = Path.GetRelativePath(_config.MediaRoot, fullPath); - + if (update) return await _database.InsertOrReplaceAsync(new ResourceAttribute() { diff --git a/Abyss/Components/Services/UserService.cs b/Abyss/Components/Services/UserService.cs index 8e150cb..b45a6dc 100644 --- a/Abyss/Components/Services/UserService.cs +++ b/Abyss/Components/Services/UserService.cs @@ -179,4 +179,12 @@ public class UserService var algorithm = SignatureAlgorithm.Ed25519; return algorithm.Verify(publicKey, data, signature); } + + public string CreateToken(string user, string ip, TimeSpan lifetime) + { + var token = GenerateRandomAsciiString(64); + _cache.Set(token, $"{user}@{ip}", DateTimeOffset.Now.Add(lifetime)); + _logger.LogInformation($"Created token for {user}@{ip}, valid {lifetime.TotalMinutes} minutes"); + return token; + } } \ No newline at end of file diff --git a/Abyss/Components/Static/Helpers.cs b/Abyss/Components/Static/Helpers.cs index 849be90..659e06a 100644 --- a/Abyss/Components/Static/Helpers.cs +++ b/Abyss/Components/Static/Helpers.cs @@ -1,11 +1,35 @@ using System.Globalization; using System.Text.RegularExpressions; +using Microsoft.AspNetCore.StaticFiles; namespace Abyss.Components.Static; public static class Helpers { + private static readonly FileExtensionContentTypeProvider _provider = InitProvider(); + + private static FileExtensionContentTypeProvider InitProvider() + { + var provider = new FileExtensionContentTypeProvider(); + + provider.Mappings[".m3u8"] = "application/vnd.apple.mpegurl"; + provider.Mappings[".ts"] = "video/mp2t"; + provider.Mappings[".mpd"] = "application/dash+xml"; + + return provider; + } + + public static string GetContentType(string path) + { + if (_provider.TryGetContentType(path, out var contentType)) + { + return contentType; + } + + return "application/octet-stream"; + } + public static string? SafePathCombine(string basePath, params string[] pathParts) { if (string.IsNullOrWhiteSpace(basePath))