[feat] Video system optimization
This commit is contained in:
22
.idea/.idea.Abyss/.idea/workspace.xml
generated
22
.idea/.idea.Abyss/.idea/workspace.xml
generated
@@ -11,6 +11,14 @@
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="bf317275-3039-49bb-a475-725a800a0cce" name="Changes" comment="">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/.idea.Abyss/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.Abyss/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/ImageController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/ImageController.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/LiveController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/LiveController.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/VideoController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/VideoController.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Abyss/Components/Controllers/Security/RootController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Security/RootController.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Abyss/Components/Controllers/Task/TaskController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Task/TaskController.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Abyss/Components/Services/ResourceService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Services/ResourceService.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Abyss/Toolkits/update-video.py" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Toolkits/update-video.py" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -33,15 +41,14 @@
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/7598e47d5cdf4107ba88f8220720fdc89000/a6/79d67871/xxHash128.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/f09ccaeb94c34c2299acd3efee0facee1a400/81/137b58b4/Key.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/AbyssController.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Media/LiveController.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Media/LiveController.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Media/LiveController.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Middleware/BadRequestExceptionMiddleware.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Security/RootController.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/RootController.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/RootController.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/UserController.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/UserController.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/UserController.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/UserController.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/UserController.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/UserController.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Task/TaskController.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/AbyssService.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/ConfigureService.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
@@ -221,6 +228,9 @@
|
||||
<workItem from="1758040188148" duration="1000" />
|
||||
<workItem from="1758049713959" duration="86000" />
|
||||
<workItem from="1758084310862" duration="17701000" />
|
||||
<workItem from="1758121232981" duration="69000" />
|
||||
<workItem from="1758279286341" duration="6796000" />
|
||||
<workItem from="1758303096075" duration="2017000" />
|
||||
</task>
|
||||
<servers />
|
||||
</component>
|
||||
|
||||
@@ -13,7 +13,7 @@ using Task = System.Threading.Tasks.Task;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ImageController(ILogger<ImageController> logger, ResourceService rs, ConfigureService config) : BaseController
|
||||
public class ImageController(ResourceService rs, ConfigureService config) : BaseController
|
||||
{
|
||||
public readonly string ImageFolder = Path.Combine(config.MediaRoot, "Images");
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Abyss.Components.Controllers.Media;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class LiveController(ILogger<LiveController> logger, ResourceService rs, ConfigureService config): BaseController
|
||||
public class LiveController(ResourceService rs, ConfigureService config): BaseController
|
||||
{
|
||||
public readonly string LiveFolder = Path.Combine(config.MediaRoot, "Live");
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
using Abyss.Components.Services;
|
||||
using Abyss.Components.Static;
|
||||
using Abyss.Components.Tools;
|
||||
using Abyss.Model;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Abyss.Components.Controllers.Media;
|
||||
@@ -12,7 +12,8 @@ using Task = System.Threading.Tasks.Task;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class VideoController(ILogger<VideoController> logger, ResourceService rs, ConfigureService config) : BaseController
|
||||
public class VideoController(ILogger<VideoController> logger, ResourceService rs, ConfigureService config)
|
||||
: BaseController
|
||||
{
|
||||
private ILogger<VideoController> _logger = logger;
|
||||
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
|
||||
@@ -21,7 +22,7 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
|
||||
public async Task<IActionResult> InitAsync(string token, string owner)
|
||||
{
|
||||
var r = await rs.Initialize(VideoFolder, token, owner, Ip);
|
||||
if(r) return Ok(r);
|
||||
if (r) return Ok(r);
|
||||
return StatusCode(403, new { message = "403 Denied" });
|
||||
}
|
||||
|
||||
@@ -30,7 +31,7 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
|
||||
{
|
||||
var r = (await rs.Query(VideoFolder, token, Ip))?.SortLikeWindows();
|
||||
|
||||
if(r == null)
|
||||
if (r == null)
|
||||
return StatusCode(401, new { message = "Unauthorized" });
|
||||
|
||||
return Ok(r);
|
||||
@@ -44,18 +45,15 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
|
||||
var r = await rs.Query(d, token, Ip);
|
||||
if (r == null) return StatusCode(401, new { message = "Unauthorized" });
|
||||
|
||||
var rv = r.Select(x =>
|
||||
{
|
||||
return Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"]);
|
||||
}).ToArray();
|
||||
var rv = r.Select(x => { return Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"]); }).ToArray();
|
||||
|
||||
for (int i = 0; i < rv.Length; i++)
|
||||
{
|
||||
if(rv[i] == null) continue;
|
||||
if (rv[i] == null) continue;
|
||||
rv[i] = await System.IO.File.ReadAllTextAsync(rv[i] ?? "");
|
||||
}
|
||||
|
||||
var sv = rv.Where(x => x!=null).Select(x => x ?? "")
|
||||
var sv = rv.Where(x => x != null).Select(x => x ?? "")
|
||||
.Select(x => JsonConvert.DeserializeObject<Video>(x)).ToArray();
|
||||
|
||||
|
||||
@@ -75,13 +73,14 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
|
||||
}
|
||||
|
||||
[HttpPost("{klass}/bulkquery")]
|
||||
public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id, [FromRoute] string klass)
|
||||
public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id,
|
||||
[FromRoute] string klass)
|
||||
{
|
||||
var db = id.Select(x => Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"])).ToArray();
|
||||
if(db.Any(x => x == null))
|
||||
if (db.Any(x => x == null))
|
||||
return BadRequest();
|
||||
|
||||
if(!await rs.GetAll(db!, token, Ip))
|
||||
if (!await rs.GetAll(db!, token, Ip))
|
||||
return StatusCode(403, new { message = "403 Denied" });
|
||||
|
||||
var rc = db.Select(x => System.IO.File.ReadAllTextAsync(x!)).ToArray();
|
||||
@@ -115,14 +114,96 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
|
||||
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
[HttpGet("{klass}/{id}/subtitle")]
|
||||
public async Task<IActionResult> Subtitle(string klass, string id, string token)
|
||||
{
|
||||
var folder = Helpers.SafePathCombine(VideoFolder, new[] { klass, id });
|
||||
if (folder == null)
|
||||
return StatusCode(403, new { message = "403 Denied" });
|
||||
|
||||
string? subtitlePath = null;
|
||||
|
||||
try
|
||||
{
|
||||
var preferred = Path.Combine(folder, "subtitle.ass");
|
||||
if (System.IO.File.Exists(preferred))
|
||||
{
|
||||
subtitlePath = preferred;
|
||||
}
|
||||
else
|
||||
{
|
||||
subtitlePath = Directory.EnumerateFiles(folder, "*.ass")
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
return NotFound(new { message = "video folder not found" });
|
||||
}
|
||||
|
||||
if (subtitlePath == null)
|
||||
return NotFound(new { message = "subtitle not found" });
|
||||
|
||||
var r = await rs.Get(subtitlePath, token, Ip);
|
||||
if (!r)
|
||||
return StatusCode(403, new { message = "403 Denied" });
|
||||
|
||||
return PhysicalFile(subtitlePath, "text/x-ssa", enableRangeProcessing: false);
|
||||
}
|
||||
|
||||
[HttpGet("{klass}/{id}/av")]
|
||||
public async Task<IActionResult> Av(string klass, string id, string token)
|
||||
{
|
||||
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "video.mp4"]);
|
||||
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
||||
var folder = Helpers.SafePathCombine(VideoFolder, new[] { klass, id });
|
||||
if (folder == null) return StatusCode(403, new { message = "403 Denied" });
|
||||
|
||||
var r = await rs.Get(d, token, Ip);
|
||||
var allowedExt = new[] { ".mp4", ".mkv", ".webm", ".mov", ".ogg" };
|
||||
|
||||
string? videoPath = null;
|
||||
|
||||
foreach (var ext in allowedExt)
|
||||
{
|
||||
var p = Path.Combine(folder, "video" + ext);
|
||||
if (System.IO.File.Exists(p))
|
||||
{
|
||||
videoPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (videoPath == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
videoPath = Directory.EnumerateFiles(folder)
|
||||
.FirstOrDefault(f => allowedExt.Contains(Path.GetExtension(f).ToLowerInvariant()));
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
return NotFound(new { message = "video folder not found" });
|
||||
}
|
||||
}
|
||||
|
||||
if (videoPath == null) return NotFound(new { message = "video not found" });
|
||||
|
||||
var r = await rs.Get(videoPath, token, Ip);
|
||||
if (!r) return StatusCode(403, new { message = "403 Denied" });
|
||||
return PhysicalFile(d, "video/mp4", enableRangeProcessing: true);
|
||||
|
||||
var provider = new FileExtensionContentTypeProvider();
|
||||
if (!provider.TryGetContentType(videoPath, out var contentType))
|
||||
{
|
||||
var ext = Path.GetExtension(videoPath).ToLowerInvariant();
|
||||
contentType = ext switch
|
||||
{
|
||||
".mkv" => "video/x-matroska",
|
||||
".mp4" => "video/mp4",
|
||||
".webm" => "video/webm",
|
||||
".mov" => "video/quicktime",
|
||||
".ogg" => "video/ogg",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
}
|
||||
|
||||
return PhysicalFile(videoPath, contentType, enableRangeProcessing: true);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,139 @@
|
||||
using System.Text;
|
||||
using Abyss.Components.Services;
|
||||
using Abyss.Components.Static;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Abyss.Components.Controllers.Security;
|
||||
|
||||
public class RootController
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class RootController(ILogger<RootController> logger, UserService userService, ResourceService resourceService)
|
||||
: BaseController
|
||||
{
|
||||
[HttpPost("chmod")]
|
||||
public async Task<IActionResult> Chmod(string token, string path, string permission)
|
||||
{
|
||||
logger.LogInformation("Chmod method called with path: {Path}, permission: {Permission}", path, permission);
|
||||
|
||||
if (userService.Validate(token, Ip) != 1)
|
||||
{
|
||||
logger.LogInformation("Chmod authorization failed for token: {Token}", token);
|
||||
return StatusCode(401, "Unauthorized");
|
||||
}
|
||||
|
||||
bool r = await resourceService.Chmod(path, token, permission, Ip, true);
|
||||
logger.LogInformation("Chmod operation completed with result: {Result}", r);
|
||||
return r ? Ok() : StatusCode(502);
|
||||
}
|
||||
|
||||
[HttpPost("chown")]
|
||||
public async Task<IActionResult> Chown(string token, string path, int owner)
|
||||
{
|
||||
logger.LogInformation("Chown method called with path: {Path}, owner: {Owner}", path, owner);
|
||||
|
||||
if (userService.Validate(token, Ip) != 1)
|
||||
{
|
||||
logger.LogInformation("Chown authorization failed for token: {Token}", token);
|
||||
return StatusCode(401, "Unauthorized");
|
||||
}
|
||||
|
||||
bool r = await resourceService.Chown(path, token, owner, Ip, true);
|
||||
logger.LogInformation("Chown operation completed with result: {Result}", r);
|
||||
return r ? Ok() : StatusCode(502);
|
||||
}
|
||||
|
||||
[HttpGet("ls")]
|
||||
public async Task<IActionResult> Ls(string token, string path)
|
||||
{
|
||||
logger.LogInformation("Ls method called with path: {Path}", path);
|
||||
|
||||
if (userService.Validate(token, Ip) != 1)
|
||||
{
|
||||
logger.LogInformation("Ls authorization failed for token: {Token}", token);
|
||||
return StatusCode(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
logger.LogInformation("Ls method received empty path parameter");
|
||||
return BadRequest("path is required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
logger.LogInformation("Resolved full path: {FullPath}", fullPath);
|
||||
|
||||
if (!Directory.Exists(fullPath))
|
||||
{
|
||||
logger.LogInformation("Directory does not exist: {FullPath}", fullPath);
|
||||
return BadRequest("Path does not exist or is not a directory");
|
||||
}
|
||||
|
||||
var entries = Directory.EnumerateFileSystemEntries(fullPath, "*", SearchOption.TopDirectoryOnly);
|
||||
logger.LogInformation("Found {Count} entries in directory", entries.Count());
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filename = Path.GetFileName(entry);
|
||||
var isDir = Directory.Exists(entry);
|
||||
|
||||
var ra = await resourceService.GetAttribute(entry);
|
||||
|
||||
var ownerId = ra?.Owner ?? -1;
|
||||
var uid = ra?.Uid ?? string.Empty;
|
||||
var permRaw = ra?.Permission ?? "--,--,--";
|
||||
|
||||
var permStr = ConvertToLsPerms(permRaw, isDir);
|
||||
|
||||
sb.AppendLine($"{permStr} {ownerId,5} {uid} {filename}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogInformation("Error processing entry {Entry}: {ErrorMessage}", entry, ex.Message);
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Ls operation completed successfully");
|
||||
return Content(sb.ToString(), "text/plain; charset=utf-8");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogInformation("Ls operation failed with error: {ErrorMessage}", ex.Message);
|
||||
return StatusCode(500, "Internal Server Error");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConvertToLsPerms(string permRaw, bool isDirectory)
|
||||
{
|
||||
// expects format like "rw,r-,r-"
|
||||
if (string.IsNullOrEmpty(permRaw))
|
||||
permRaw = "--,--,--";
|
||||
|
||||
var parts = permRaw.Split(',', StringSplitOptions.None);
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return (isDirectory ? 'd' : '-') + "---------";
|
||||
}
|
||||
|
||||
string makeTriplet(string token)
|
||||
{
|
||||
if (token.Length < 2) token = "--";
|
||||
var r = token.Length > 0 && token[0] == 'r' ? 'r' : '-';
|
||||
var w = token.Length > 1 && token[1] == 'w' ? 'w' : '-';
|
||||
var x = '-'; // we don't manage execute bits in current model
|
||||
return $"{r}{w}{x}";
|
||||
}
|
||||
|
||||
var owner = makeTriplet(parts[0]);
|
||||
var group = makeTriplet(parts[1]);
|
||||
var other = makeTriplet(parts[2]);
|
||||
|
||||
return (isDirectory ? 'd' : '-') + owner + group + other;
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,10 @@ namespace Abyss.Components.Controllers.Security;
|
||||
[EnableRateLimiting("Fixed")]
|
||||
public class UserController(UserService userService, ILogger<UserController> logger) : BaseController
|
||||
{
|
||||
private readonly ILogger<UserController> _logger = logger;
|
||||
private readonly UserService _userService = userService;
|
||||
|
||||
[HttpGet("{user}")]
|
||||
public async Task<IActionResult> Challenge(string user)
|
||||
{
|
||||
var c = await _userService.Challenge(user);
|
||||
var c = await userService.Challenge(user);
|
||||
if (c == null)
|
||||
return StatusCode(403, new { message = "Access forbidden" });
|
||||
|
||||
@@ -30,7 +27,7 @@ public class UserController(UserService userService, ILogger<UserController> log
|
||||
[HttpPost("{user}")]
|
||||
public async Task<IActionResult> Challenge(string user, [FromBody] ChallengeResponse response)
|
||||
{
|
||||
var r = await _userService.Verify(user, response.Response, Ip);
|
||||
var r = await userService.Verify(user, response.Response, Ip);
|
||||
if (r == null)
|
||||
return StatusCode(403, new { message = "Access forbidden" });
|
||||
return Ok(r);
|
||||
@@ -39,7 +36,7 @@ public class UserController(UserService userService, ILogger<UserController> log
|
||||
[HttpPost("validate")]
|
||||
public IActionResult Validate(string token)
|
||||
{
|
||||
var u = _userService.Validate(token, Ip);
|
||||
var u = userService.Validate(token, Ip);
|
||||
if (u == -1)
|
||||
{
|
||||
return StatusCode(401, new { message = "Invalid" });
|
||||
@@ -51,13 +48,13 @@ public class UserController(UserService userService, ILogger<UserController> log
|
||||
[HttpPost("destroy")]
|
||||
public IActionResult Destroy(string token)
|
||||
{
|
||||
var u = _userService.Validate(token, Ip);
|
||||
var u = userService.Validate(token, Ip);
|
||||
if (u == -1)
|
||||
{
|
||||
return StatusCode(401, new { message = "Invalid" });
|
||||
}
|
||||
|
||||
_userService.Destroy(token);
|
||||
userService.Destroy(token);
|
||||
return Ok("Success");
|
||||
}
|
||||
|
||||
@@ -65,12 +62,12 @@ public class UserController(UserService userService, ILogger<UserController> log
|
||||
public async Task<IActionResult> Create(string user, [FromBody] UserCreating creating)
|
||||
{
|
||||
// Valid token
|
||||
var r = await _userService.Verify(user, creating.Response, Ip);
|
||||
var r = await userService.Verify(user, creating.Response, Ip);
|
||||
if (r == null)
|
||||
return StatusCode(403, new { message = "Denied" });
|
||||
|
||||
// User exists ?
|
||||
var cu = await _userService.QueryUser(creating.Name);
|
||||
var cu = await userService.QueryUser(creating.Name);
|
||||
if (cu != null)
|
||||
return StatusCode(403, new { message = "Denied" });
|
||||
|
||||
@@ -79,11 +76,11 @@ public class UserController(UserService userService, ILogger<UserController> log
|
||||
return StatusCode(403, new { message = "Denied" });
|
||||
|
||||
// Valid parent && Privilege
|
||||
var ou = await _userService.QueryUser(_userService.Validate(r, Ip));
|
||||
var ou = await userService.QueryUser(userService.Validate(r, Ip));
|
||||
if (creating.Privilege > ou?.Privilege || ou == null)
|
||||
return StatusCode(403, new { message = "Denied" });
|
||||
|
||||
await _userService.CreateUser(new User
|
||||
await userService.CreateUser(new User
|
||||
{
|
||||
Username = creating.Name,
|
||||
ParentId = ou.Uuid,
|
||||
@@ -91,20 +88,20 @@ public class UserController(UserService userService, ILogger<UserController> log
|
||||
PublicKey = creating.PublicKey,
|
||||
});
|
||||
|
||||
_userService.Destroy(r);
|
||||
userService.Destroy(r);
|
||||
return Ok("Success");
|
||||
}
|
||||
|
||||
[HttpGet("{user}/open")]
|
||||
public async Task<IActionResult> Open(string user, [FromQuery] string token, [FromQuery] string? bindIp = null)
|
||||
{
|
||||
var caller = _userService.Validate(token, Ip);
|
||||
var caller = userService.Validate(token, Ip);
|
||||
if (caller != 1)
|
||||
{
|
||||
return StatusCode(403, new { message = "Access forbidden" });
|
||||
}
|
||||
|
||||
var target = await _userService.QueryUser(user);
|
||||
var target = await userService.QueryUser(user);
|
||||
if (target == null)
|
||||
{
|
||||
return StatusCode(404, new { message = "User not found" });
|
||||
@@ -112,9 +109,9 @@ public class UserController(UserService userService, ILogger<UserController> log
|
||||
|
||||
var ipToBind = string.IsNullOrWhiteSpace(bindIp) ? Ip : bindIp;
|
||||
|
||||
var t = _userService.CreateToken(target.Uuid, ipToBind, TimeSpan.FromHours(1));
|
||||
var t = userService.CreateToken(target.Uuid, ipToBind, TimeSpan.FromHours(1));
|
||||
|
||||
_logger.LogInformation("Root created 1h token for {User}, bound to {BindIp}, request from {ReqIp}", user,
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace Abyss.Components.Controllers.Task;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class TaskController(ILogger<TaskController> logger, ConfigureService config, TaskService taskService) : Controller
|
||||
public class TaskController(ConfigureService config, TaskService taskService) : Controller
|
||||
{
|
||||
public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks");
|
||||
|
||||
|
||||
@@ -55,6 +55,204 @@ public class ResourceService
|
||||
return Convert.ToBase64String(r);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, bool>> ValidAny(string[] paths, string token, OperationType type, string ip)
|
||||
{
|
||||
var result = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (paths.Length == 0)
|
||||
return result; // empty input -> empty result
|
||||
|
||||
// Normalize media root
|
||||
var mediaRootFull = Path.GetFullPath(_config.MediaRoot);
|
||||
|
||||
// Prepare normalized full paths and early-check outside-media-root
|
||||
var fullPaths = new List<string>(paths.Length);
|
||||
foreach (var p in paths)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(p);
|
||||
// record normalized path as key
|
||||
if (!full.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError($"Path outside media root or null: {p}");
|
||||
result[full] = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
fullPaths.Add(full);
|
||||
// initialize to false; will set true when all checks pass
|
||||
result[full] = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// malformed path -> mark false and continue
|
||||
_logger.LogError(ex, $"Invalid path encountered in ValidAny: {p}");
|
||||
try
|
||||
{
|
||||
result[Path.GetFullPath(p)] = false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fullPaths.Count == 0)
|
||||
return result;
|
||||
|
||||
// Validate token and user once
|
||||
int uuid = _user.Validate(token, ip);
|
||||
if (uuid == -1)
|
||||
{
|
||||
_logger.LogError($"Invalid token: {token}");
|
||||
// all previously-initialized keys remain false
|
||||
return result;
|
||||
}
|
||||
|
||||
User? user = await _user.QueryUser(uuid);
|
||||
if (user == null || user.Uuid != uuid)
|
||||
{
|
||||
_logger.LogError($"Verification failed: {token}");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build mapping: for each input path -> list of required (uid, op)
|
||||
// Also build uid -> set of ops needed overall for batching
|
||||
var pathToReqs = new Dictionary<string, List<(string uid, OperationType op)>>(StringComparer.OrdinalIgnoreCase);
|
||||
var uidToOps = new Dictionary<string, HashSet<OperationType>>(StringComparer.OrdinalIgnoreCase);
|
||||
var uidToExampleRelPath = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var full in fullPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
// rel path relative to media root for Uid calculation
|
||||
var rel = Path.GetRelativePath(_config.MediaRoot, full);
|
||||
|
||||
var parts = rel
|
||||
.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
|
||||
StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToArray();
|
||||
|
||||
var reqs = new List<(string uid, OperationType op)>();
|
||||
|
||||
// parents: each prefix requires Read
|
||||
for (int i = 0; i < parts.Length - 1; i++)
|
||||
{
|
||||
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
|
||||
var uidDir = Uid(subPath);
|
||||
reqs.Add((uidDir, OperationType.Read));
|
||||
|
||||
if (!uidToOps.TryGetValue(uidDir, out var ops))
|
||||
{
|
||||
ops = new HashSet<OperationType>();
|
||||
uidToOps[uidDir] = ops;
|
||||
uidToExampleRelPath[uidDir] = subPath;
|
||||
}
|
||||
|
||||
ops.Add(OperationType.Read);
|
||||
}
|
||||
|
||||
// resource itself requires requested 'type'
|
||||
var resourcePath = (parts.Length == 0) ? string.Empty : Path.Combine(parts);
|
||||
var uidRes = Uid(resourcePath);
|
||||
reqs.Add((uidRes, type));
|
||||
|
||||
if (!uidToOps.TryGetValue(uidRes, out var resOps))
|
||||
{
|
||||
resOps = new HashSet<OperationType>();
|
||||
uidToOps[uidRes] = resOps;
|
||||
uidToExampleRelPath[uidRes] = resourcePath;
|
||||
}
|
||||
|
||||
resOps.Add(type);
|
||||
|
||||
pathToReqs[full] = reqs;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error building requirements for path '{full}' in ValidAny.");
|
||||
// leave result[full] as false
|
||||
}
|
||||
}
|
||||
|
||||
// Batch query DB for all UIDs (chunked)
|
||||
var uidsNeeded = uidToOps.Keys.ToList();
|
||||
var rasList = new List<ResourceAttribute>();
|
||||
|
||||
const int sqliteMaxVariableNumber = 900;
|
||||
if (uidsNeeded.Count > 0)
|
||||
{
|
||||
for (int i = 0; i < uidsNeeded.Count; i += sqliteMaxVariableNumber)
|
||||
{
|
||||
var chunk = uidsNeeded.Skip(i).Take(sqliteMaxVariableNumber).ToList();
|
||||
var placeholders = string.Join(",", chunk.Select(_ => "?"));
|
||||
var queryArgs = chunk.Cast<object>().ToArray();
|
||||
var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})";
|
||||
var chunkResult = await _database.QueryAsync<ResourceAttribute>(sql, queryArgs);
|
||||
rasList.AddRange(chunkResult);
|
||||
}
|
||||
}
|
||||
|
||||
var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Check each uid+op once and cache results
|
||||
var permCache = new Dictionary<(string uid, OperationType op), bool>();
|
||||
foreach (var kv in uidToOps)
|
||||
{
|
||||
var uid = kv.Key;
|
||||
var ops = kv.Value;
|
||||
|
||||
if (!raDict.TryGetValue(uid, out var ra))
|
||||
{
|
||||
// missing resource attribute -> all ops for this uid are false
|
||||
foreach (var op in ops)
|
||||
{
|
||||
permCache[(uid, op)] = false;
|
||||
var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid);
|
||||
_logger.LogDebug($"ValidAny: missing ResourceAttribute for Uid={uid}, example='{examplePath}'");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var op in ops)
|
||||
{
|
||||
var key = (uid, op);
|
||||
if (!permCache.TryGetValue(key, out var ok))
|
||||
{
|
||||
ok = await CheckPermission(user, ra, op);
|
||||
permCache[key] = ok;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compose results per original path
|
||||
foreach (var kv in pathToReqs)
|
||||
{
|
||||
var full = kv.Key;
|
||||
var reqs = kv.Value;
|
||||
|
||||
bool allOk = true;
|
||||
foreach (var (uid, op) in reqs)
|
||||
{
|
||||
if (!permCache.TryGetValue((uid, op), out var ok) || !ok)
|
||||
{
|
||||
allOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result[full] = allOk;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> ValidAll(string[] paths, string token, OperationType type, string ip)
|
||||
{
|
||||
if (paths.Length == 0)
|
||||
@@ -311,8 +509,44 @@ public class ResourceService
|
||||
if (Helpers.GetPathType(path) != PathType.Directory)
|
||||
return null;
|
||||
|
||||
var files = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly);
|
||||
return files.Select(x => Path.GetRelativePath(path, x)).ToArray();
|
||||
try
|
||||
{
|
||||
var entries = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly).ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var validMap = await ValidAny(entries, token, OperationType.Read, ip);
|
||||
|
||||
var allowed = new List<string>(entries.Length);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(entry);
|
||||
if (validMap.TryGetValue(full, out var ok) && ok)
|
||||
{
|
||||
allowed.Add(Path.GetRelativePath(path, entry));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug($"Query: access denied or not managed for '{entry}' (user token: {token}) - item skipped.");
|
||||
}
|
||||
}
|
||||
catch (Exception exEntry)
|
||||
{
|
||||
_logger.LogError(exEntry, $"Error processing entry '{entry}' in Query.");
|
||||
}
|
||||
}
|
||||
|
||||
return allowed.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error while listing directory '{path}' in Query.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> Get(string path, string token, string ip)
|
||||
@@ -415,22 +649,23 @@ public class ResourceService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> Put(string path, string token, string ip)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
// public async Task<bool> Put(string path, string token, string ip)
|
||||
// {
|
||||
// throw new NotImplementedException();
|
||||
// }
|
||||
|
||||
public async Task<bool> Delete(string path, string token, string ip)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
// public async Task<bool> Delete(string path, string token, string ip)
|
||||
// {
|
||||
// throw new NotImplementedException();
|
||||
// }
|
||||
|
||||
public async Task<bool> Exclude(string path, string token, string ip)
|
||||
{
|
||||
var requester = _user.Validate(token, ip);
|
||||
if (requester != 1)
|
||||
{
|
||||
_logger.LogWarning($"Permission denied: Non-root user '{requester}' attempted to exclude resource '{path}'.");
|
||||
_logger.LogWarning(
|
||||
$"Permission denied: Non-root user '{requester}' attempted to exclude resource '{path}'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -544,41 +779,104 @@ public class ResourceService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> Chmod(string path, string token, string permission, string ip)
|
||||
public async Task<bool> Chmod(string path, string token, string permission, string ip, bool recursive = false)
|
||||
{
|
||||
if (!await Valid(path, token, OperationType.Security, ip))
|
||||
return false;
|
||||
|
||||
// Validate the permission format using the existing regex
|
||||
// Validate permission format first
|
||||
if (!PermissionRegex.IsMatch(permission))
|
||||
{
|
||||
_logger.LogError($"Invalid permission format: {permission}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize path to full path (Valid / ValidAll expect absolute path starting with media root)
|
||||
path = Path.GetFullPath(path);
|
||||
|
||||
// If recursive and path is directory, collect all descendants (iterative)
|
||||
List<string> targets = new List<string>();
|
||||
try
|
||||
{
|
||||
path = Path.GetRelativePath(_config.MediaRoot, path);
|
||||
var uid = Uid(path);
|
||||
var resource = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
|
||||
|
||||
if (resource == null)
|
||||
if (recursive && Directory.Exists(path))
|
||||
{
|
||||
_logger.LogError($"Resource not found: {path}");
|
||||
// include root directory itself
|
||||
targets.Add(path);
|
||||
// Enumerate all files and directories under path iteratively
|
||||
foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
targets.Add(entry);
|
||||
}
|
||||
|
||||
// Permission check for all targets
|
||||
if (!await ValidAll(targets.ToArray(), token, OperationType.Security, ip))
|
||||
{
|
||||
_logger.LogWarning($"Permission denied for recursive chmod on '{path}'");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-recursive or target is a file: validate single path
|
||||
if (!await Valid(path, token, OperationType.Security, ip))
|
||||
{
|
||||
_logger.LogWarning($"Permission denied for chmod on '{path}'");
|
||||
return false;
|
||||
}
|
||||
|
||||
resource.Permission = permission;
|
||||
var rowsAffected = await _database.UpdateAsync(resource);
|
||||
targets.Add(path);
|
||||
}
|
||||
|
||||
if (rowsAffected > 0)
|
||||
// Convert targets to relative paths and UIDs
|
||||
var relUids = targets.Select(t => Path.GetRelativePath(_config.MediaRoot, t))
|
||||
.Select(rel => Uid(rel))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (relUids.Count == 0)
|
||||
{
|
||||
_logger.LogInformation($"Successfully changed permissions for '{path}' to '{permission}'");
|
||||
_logger.LogWarning($"No targets resolved for chmod on '{path}'");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Batch query existing ResourceAttribute rows for these UIDs (chunk to respect SQLite param limit)
|
||||
var rows = new List<ResourceAttribute>();
|
||||
const int sqliteMaxVariableNumber = 900;
|
||||
for (int i = 0; i < relUids.Count; i += sqliteMaxVariableNumber)
|
||||
{
|
||||
var chunk = relUids.Skip(i).Take(sqliteMaxVariableNumber).ToList();
|
||||
var placeholders = string.Join(",", chunk.Select(_ => "?"));
|
||||
var queryArgs = chunk.Cast<object>().ToArray();
|
||||
var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})";
|
||||
var chunkResult = await _database.QueryAsync<ResourceAttribute>(sql, queryArgs);
|
||||
rows.AddRange(chunkResult);
|
||||
}
|
||||
|
||||
var rowDict = rows.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
int updatedCount = 0;
|
||||
foreach (var uid in relUids)
|
||||
{
|
||||
if (rowDict.TryGetValue(uid, out var ra))
|
||||
{
|
||||
ra.Permission = permission;
|
||||
var res = await _database.UpdateAsync(ra);
|
||||
if (res > 0) updatedCount++;
|
||||
else _logger.LogError($"Failed to update permission row (UID={uid}) for chmod on '{path}'");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Resource not managed by DB — skip but log
|
||||
_logger.LogWarning($"Chmod skipped: resource not found in DB (Uid={uid}) for target '{path}'");
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
$"Chmod: updated permissions for {updatedCount} resource(s) (root='{path}', recursive={recursive})");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"Failed to update permissions for: {path}");
|
||||
_logger.LogWarning($"Chmod: no resources updated for '{path}' (recursive={recursive})");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -589,12 +887,9 @@ public class ResourceService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> Chown(string path, string token, int owner, string ip)
|
||||
public async Task<bool> Chown(string path, string token, int owner, string ip, bool recursive = false)
|
||||
{
|
||||
if (!await Valid(path, token, OperationType.Security, ip))
|
||||
return false;
|
||||
|
||||
// Validate that the new owner exists
|
||||
// Validate new owner exists
|
||||
var newOwner = await _user.QueryUser(owner);
|
||||
if (newOwner == null)
|
||||
{
|
||||
@@ -602,29 +897,90 @@ public class ResourceService
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize
|
||||
path = Path.GetFullPath(path);
|
||||
|
||||
// Permission checks and target collection
|
||||
List<string> targets = new List<string>();
|
||||
try
|
||||
{
|
||||
path = Path.GetRelativePath(_config.MediaRoot, path);
|
||||
var uid = Uid(path);
|
||||
var resource = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
|
||||
|
||||
if (resource == null)
|
||||
if (recursive && Directory.Exists(path))
|
||||
{
|
||||
_logger.LogError($"Resource not found: {path}");
|
||||
targets.Add(path);
|
||||
foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
targets.Add(entry);
|
||||
}
|
||||
|
||||
if (!await ValidAll(targets.ToArray(), token, OperationType.Security, ip))
|
||||
{
|
||||
_logger.LogWarning($"Permission denied for recursive chown on '{path}'");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!await Valid(path, token, OperationType.Security, ip))
|
||||
{
|
||||
_logger.LogWarning($"Permission denied for chown on '{path}'");
|
||||
return false;
|
||||
}
|
||||
|
||||
resource.Owner = owner;
|
||||
var rowsAffected = await _database.UpdateAsync(resource);
|
||||
targets.Add(path);
|
||||
}
|
||||
|
||||
if (rowsAffected > 0)
|
||||
// Build UID list
|
||||
var relUids = targets.Select(t => Path.GetRelativePath(_config.MediaRoot, t))
|
||||
.Select(rel => Uid(rel))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (relUids.Count == 0)
|
||||
{
|
||||
_logger.LogInformation($"Successfully changed ownership of '{path}' to '{owner}'");
|
||||
_logger.LogWarning($"No targets resolved for chown on '{path}'");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Batch query DB
|
||||
var rows = new List<ResourceAttribute>();
|
||||
const int sqliteMaxVariableNumber = 900;
|
||||
for (int i = 0; i < relUids.Count; i += sqliteMaxVariableNumber)
|
||||
{
|
||||
var chunk = relUids.Skip(i).Take(sqliteMaxVariableNumber).ToList();
|
||||
var placeholders = string.Join(",", chunk.Select(_ => "?"));
|
||||
var queryArgs = chunk.Cast<object>().ToArray();
|
||||
var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})";
|
||||
var chunkResult = await _database.QueryAsync<ResourceAttribute>(sql, queryArgs);
|
||||
rows.AddRange(chunkResult);
|
||||
}
|
||||
|
||||
var rowDict = rows.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
int updatedCount = 0;
|
||||
foreach (var uid in relUids)
|
||||
{
|
||||
if (rowDict.TryGetValue(uid, out var ra))
|
||||
{
|
||||
ra.Owner = owner;
|
||||
var res = await _database.UpdateAsync(ra);
|
||||
if (res > 0) updatedCount++;
|
||||
else _logger.LogError($"Failed to update owner row (UID={uid}) for chown on '{path}'");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning($"Chown skipped: resource not found in DB (Uid={uid}) for target '{path}'");
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
$"Chown: changed owner for {updatedCount} resource(s) (root='{path}', recursive={recursive})");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"Failed to change ownership for: {path}");
|
||||
_logger.LogWarning($"Chown: no resources updated for '{path}' (recursive={recursive})");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -635,6 +991,7 @@ public class ResourceService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task<bool> InsertRaRow(string fullPath, int owner, string permission, bool update = false)
|
||||
{
|
||||
if (!PermissionRegex.IsMatch(permission))
|
||||
@@ -662,4 +1019,32 @@ public class ResourceService
|
||||
}) == 1;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResourceAttribute?> GetAttribute(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
// normalize to full path
|
||||
var full = Path.GetFullPath(path);
|
||||
|
||||
// ensure it's under media root
|
||||
var mediaRootFull = Path.GetFullPath(_config.MediaRoot);
|
||||
if (!full.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var rel = Path.GetRelativePath(_config.MediaRoot, full);
|
||||
var uid = Uid(rel);
|
||||
|
||||
var ra = await _database.Table<ResourceAttribute>()
|
||||
.Where(r => r.Uid == uid)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return ra;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"GetAttribute failed for path '{path}'");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
ALLOWED_VIDEO_EXTS = [".mp4", ".mkv", ".webm", ".mov", ".ogg", ".ts", ".m2ts"]
|
||||
|
||||
def get_video_duration(video_path):
|
||||
"""Get video duration in milliseconds using ffprobe"""
|
||||
try:
|
||||
@@ -27,14 +29,12 @@ def create_thumbnails(video_path, gallery_path, num_thumbnails=10):
|
||||
Extracts thumbnails from a video and saves them to the gallery directory.
|
||||
"""
|
||||
try:
|
||||
# Check if ffmpeg is installed
|
||||
subprocess.run(['ffmpeg', '-version'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("Error: ffmpeg is not installed or not in your PATH. Skipping thumbnail creation.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Get video duration using ffprobe
|
||||
duration_cmd = [
|
||||
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1', str(video_path)
|
||||
@@ -107,15 +107,42 @@ def create_cover(video_path, output_path, time_percent):
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error creating cover image: {e}")
|
||||
|
||||
def find_video_in_dir(base_path):
|
||||
"""
|
||||
Find video file in base_path. Preference:
|
||||
1) file named 'video' with allowed ext (video.mp4, video.mkv, ...)
|
||||
2) first file with allowed ext
|
||||
Returns Path or None.
|
||||
"""
|
||||
if not base_path.exists() or not base_path.is_dir():
|
||||
return None
|
||||
|
||||
# prefer explicit video.* name
|
||||
for ext in ALLOWED_VIDEO_EXTS:
|
||||
candidate = base_path / ("video" + ext)
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate
|
||||
|
||||
# otherwise find first allowed extension file
|
||||
for f in base_path.iterdir():
|
||||
if f.is_file() and f.suffix.lower() in ALLOWED_VIDEO_EXTS:
|
||||
return f
|
||||
|
||||
return None
|
||||
|
||||
def update_summary(base_path, name_input=None, author_input=None):
|
||||
"""
|
||||
Updates the summary.json file for a given path.
|
||||
name_input and author_input are optional, used for the '-a' mode.
|
||||
"""
|
||||
summary_path = base_path / "summary.json"
|
||||
video_path = base_path / "video.mp4"
|
||||
gallery_path = base_path / "gallery"
|
||||
|
||||
# Find the video file dynamically
|
||||
video_path = find_video_in_dir(base_path)
|
||||
if video_path is None:
|
||||
print(f"Warning: no video file found in {base_path}")
|
||||
|
||||
# Default template
|
||||
default_summary = {
|
||||
"name": name_input if name_input is not None else "null",
|
||||
@@ -139,11 +166,11 @@ def update_summary(base_path, name_input=None, author_input=None):
|
||||
except json.JSONDecodeError:
|
||||
print("Warning: Invalid JSON in summary.json, using defaults")
|
||||
|
||||
# Update duration from video file
|
||||
if video_path.exists():
|
||||
# Update duration from video file if found
|
||||
if video_path and video_path.exists():
|
||||
default_summary["duration"] = get_video_duration(video_path)
|
||||
else:
|
||||
print(f"Warning: video.mp4 not found at {video_path}")
|
||||
print("Warning: video file for duration not found; duration set to 0")
|
||||
|
||||
# Update gallery from directory
|
||||
if gallery_path.exists() and gallery_path.is_dir():
|
||||
@@ -179,12 +206,15 @@ def main():
|
||||
print("Usage: python script.py <command> [arguments]")
|
||||
print("Commands:")
|
||||
print(" -u <path> Update the summary.json in the specified path.")
|
||||
print(" -a <video_file> <path> Add a new video project in a new directory under the specified path.")
|
||||
print(" -a <video_file> <path> Add a new video project in a new directory under the specified path. Optional -y to accept defaults.")
|
||||
print(" -c <path> <time> Create a cover image from the video in the specified path at a given time percentage (0.0-1.0).")
|
||||
sys.exit(1)
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
# global -y flag (if present anywhere)
|
||||
assume_yes = '-y' in sys.argv
|
||||
|
||||
if command == '-u':
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python script.py -u <path>")
|
||||
@@ -196,12 +226,14 @@ def main():
|
||||
update_summary(base_path)
|
||||
|
||||
elif command == '-a':
|
||||
if len(sys.argv) != 4:
|
||||
print("Usage: python script.py -a <video_file> <path>")
|
||||
# allow invocation with optional -y flag anywhere; expecting at least video and base path
|
||||
params = [p for p in sys.argv[2:] if p != '-y']
|
||||
if len(params) != 2:
|
||||
print("Usage: python script.py -a <video_file> <path> (optional -y to accept defaults)")
|
||||
sys.exit(1)
|
||||
|
||||
video_source_path = Path(sys.argv[2])
|
||||
base_path = Path(sys.argv[3])
|
||||
video_source_path = Path(params[0])
|
||||
base_path = Path(params[1])
|
||||
|
||||
if not video_source_path.exists() or not video_source_path.is_file():
|
||||
print(f"Error: Video file not found: {video_source_path}")
|
||||
@@ -221,15 +253,25 @@ def main():
|
||||
gallery_path.mkdir(exist_ok=True)
|
||||
print(f"New project directory created at {new_project_path}")
|
||||
|
||||
# Copy video file to the new directory
|
||||
shutil.copy(video_source_path, new_project_path / "video.mp4")
|
||||
print(f"Video copied to {new_project_path / 'video.mp4'}")
|
||||
# Copy video file to the new directory, preserving extension in the target name
|
||||
dest_video_name = "video" + video_source_path.suffix.lower()
|
||||
video_dest_path = new_project_path / dest_video_name
|
||||
shutil.copy(video_source_path, video_dest_path)
|
||||
print(f"Video copied to {video_dest_path}")
|
||||
|
||||
# Auto-generate thumbnails
|
||||
video_dest_path = new_project_path / "video.mp4"
|
||||
create_thumbnails(video_dest_path, gallery_path)
|
||||
|
||||
# Get user input for name and author, with a prompt for default values
|
||||
# Auto-generate cover at 50%
|
||||
cover_path = new_project_path / "cover.jpg"
|
||||
create_cover(video_dest_path, cover_path, 0.5)
|
||||
|
||||
# Get user input for name and author, unless assume_yes is set
|
||||
if assume_yes:
|
||||
video_name = video_source_path.stem
|
||||
video_author = "Anonymous"
|
||||
print(f"Assume yes (-y): using defaults: name='{video_name}', author='{video_author}'")
|
||||
else:
|
||||
print("\nEnter the video name (press Enter to use the original filename):")
|
||||
video_name = input(f"Video Name [{video_source_path.stem}]: ")
|
||||
if not video_name:
|
||||
@@ -249,7 +291,11 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
base_path = Path(sys.argv[2])
|
||||
video_path = base_path / "video.mp4"
|
||||
# find video dynamically
|
||||
video_path = find_video_in_dir(base_path)
|
||||
if video_path is None:
|
||||
print(f"Error: no video file found in {base_path}")
|
||||
sys.exit(1)
|
||||
cover_path = base_path / "cover.jpg"
|
||||
|
||||
try:
|
||||
@@ -261,7 +307,7 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
if not video_path.exists() or not video_path.is_file():
|
||||
print(f"Error: video.mp4 not found at {video_path}")
|
||||
print(f"Error: video file not found at {video_path}")
|
||||
sys.exit(1)
|
||||
|
||||
create_cover(video_path, cover_path, time_percent)
|
||||
|
||||
Reference in New Issue
Block a user