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 @@
+
-
+
+
+
+
+
@@ -32,10 +37,15 @@
+
+
+
+
+
@@ -51,7 +61,6 @@
-
@@ -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))