Compare commits
	
		
			18 Commits
		
	
	
		
			dev-abyss
			...
			46ff611706
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 46ff611706 | ||
|   | f1e636a79d | ||
|   | 87536a1508 | ||
|   | a7c522a61f | ||
|   | 3723ea32a7 | ||
|   | e7d24aa20b | ||
|   | a2f6eb1fba | ||
|   | 57c37bee51 | ||
|   | a228d523a2 | ||
|   | 40c041444a | ||
|   | 8465ec5b2a | ||
|   | ec7306ade2 | ||
|   | 9aa987d52a | ||
|   | e174238d3c | ||
|   | cad92f8fa5 | ||
|   | 9b6a4a9982 | ||
|   | 197cf525fb | ||
|   | ae93b75e41 | 
							
								
								
									
										40
									
								
								.idea/.idea.Abyss/.idea/workspace.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										40
									
								
								.idea/.idea.Abyss/.idea/workspace.xml
									
									
									
										generated
									
									
									
								
							| @@ -9,10 +9,7 @@ | ||||
|     <option name="autoReloadType" value="SELECTIVE" /> | ||||
|   </component> | ||||
|   <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/Services/UserService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Services/UserService.cs" afterDir="false" /> | ||||
|     </list> | ||||
|     <list default="true" id="bf317275-3039-49bb-a475-725a800a0cce" name="Changes" comment="" /> | ||||
|     <option name="SHOW_DIALOG" value="false" /> | ||||
|     <option name="HIGHLIGHT_CONFLICTS" value="true" /> | ||||
|     <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> | ||||
| @@ -34,7 +31,13 @@ | ||||
|     <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="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" /> | ||||
| @@ -85,8 +88,8 @@ | ||||
|     "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", | ||||
|     "RunOnceActivity.git.unshallow": "true", | ||||
|     "XThreadsFramesViewSplitterKey": "0.30266345", | ||||
|     "git-widget-placeholder": "dev-abyss", | ||||
|     "last_opened_file_path": "/storage/Images/31/summary.json", | ||||
|     "git-widget-placeholder": "main", | ||||
|     "last_opened_file_path": "/home/acite/embd/WebProjects/Abyss/README.md", | ||||
|     "node.js.detected.package.eslint": "true", | ||||
|     "node.js.detected.package.tslint": "true", | ||||
|     "node.js.selected.package.eslint": "(autodetect)", | ||||
| @@ -201,6 +204,31 @@ | ||||
|       <workItem from="1757687641035" duration="2969000" /> | ||||
|       <workItem from="1757693751836" duration="667000" /> | ||||
|       <workItem from="1757694833696" duration="11000" /> | ||||
|       <workItem from="1757695721386" duration="749000" /> | ||||
|       <workItem from="1757702942841" duration="32000" /> | ||||
|       <workItem from="1757735249561" duration="5523000" /> | ||||
|       <workItem from="1757742881713" duration="2285000" /> | ||||
|       <workItem from="1757745929389" duration="93000" /> | ||||
|       <workItem from="1757751423586" duration="2687000" /> | ||||
|       <workItem from="1757782027930" duration="308000" /> | ||||
|       <workItem from="1757830765557" duration="1218000" /> | ||||
|       <workItem from="1757862781213" duration="341000" /> | ||||
|       <workItem from="1757918235256" duration="1000" /> | ||||
|       <workItem from="1758040123892" duration="21000" /> | ||||
|       <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="2560000" /> | ||||
|       <workItem from="1758307172642" duration="157000" /> | ||||
|       <workItem from="1758307433345" duration="34000" /> | ||||
|       <workItem from="1758344749532" duration="238000" /> | ||||
|       <workItem from="1758345893755" duration="2662000" /> | ||||
|       <workItem from="1758349313244" duration="24000" /> | ||||
|       <workItem from="1758349710909" duration="16000" /> | ||||
|       <workItem from="1758350096355" duration="452000" /> | ||||
|       <workItem from="1758350848039" duration="946000" /> | ||||
|     </task> | ||||
|     <servers /> | ||||
|   </component> | ||||
|   | ||||
| @@ -2,8 +2,6 @@ | ||||
| Microsoft Visual Studio Solution File, Format Version 12.00 | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Abyss", "Abyss\Abyss.csproj", "{3337C1CD-2419-4922-BC92-AF1A825DDF23}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AbyssCli", "AbyssCli\AbyssCli.csproj", "{D7D668D4-61E7-4AA4-B615-A162FABAD333}" | ||||
| EndProject | ||||
| Global | ||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| 		Debug|Any CPU = Debug|Any CPU | ||||
| @@ -14,9 +12,5 @@ Global | ||||
| 		{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| EndGlobal | ||||
|   | ||||
| @@ -5,6 +5,7 @@ | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKey_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff09ccaeb94c34c2299acd3efee0facee1a400_003F81_003F137b58b4_003FKey_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003F4c_003F4b962087_003FMonitor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F011a191356a243438f987de3ec3d6c6230800_003F04_003F8419ff35_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASQLiteAsyncConnection_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F61fe11e9d86b4d2a9bd2b806929b7d381a400_003Fe9_003F67f4a40e_003FSQLiteAsyncConnection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AString_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003Fd0_003F3b166e9e_003FString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003F6b_003F2e4babaf_003FTask_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AxxHash128_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7598e47d5cdf4107ba88f8220720fdc89000_003Fa6_003F79d67871_003FxxHash128_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary> | ||||
| @@ -13,6 +13,10 @@ | ||||
|         <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> | ||||
|         <PackageReference Include="NSec.Cryptography" Version="25.4.0" /> | ||||
|         <PackageReference Include="sqlite-net-pcl" Version="1.9.172" /> | ||||
|         <PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.11" /> | ||||
|         <PackageReference Include="SQLitePCLRaw.core" Version="3.0.2" /> | ||||
|         <PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.11" /> | ||||
|         <PackageReference Include="SQLitePCLRaw.provider.e_sqlite3" Version="3.0.2" /> | ||||
|         <PackageReference Include="Standart.Hash.xxHash" Version="4.0.5" /> | ||||
|         <PackageReference Include="System.IO.Hashing" Version="10.0.0-preview.7.25380.108" /> | ||||
|     </ItemGroup> | ||||
|   | ||||
| @@ -9,9 +9,11 @@ using Newtonsoft.Json.Linq; | ||||
| namespace Abyss.Components.Controllers.Media; | ||||
| using System.IO; | ||||
|  | ||||
| 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"); | ||||
|      | ||||
| @@ -46,6 +48,25 @@ public class ImageController(ILogger<ImageController> logger, ResourceService rs | ||||
|         return Ok(await System.IO.File.ReadAllTextAsync(d)); | ||||
|     } | ||||
|  | ||||
|     [HttpPost("bulkquery")] | ||||
|     public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id) | ||||
|     { | ||||
|         List<string> result = new List<string>(); | ||||
|  | ||||
|         var db = id.Select(x => Helpers.SafePathCombine(ImageFolder, [x, "summary.json"])).ToArray(); | ||||
|         if (db.Any(x => x == null)) | ||||
|             return BadRequest(); | ||||
|          | ||||
|         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(); | ||||
|         string[] rcs = await Task.WhenAll(rc); | ||||
|         var rjs = rcs.Select(JsonConvert.DeserializeObject<Comic>).Select(x => x!).ToArray(); | ||||
|          | ||||
|         return Ok(JsonConvert.SerializeObject(rjs)); | ||||
|     } | ||||
|  | ||||
|     [HttpPost("{id}/bookmark")] | ||||
|     public async Task<IActionResult> Bookmark(string id, string token, [FromBody] Bookmark bookmark) | ||||
|     { | ||||
|   | ||||
| @@ -6,12 +6,12 @@ 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"); | ||||
|  | ||||
|     [HttpPost("{id}")] | ||||
|     public async Task<IActionResult> AddLive(string id, string token, string owner) | ||||
|     public async Task<IActionResult> AddLive(string id, string token, int owner) | ||||
|     { | ||||
|         var d = Helpers.SafePathCombine(LiveFolder, [id]); | ||||
|         if (d == null) return StatusCode(403, new { message = "403 Denied" }); | ||||
|   | ||||
| @@ -1,26 +1,28 @@ | ||||
| using System.Diagnostics; | ||||
| 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; | ||||
|  | ||||
| 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"); | ||||
|  | ||||
|     [HttpPost("init")] | ||||
|     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" }); | ||||
|     } | ||||
|  | ||||
| @@ -29,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); | ||||
| @@ -43,22 +45,19 @@ 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(); | ||||
|  | ||||
|  | ||||
|         return Ok(sv.Zip(r, (x, y) => (x, y)).NaturalSort(x => x.x.name).Select(x => x.y).ToArray()); | ||||
|         return Ok(sv.Zip(r, (x, y) => (x, y)).NaturalSort(x => x.x!.name).Select(x => x.y).ToArray()); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{klass}/{id}")] | ||||
| @@ -73,6 +72,24 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs | ||||
|         return Ok(await System.IO.File.ReadAllTextAsync(d)); | ||||
|     } | ||||
|  | ||||
|     [HttpPost("{klass}/bulkquery")] | ||||
|     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)) | ||||
|             return BadRequest(); | ||||
|  | ||||
|         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(); | ||||
|         string[] rcs = await Task.WhenAll(rc); | ||||
|         var rjs = rcs.Select(JsonConvert.DeserializeObject<Video>).Select(x => x!).ToList(); | ||||
|  | ||||
|         return Ok(JsonConvert.SerializeObject(rjs)); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{klass}/{id}/cover")] | ||||
|     public async Task<IActionResult> Cover(string klass, string id, string token) | ||||
|     { | ||||
| @@ -82,8 +99,6 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs | ||||
|         var r = await rs.Get(d, token, Ip); | ||||
|         if (!r) return StatusCode(403, new { message = "403 Denied" }); | ||||
|  | ||||
|         _logger.LogInformation($"Cover found for {id}"); | ||||
|          | ||||
|         return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true); | ||||
|     } | ||||
|  | ||||
| @@ -99,14 +114,116 @@ 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 preferredVtt = Path.Combine(folder, "subtitle.vtt"); | ||||
|             if (System.IO.File.Exists(preferredVtt)) | ||||
|             { | ||||
|                 subtitlePath = preferredVtt; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 subtitlePath = Directory.EnumerateFiles(folder, "*.vtt").FirstOrDefault(); | ||||
|  | ||||
|                 if (subtitlePath == null) | ||||
|                 { | ||||
|                     var preferredAss = Path.Combine(folder, "subtitle.ass"); | ||||
|                     if (System.IO.File.Exists(preferredAss)) | ||||
|                     { | ||||
|                         subtitlePath = preferredAss; | ||||
|                     } | ||||
|                     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" }); | ||||
|  | ||||
|         var ext = Path.GetExtension(subtitlePath).ToLowerInvariant(); | ||||
|         var contentType = ext switch | ||||
|         { | ||||
|             ".vtt" => "text/vtt", | ||||
|             ".ass" => "text/x-ssa", | ||||
|             _ => "text/plain" | ||||
|         }; | ||||
|  | ||||
|         return PhysicalFile(subtitlePath, contentType, 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); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| namespace Abyss.Components.Controllers.Middleware; | ||||
|  | ||||
| public class BadRequestExceptionMiddleware(RequestDelegate next, ILogger<BadRequestExceptionMiddleware> logger) | ||||
| { | ||||
|     public async System.Threading.Tasks.Task Invoke(HttpContext context) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             await next(context); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex.Message); | ||||
|             context.Response.StatusCode = StatusCodes.Status400BadRequest; | ||||
|             await context.Response.WriteAsync("Bad Request"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,13 +0,0 @@ | ||||
| 明确几个此目录下的API的开发理念: | ||||
| - 永远不传输私钥 | ||||
|  | ||||
| root用户的私钥仅通过服务器shell配置 | ||||
| 私钥在客户端生成,仅将公钥传输到服务器 | ||||
| token通过挑战-响应机制创建,加密传输 | ||||
|  | ||||
|  | ||||
| - 用户管理 | ||||
|  | ||||
| 创建任何新用户都必须通过一个已有用户的token,且新用户权限等级不大于该用户 | ||||
| root用户的权限等级为 **114514** | ||||
|  | ||||
							
								
								
									
										153
									
								
								Abyss/Components/Controllers/Security/RootController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								Abyss/Components/Controllers/Security/RootController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,153 @@ | ||||
| using System.Text; | ||||
| using Abyss.Components.Services; | ||||
| using Abyss.Components.Static; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
|  | ||||
| namespace Abyss.Components.Controllers.Security; | ||||
|  | ||||
| [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, string? recursive) | ||||
|     { | ||||
|         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, recursive == "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, string? recursive) | ||||
|     { | ||||
|         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, recursive == "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"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpPost("init")] | ||||
|     public async Task<IActionResult> Init(string token, string path, int owner) | ||||
|     { | ||||
|         if (userService.Validate(token, Ip) != 1) | ||||
|         { | ||||
|             logger.LogInformation("Init authorization failed for token: {Token}", token); | ||||
|             return StatusCode(401, "Unauthorized"); | ||||
|         } | ||||
|          | ||||
|         var r = await resourceService.Initialize(path, token, owner, Ip); | ||||
|         if (r) return Ok(r); | ||||
|         return StatusCode(403, new { message = "403 Denied" }); | ||||
|     } | ||||
|  | ||||
|     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; | ||||
|     } | ||||
| } | ||||
| @@ -1,4 +1,3 @@ | ||||
|  | ||||
| // UserController.cs | ||||
|  | ||||
| using System.Text.RegularExpressions; | ||||
| @@ -13,16 +12,13 @@ namespace Abyss.Components.Controllers.Security; | ||||
| [ApiController] | ||||
| [Route("api/[controller]")] | ||||
| [EnableRateLimiting("Fixed")] | ||||
| public class UserController(UserService user, ILogger<UserController> logger) : BaseController | ||||
| public class UserController(UserService userService, ILogger<UserController> logger) : BaseController | ||||
| { | ||||
|     private readonly ILogger<UserController> _logger = logger; | ||||
|     private readonly UserService _user = user; | ||||
|  | ||||
|     [HttpGet("{user}")] | ||||
|     public async Task<IActionResult> Challenge(string user) | ||||
|     { | ||||
|         var c = await _user.Challenge(user); | ||||
|         if(c == null)  | ||||
|         var c = await userService.Challenge(user); | ||||
|         if (c == null) | ||||
|             return StatusCode(403, new { message = "Access forbidden" }); | ||||
|  | ||||
|         return Ok(c); | ||||
| @@ -31,18 +27,17 @@ public class UserController(UserService user, ILogger<UserController> logger) : | ||||
|     [HttpPost("{user}")] | ||||
|     public async Task<IActionResult> Challenge(string user, [FromBody] ChallengeResponse response) | ||||
|     { | ||||
|         var r = await _user.Verify(user, response.Response, Ip); | ||||
|         if(r == null) | ||||
|         var r = await userService.Verify(user, response.Response, Ip); | ||||
|         if (r == null) | ||||
|             return StatusCode(403, new { message = "Access forbidden" }); | ||||
|          | ||||
|         return Ok(r); | ||||
|     } | ||||
|  | ||||
|     [HttpPost("validate")] | ||||
|     public IActionResult Validate(string token) | ||||
|     { | ||||
|         var u = _user.Validate(token, Ip); | ||||
|         if (u == null) | ||||
|         var u = userService.Validate(token, Ip); | ||||
|         if (u == -1) | ||||
|         { | ||||
|             return StatusCode(401, new { message = "Invalid" }); | ||||
|         } | ||||
| @@ -53,13 +48,13 @@ public class UserController(UserService user, ILogger<UserController> logger) : | ||||
|     [HttpPost("destroy")] | ||||
|     public IActionResult Destroy(string token) | ||||
|     { | ||||
|         var u = _user.Validate(token, Ip); | ||||
|         if (u == null) | ||||
|         var u = userService.Validate(token, Ip); | ||||
|         if (u == -1) | ||||
|         { | ||||
|             return StatusCode(401, new { message = "Invalid" }); | ||||
|         } | ||||
|  | ||||
|         _user.Destroy(token); | ||||
|         userService.Destroy(token); | ||||
|         return Ok("Success"); | ||||
|     } | ||||
|  | ||||
| @@ -67,46 +62,46 @@ public class UserController(UserService user, ILogger<UserController> logger) : | ||||
|     public async Task<IActionResult> Create(string user, [FromBody] UserCreating creating) | ||||
|     { | ||||
|         // Valid token | ||||
|         var r = await _user.Verify(user, creating.Response, Ip); | ||||
|         if(r == null) | ||||
|         var r = await userService.Verify(user, creating.Response, Ip); | ||||
|         if (r == null) | ||||
|             return StatusCode(403, new { message = "Denied" }); | ||||
|  | ||||
|         // User exists ? | ||||
|         var cu = await _user.QueryUser(creating.Name); | ||||
|         if(cu != null)  | ||||
|         var cu = await userService.QueryUser(creating.Name); | ||||
|         if (cu != null) | ||||
|             return StatusCode(403, new { message = "Denied" }); | ||||
|  | ||||
|         // Valid username string | ||||
|         if(!IsAlphanumeric(creating.Name)) | ||||
|         if (!IsAlphanumeric(creating.Name)) | ||||
|             return StatusCode(403, new { message = "Denied" }); | ||||
|  | ||||
|         // Valid parent && Privilege | ||||
|         var ou = await _user.QueryUser(_user.Validate(r, Ip) ?? ""); | ||||
|         if(creating.Parent != (_user.Validate(r, Ip) ?? "") || creating.Privilege > ou?.Privilege) | ||||
|         var ou = await userService.QueryUser(userService.Validate(r, Ip)); | ||||
|         if (creating.Privilege > ou?.Privilege || ou == null) | ||||
|             return StatusCode(403, new { message = "Denied" }); | ||||
|  | ||||
|         await _user.CreateUser(new User() | ||||
|         await userService.CreateUser(new User | ||||
|         { | ||||
|             Name = creating.Name, | ||||
|             Parent = _user.Validate(r, Ip) ?? "", | ||||
|             Username = creating.Name, | ||||
|             ParentId = ou.Uuid, | ||||
|             Privilege = creating.Privilege, | ||||
|             PublicKey = creating.PublicKey, | ||||
|         } ); | ||||
|         }); | ||||
|  | ||||
|         _user.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 = _user.Validate(token, Ip); | ||||
|         if (caller == null || caller != "root") | ||||
|         var caller = userService.Validate(token, Ip); | ||||
|         if (caller != 1) | ||||
|         { | ||||
|             return StatusCode(403, new { message = "Access forbidden" }); | ||||
|         } | ||||
|  | ||||
|         var target = await _user.QueryUser(user); | ||||
|         var target = await userService.QueryUser(user); | ||||
|         if (target == null) | ||||
|         { | ||||
|             return StatusCode(404, new { message = "User not found" }); | ||||
| @@ -114,9 +109,10 @@ public class UserController(UserService user, ILogger<UserController> logger) : | ||||
|  | ||||
|         var ipToBind = string.IsNullOrWhiteSpace(bindIp) ? Ip : bindIp; | ||||
|  | ||||
|         var t = _user.CreateToken(user, 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, ipToBind, Ip); | ||||
|         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"); | ||||
|      | ||||
|   | ||||
| @@ -5,7 +5,7 @@ using Abyss.Components.Tools; | ||||
|  | ||||
| namespace Abyss.Components.Services; | ||||
|  | ||||
| public class AbyssService(ILogger<AbyssService> logger, ConfigureService config) : IHostedService, IDisposable | ||||
| public class AbyssService(ILogger<AbyssService> logger, ConfigureService config, UserService user) : IHostedService, IDisposable | ||||
| { | ||||
|     private Task? _executingTask; | ||||
|     private CancellationTokenSource? _cts; | ||||
| @@ -41,6 +41,7 @@ public class AbyssService(ILogger<AbyssService> logger, ConfigureService config) | ||||
|                 int bytesRead = await upstream.ReadAsync(buffer, 0, buffer.Length, token); | ||||
|                 if (bytesRead == 0)  | ||||
|                     break; | ||||
|                  | ||||
|                 await client.WriteAsync(buffer, 0, bytesRead, token); | ||||
|             } | ||||
|         }); | ||||
| @@ -51,10 +52,9 @@ public class AbyssService(ILogger<AbyssService> logger, ConfigureService config) | ||||
|  | ||||
|     private async Task ClientHandlerAsync(TcpClient client, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var stream = await client.GetAbyssStreamAsync(ct: cancellationToken); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             await using var stream = await client.GetAbyssStreamAsync(ct: cancellationToken, us: user); | ||||
|             var request = HttpHelper.Parse(await HttpReader.ReadHttpMessageAsync(stream, cancellationToken)); | ||||
|             var port = 80; | ||||
|             var sp = request.RequestUri?.ToString().Split(':') ?? []; | ||||
| @@ -136,7 +136,6 @@ public class AbyssService(ILogger<AbyssService> logger, ConfigureService config) | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             stream.Close(); | ||||
|             client.Close(); | ||||
|             client.Dispose(); | ||||
|         } | ||||
|   | ||||
| @@ -4,7 +4,6 @@ using System.Text; | ||||
| using System.Text.RegularExpressions; | ||||
| using Abyss.Components.Static; | ||||
| using Abyss.Model; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using SQLite; | ||||
| using System.IO.Hashing; | ||||
|  | ||||
| @@ -21,19 +20,15 @@ public class ResourceService | ||||
| { | ||||
|     private readonly ILogger<ResourceService> _logger; | ||||
|     private readonly ConfigureService _config; | ||||
|     private readonly IMemoryCache _cache; | ||||
|     private readonly UserService _user; | ||||
|     private readonly SQLiteAsyncConnection _database; | ||||
|  | ||||
|     private static readonly Regex PermissionRegex = | ||||
|         new(@"^([r-][w-]),([r-][w-]),([r-][w-])$", RegexOptions.Compiled); | ||||
|     private static readonly Regex PermissionRegex = new("^([r-][w-]),([r-][w-]),([r-][w-])$", RegexOptions.Compiled); | ||||
|  | ||||
|     public ResourceService(ILogger<ResourceService> logger, ConfigureService config, IMemoryCache cache, | ||||
|         UserService user) | ||||
|     public ResourceService(ILogger<ResourceService> logger, ConfigureService config, UserService user) | ||||
|     { | ||||
|         _logger = logger; | ||||
|         _config = config; | ||||
|         _cache = cache; | ||||
|         _user = user; | ||||
|  | ||||
|         _database = new SQLiteAsyncConnection(config.RaDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create); | ||||
| @@ -42,13 +37,13 @@ public class ResourceService | ||||
|         var tasksPath = Helpers.SafePathCombine(_config.MediaRoot, "Tasks"); | ||||
|         if (tasksPath != null) | ||||
|         { | ||||
|             InsertRaRow(tasksPath, "root", "rw,r-,r-", true).Wait(); | ||||
|             InsertRaRow(tasksPath, 1, "rw,r-,r-", true).Wait(); | ||||
|         } | ||||
|  | ||||
|         var livePath = Helpers.SafePathCombine(_config.MediaRoot, "Live"); | ||||
|         if (livePath != null) | ||||
|         { | ||||
|             InsertRaRow(livePath, "root", "rw,r-,r-", true).Wait(); | ||||
|             InsertRaRow(livePath, 1, "rw,r-,r-", true).Wait(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -57,9 +52,353 @@ public class ResourceService | ||||
|     { | ||||
|         var b = Encoding.UTF8.GetBytes(path); | ||||
|         var r = XxHash128.Hash(b, 0x11451419); | ||||
|         return Convert.ToBase64String(r ?? []); | ||||
|         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) | ||||
|         { | ||||
|             _logger.LogError("ValidAll called with empty path set"); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var mediaRootFull = Path.GetFullPath(_config.MediaRoot); | ||||
|  | ||||
|         // 1. basic path checks & normalize to relative | ||||
|         var relPaths = new List<string>(paths.Length); | ||||
|         foreach (var p in paths) | ||||
|         { | ||||
|             if (!p.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 _logger.LogError($"Path outside media root or null: {p}"); | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             relPaths.Add(Path.GetRelativePath(_config.MediaRoot, Path.GetFullPath(p))); | ||||
|         } | ||||
|  | ||||
|         // 2. validate token and user once | ||||
|         int uuid = _user.Validate(token, ip); | ||||
|         if (uuid == -1) | ||||
|         { | ||||
|             _logger.LogError($"Invalid token: {token}"); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         User? user = await _user.QueryUser(uuid); | ||||
|         if (user == null || user.Uuid != uuid) | ||||
|         { | ||||
|             _logger.LogError($"Verification failed: {token}"); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // 3. build uid -> required ops map (avoid duplicate Uid calculations) | ||||
|         var uidToOps = new Dictionary<string, HashSet<OperationType>>(StringComparer.OrdinalIgnoreCase); | ||||
|         var uidToExampleRelPath = | ||||
|             new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // for better logging | ||||
|         foreach (var rel in relPaths) | ||||
|         { | ||||
|             var parts = rel | ||||
|                 .Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, | ||||
|                     StringSplitOptions.RemoveEmptyEntries) | ||||
|                 .Where(s => !string.IsNullOrEmpty(s)) | ||||
|                 .ToArray(); | ||||
|  | ||||
|             // parents (each prefix) require Read | ||||
|             for (int i = 0; i < parts.Length - 1; i++) | ||||
|             { | ||||
|                 var subPath = Path.Combine(parts.Take(i + 1).ToArray()); | ||||
|                 var uidDir = Uid(subPath); | ||||
|                 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); | ||||
|             if (!uidToOps.TryGetValue(uidRes, out var resOps)) | ||||
|             { | ||||
|                 resOps = new HashSet<OperationType>(); | ||||
|                 uidToOps[uidRes] = resOps; | ||||
|                 uidToExampleRelPath[uidRes] = resourcePath; | ||||
|             } | ||||
|  | ||||
|             resOps.Add(type); | ||||
|         } | ||||
|  | ||||
|         // 4. batch query DB for all UIDs using parameterized IN (...) and chunking to respect SQLite param limits | ||||
|         var uidsNeeded = uidToOps.Keys.ToList(); | ||||
|         var rasList = new List<ResourceAttribute>(); | ||||
|  | ||||
|         const int sqliteMaxVariableNumber = 900; // keep below default 999 for safety | ||||
|         if (uidsNeeded.Count > 0) | ||||
|         { | ||||
|             if (uidsNeeded.Count <= sqliteMaxVariableNumber) | ||||
|             { | ||||
|                 var placeholders = string.Join(",", uidsNeeded.Select(_ => "?")); | ||||
|                 var queryArgs = uidsNeeded.Cast<object>().ToArray(); | ||||
|                 var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})"; | ||||
|                 var chunkResult = await _database.QueryAsync<ResourceAttribute>(sql, queryArgs); | ||||
|                 rasList.AddRange(chunkResult); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 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); | ||||
|  | ||||
|         // 5. check each uid once per required operation (cache results per uid+op) | ||||
|         var permCache = new Dictionary<(string uid, OperationType op), bool>(); // avoid repeated CheckPermission | ||||
|  | ||||
|         foreach (var kv in uidToOps) | ||||
|         { | ||||
|             var uid = kv.Key; | ||||
|             if (!raDict.TryGetValue(uid, out var ra)) | ||||
|             { | ||||
|                 var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid); | ||||
|                 _logger.LogError( | ||||
|                     $"Permission check failed (missing resource attribute): User: {uuid}, Resource: {examplePath}, Uid: {uid}"); | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             foreach (var op in kv.Value) | ||||
|             { | ||||
|                 var key = (uid, op); | ||||
|                 if (!permCache.TryGetValue(key, out var ok)) | ||||
|                 { | ||||
|                     ok = await CheckPermission(user, ra, op); | ||||
|                     permCache[key] = ok; | ||||
|                 } | ||||
|  | ||||
|                 if (!ok) | ||||
|                 { | ||||
|                     var examplePath = uidToExampleRelPath.TryGetValue(uid, out var p) ? p : uid; | ||||
|                     _logger.LogError( | ||||
|                         $"Permission check failed: User: {uuid}, Resource: {examplePath}, Uid: {uid}, Type: {op}"); | ||||
|                     return false; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public async Task<bool> Valid(string path, string token, OperationType type, string ip) | ||||
|     { | ||||
|         // Path is abs path here, due to Helpers.SafePathCombine | ||||
| @@ -68,16 +407,16 @@ public class ResourceService | ||||
|  | ||||
|         path = Path.GetRelativePath(_config.MediaRoot, path); | ||||
|  | ||||
|         string? username = _user.Validate(token, ip); | ||||
|         if (username == null) | ||||
|         int uuid = _user.Validate(token, ip); | ||||
|         if (uuid == -1) | ||||
|         { | ||||
|             // 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) | ||||
|         User? user = await _user.QueryUser(uuid); | ||||
|         if (user == null || user.Uuid != uuid) | ||||
|         { | ||||
|             _logger.LogError($"Verification failed: {token}"); | ||||
|             return false; // Two-factor authentication | ||||
| @@ -90,34 +429,38 @@ public class ResourceService | ||||
|         { | ||||
|             var subPath = Path.Combine(parts.Take(i + 1).ToArray()); | ||||
|             var uidDir = Uid(subPath); | ||||
|             var raDir = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uidDir) | ||||
|             var raDir = await _database | ||||
|                 .Table<ResourceAttribute>() | ||||
|                 .Where(r => r.Uid == uidDir) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             if (raDir == null) | ||||
|             { | ||||
|                 _logger.LogError($"Permission denied: {username} has no read access to parent directory {subPath}"); | ||||
|                 _logger.LogError($"Permission denied: {uuid} has no read access to parent directory {subPath}"); | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (!await CheckPermission(user, raDir, OperationType.Read)) | ||||
|             { | ||||
|                 _logger.LogError($"Permission denied: {username} has no read access to parent directory {subPath}"); | ||||
|                 _logger.LogError($"Permission denied: {uuid} has no read access to parent directory {subPath}"); | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var uid = Uid(path); | ||||
|         ResourceAttribute? ra = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid) | ||||
|         ResourceAttribute? ra = await _database | ||||
|             .Table<ResourceAttribute>() | ||||
|             .Where(r => r.Uid == uid) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (ra == null) | ||||
|         { | ||||
|             _logger.LogError($"Permission check failed: User: {username}, Resource: {path}, Type: {type.ToString()} "); | ||||
|             _logger.LogError($"Permission check failed: User: {uuid}, Resource: {path}, Type: {type.ToString()} "); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var l = await CheckPermission(user, ra, type); | ||||
|         if (!l) | ||||
|         { | ||||
|             _logger.LogError($"Permission check failed: User: {username}, Resource: {path}, Type: {type.ToString()} "); | ||||
|             _logger.LogError($"Permission check failed: User: {uuid}, Resource: {path}, Type: {type.ToString()} "); | ||||
|         } | ||||
|  | ||||
|         return l; | ||||
| @@ -135,7 +478,7 @@ public class ResourceService | ||||
|         var owner = await _user.QueryUser(ra.Owner); | ||||
|         if (owner == null) return false; | ||||
|  | ||||
|         bool isOwner = ra.Owner == user.Name; | ||||
|         bool isOwner = ra.Owner == user.Uuid; | ||||
|         bool isPeer = !isOwner && user.Privilege == owner.Privilege; | ||||
|         bool isOther = !isOwner && !isPeer; | ||||
|  | ||||
| @@ -152,7 +495,7 @@ public class ResourceService | ||||
|             case OperationType.Write: | ||||
|                 return currentPerm.Contains('w') || (user.Privilege > owner.Privilege); | ||||
|             case OperationType.Security: | ||||
|                 return (isOwner && currentPerm.Contains('w')) || user.Name == "root"; | ||||
|                 return (isOwner && currentPerm.Contains('w')) || user.Uuid == 1; | ||||
|             default: | ||||
|                 return false; | ||||
|         } | ||||
| @@ -166,8 +509,45 @@ 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) | ||||
| @@ -175,22 +555,35 @@ public class ResourceService | ||||
|         return await Valid(path, token, OperationType.Read, ip); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> GetAll(string[] path, string token, string ip) | ||||
|     { | ||||
|         return await ValidAll(path, token, OperationType.Read, ip); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> Update(string path, string token, string ip) | ||||
|     { | ||||
|         return await Valid(path, token, OperationType.Write, ip); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> Initialize(string path, string token, string username, string ip) | ||||
|     public async Task<bool> Initialize(string path, string token, string owner, string ip) | ||||
|     { | ||||
|         var u = await _user.QueryUser(owner); | ||||
|         if (u == null || u.Uuid == -1) return false; | ||||
|  | ||||
|         return await Initialize(path, token, u.Uuid, ip); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> Initialize(string path, string token, int owner, string ip) | ||||
|     { | ||||
|         // TODO: Use a more elegant Debug mode | ||||
|         if (_config.DebugMode == "Debug") | ||||
|             goto debug; | ||||
|         // 1. Authorization: Verify the operation is performed by 'root' | ||||
|         var requester = _user.Validate(token, ip); | ||||
|         if (requester != "root") | ||||
|         if (requester != 1) | ||||
|         { | ||||
|             _logger.LogWarning( | ||||
|                 $"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to initialize resources."); | ||||
|                 $"Permission denied: Non-root user '{requester}' attempted to initialize resources."); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
| @@ -202,10 +595,10 @@ public class ResourceService | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var ownerUser = await _user.QueryUser(username); | ||||
|         var ownerUser = await _user.QueryUser(owner); | ||||
|         if (ownerUser == null) | ||||
|         { | ||||
|             _logger.LogError($"Initialization failed: Owner user '{username}' does not exist."); | ||||
|             _logger.LogError($"Initialization failed: Owner user '{owner}' does not exist."); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
| @@ -229,8 +622,7 @@ public class ResourceService | ||||
|                     newResources.Add(new ResourceAttribute | ||||
|                     { | ||||
|                         Uid = uid, | ||||
|                         Name = currentPath, | ||||
|                         Owner = username, | ||||
|                         Owner = owner, | ||||
|                         Permission = "rw,--,--" | ||||
|                     }); | ||||
|                 } | ||||
| @@ -241,7 +633,7 @@ public class ResourceService | ||||
|             { | ||||
|                 await _database.InsertAllAsync(newResources); | ||||
|                 _logger.LogInformation( | ||||
|                     $"Successfully initialized {newResources.Count} new resources under '{path}' for user '{username}'."); | ||||
|                     $"Successfully initialized {newResources.Count} new resources under '{path}' for user '{owner}'."); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
| @@ -258,22 +650,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 != "root") | ||||
|         if (requester != 1) | ||||
|         { | ||||
|             _logger.LogWarning($"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to exclude resource '{path}'."); | ||||
|             _logger.LogWarning( | ||||
|                 $"Permission denied: Non-root user '{requester}' attempted to exclude resource '{path}'."); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
| @@ -308,13 +701,13 @@ public class ResourceService | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> Include(string path, string token, string ip, string owner, string permission) | ||||
|     public async Task<bool> Include(string path, string token, string ip, int owner, string permission) | ||||
|     { | ||||
|         var requester = _user.Validate(token, ip); | ||||
|         if (requester != "root") | ||||
|         if (requester != 1) | ||||
|         { | ||||
|             _logger.LogWarning( | ||||
|                 $"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to include resource '{path}'."); | ||||
|                 $"Permission denied: Non-root user '{requester}' attempted to include resource '{path}'."); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
| @@ -346,7 +739,6 @@ public class ResourceService | ||||
|             var newResource = new ResourceAttribute | ||||
|             { | ||||
|                 Uid = uid, | ||||
|                 Name = relPath, | ||||
|                 Owner = owner, | ||||
|                 Permission = permission | ||||
|             }; | ||||
| @@ -388,41 +780,97 @@ 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 | ||||
|         path = Path.GetFullPath(path); | ||||
|  | ||||
|         // Collect targets and permission checks | ||||
|         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}"); | ||||
|                 _logger.LogInformation($"Recursive directory '{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 chmod on '{path}'"); | ||||
|                     return false; | ||||
|                 } | ||||
|  | ||||
|             resource.Permission = permission; | ||||
|             var rowsAffected = await _database.UpdateAsync(resource); | ||||
|  | ||||
|             if (rowsAffected > 0) | ||||
|                 _logger.LogInformation($"Successfully validated chmod on '{path}'."); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 _logger.LogInformation($"Successfully changed permissions for '{path}' to '{permission}'"); | ||||
|                 if (!await Valid(path, token, OperationType.Security, ip)) | ||||
|                 { | ||||
|                     _logger.LogWarning($"Permission denied for chmod on '{path}'"); | ||||
|                     return false; | ||||
|                 } | ||||
|  | ||||
|                 targets.Add(path); | ||||
|             } | ||||
|  | ||||
|             // Build distinct UIDs | ||||
|             var relUids = targets | ||||
|                 .Select(t => Path.GetRelativePath(_config.MediaRoot, t)) | ||||
|                 .Select(rel => Uid(rel)) | ||||
|                 .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|                 .ToList(); | ||||
|  | ||||
|             if (relUids.Count == 0) | ||||
|             { | ||||
|                 _logger.LogWarning($"No targets resolved for chmod on '{path}'"); | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             // Chunked bulk UPDATE using SQL "UPDATE ... WHERE Uid IN (...)" | ||||
|             int updatedCount = 0; | ||||
|             const int sqliteMaxVariableNumber = 900; // leave some headroom for other params | ||||
|             for (int i = 0; i < relUids.Count; i += sqliteMaxVariableNumber) | ||||
|             { | ||||
|                 var chunk = relUids.Skip(i).Take(sqliteMaxVariableNumber).ToList(); | ||||
|                 var placeholders = string.Join(",", chunk.Select(_ => "?")); | ||||
|                 // First param is permission, rest are Uid values | ||||
|                 var args = new List<object> { permission }; | ||||
|                 args.AddRange(chunk); | ||||
|  | ||||
|                 var sql = $"UPDATE ResourceAttributes SET Permission = ? WHERE Uid IN ({placeholders})"; | ||||
|                 try | ||||
|                 { | ||||
|                     var rowsAffected = await _database.ExecuteAsync(sql, args.ToArray()); | ||||
|                     updatedCount += rowsAffected; | ||||
|                     _logger.LogInformation($"Chmod chunk updated {rowsAffected} rows (chunk size {chunk.Count})."); | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     _logger.LogError(ex, $"Error executing chmod update chunk for path '{path}'."); | ||||
|                     // continue with other chunks; do not abort whole operation on one chunk error | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             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; | ||||
|             } | ||||
|         } | ||||
| @@ -433,13 +881,9 @@ public class ResourceService | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public async Task<bool> Chown(string path, string token, string 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) | ||||
|         { | ||||
| @@ -447,29 +891,84 @@ 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 distinct 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 ownership of '{path}' to '{owner}'"); | ||||
|                 _logger.LogWarning($"No targets resolved for chown on '{path}'"); | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             // Chunked bulk UPDATE: SET Owner = ? WHERE Uid IN (...) | ||||
|             int updatedCount = 0; | ||||
|             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 args = new List<object> { owner }; | ||||
|                 args.AddRange(chunk); | ||||
|  | ||||
|                 var sql = $"UPDATE ResourceAttributes SET Owner = ? WHERE Uid IN ({placeholders})"; | ||||
|                 try | ||||
|                 { | ||||
|                     var rowsAffected = await _database.ExecuteAsync(sql, args.ToArray()); | ||||
|                     updatedCount += rowsAffected; | ||||
|                     _logger.LogInformation($"Chown chunk updated {rowsAffected} rows (chunk size {chunk.Count})."); | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     _logger.LogError(ex, $"Error executing chown update chunk for path '{path}'."); | ||||
|                     // continue with remaining chunks | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             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; | ||||
|             } | ||||
|         } | ||||
| @@ -480,7 +979,8 @@ public class ResourceService | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task<bool> InsertRaRow(string fullPath, string owner, string permission, bool update = false) | ||||
|  | ||||
|     private async Task<bool> InsertRaRow(string fullPath, int owner, string permission, bool update = false) | ||||
|     { | ||||
|         if (!PermissionRegex.IsMatch(permission)) | ||||
|         { | ||||
| @@ -494,7 +994,6 @@ public class ResourceService | ||||
|             return await _database.InsertOrReplaceAsync(new ResourceAttribute() | ||||
|             { | ||||
|                 Uid = Uid(path), | ||||
|                 Name = path, | ||||
|                 Owner = owner, | ||||
|                 Permission = permission, | ||||
|             }) == 1; | ||||
| @@ -503,10 +1002,37 @@ public class ResourceService | ||||
|             return await _database.InsertAsync(new ResourceAttribute() | ||||
|             { | ||||
|                 Uid = Uid(path), | ||||
|                 Name = path, | ||||
|                 Owner = owner, | ||||
|                 Permission = permission, | ||||
|             }) == 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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -61,7 +61,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R | ||||
|         if(!await rs.Valid(VideoFolder, token, OperationType.Write, ip)) | ||||
|             return null; | ||||
|         var u = user.Validate(token, ip); | ||||
|         if(u == null) | ||||
|         if(u == -1) | ||||
|             return null; | ||||
|          | ||||
|         var r = new TaskCreationResponse() | ||||
| @@ -74,7 +74,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R | ||||
|         Directory.CreateDirectory(Path.Combine(TaskFolder, r.Id.ToString(), "gallery"));  | ||||
|         // It shouldn't be a problem to spell it directly like this, as all the parameters are generated by myself | ||||
|  | ||||
|         Task v = new Task() | ||||
|         Task v = new Task | ||||
|         { | ||||
|             Name = creation.Name, | ||||
|             Owner = u, | ||||
| @@ -83,7 +83,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R | ||||
|             Type = TaskType.Video | ||||
|         }; | ||||
|          | ||||
|         await System.IO.File.WriteAllTextAsync( | ||||
|         await File.WriteAllTextAsync( | ||||
|             Path.Combine(TaskFolder, r.Id.ToString(), "task.json"),  | ||||
|             JsonConvert.SerializeObject(v, Formatting.Indented)); | ||||
|  | ||||
| @@ -239,7 +239,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R | ||||
|                 return -1; | ||||
|             } | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         catch (Exception) | ||||
|         { | ||||
|             return -1; | ||||
|         } | ||||
|   | ||||
| @@ -14,22 +14,20 @@ namespace Abyss.Components.Services; | ||||
| public class UserService | ||||
| { | ||||
|     private readonly ILogger<UserService> _logger; | ||||
|     private readonly ConfigureService _config; | ||||
|     private readonly IMemoryCache _cache; | ||||
|     private readonly SQLiteAsyncConnection _database; | ||||
|      | ||||
|     public UserService(ILogger<UserService> logger, ConfigureService config, IMemoryCache cache) | ||||
|     { | ||||
|         _logger = logger; | ||||
|         _config = config; | ||||
|         _cache = cache; | ||||
|          | ||||
|         _database = new SQLiteAsyncConnection(config.UserDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create); | ||||
|         _database.CreateTableAsync<User>().Wait(); | ||||
|         var rootUser = _database.Table<User>().Where(x => x.Name == "root").FirstOrDefaultAsync().Result; | ||||
|         var rootUser = _database.Table<User>().Where(x => x.Uuid == 1).FirstOrDefaultAsync().Result; | ||||
|          | ||||
|         if (_config.DebugMode == "Debug") | ||||
|             _cache.Set("root", $"root@127.0.0.1", DateTimeOffset.Now.AddHours(1)); | ||||
|         if (config.DebugMode == "Debug") | ||||
|             _cache.Set("abyss", $"1@127.0.0.1", DateTimeOffset.Now.AddHours(1)); | ||||
|             // Test token, can only be used locally. Will be destroyed in one hour. | ||||
|          | ||||
|         if (rootUser == null) | ||||
| @@ -50,8 +48,9 @@ public class UserService | ||||
|             Console.WriteLine("key: '" + privateKeyBase64 + "'"); | ||||
|             _database.InsertAsync(new User() | ||||
|             { | ||||
|                 Name = "root", | ||||
|                 Parent = "root", | ||||
|                 Uuid = 1, | ||||
|                 Username = "root", | ||||
|                 ParentId = 1, | ||||
|                 PublicKey = publicKeyBase64, | ||||
|                 Privilege = 1145141919, | ||||
|             }).Wait(); | ||||
| @@ -61,15 +60,16 @@ public class UserService | ||||
|     } | ||||
|     public async Task<string?> Challenge(string user) | ||||
|     { | ||||
|         var u = await _database.Table<User>().Where(x => x.Name == user).FirstOrDefaultAsync(); | ||||
|         var u = await _database.Table<User>().Where(x => x.Username == user).FirstOrDefaultAsync(); | ||||
|          | ||||
|         if (u == null) // Error: User not exists | ||||
|             return null; | ||||
|         if (_cache.TryGetValue(u.Name, out var challenge)) // The previous challenge has not yet expired | ||||
|             _cache.Remove(u.Name); | ||||
|          | ||||
|         if (_cache.TryGetValue(u.Uuid, out _)) // The previous challenge has not yet expired | ||||
|             _cache.Remove(u.Uuid); | ||||
|  | ||||
|         var c = Convert.ToBase64String(Encoding.UTF8.GetBytes(GenerateRandomAsciiString(32))); | ||||
|         _cache.Set(u.Name,c, DateTimeOffset.Now.AddMinutes(1)); | ||||
|         _cache.Set(u.Uuid, c, DateTimeOffset.Now.AddMinutes(1)); | ||||
|         return c; | ||||
|     } | ||||
|  | ||||
| @@ -77,12 +77,13 @@ public class UserService | ||||
|     // but the source that obtains the token must be the same as the source that uses the token in the future | ||||
|     public async Task<string?> Verify(string user, string response, string ip) | ||||
|     { | ||||
|         var u = await _database.Table<User>().Where(x => x.Name == user).FirstOrDefaultAsync(); | ||||
|         var u = await _database.Table<User>().Where(x => x.Username == user).FirstOrDefaultAsync(); | ||||
|         if (u == null) // Error: User not exists | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|         if (_cache.TryGetValue(u.Name, out string? challenge)) | ||||
|          | ||||
|         if (_cache.TryGetValue(u.Uuid, out string? challenge)) | ||||
|         { | ||||
|             bool isVerified = VerifySignature( | ||||
|                 PublicKey.Import( | ||||
| @@ -95,16 +96,16 @@ public class UserService | ||||
|             if (!isVerified) | ||||
|             { | ||||
|                 // Verification failed, set the challenge string to random to prevent duplicate verification | ||||
|                 _cache.Set(u.Name, $"failed : {GenerateRandomAsciiString(32)}", DateTimeOffset.Now.AddMinutes(1)); | ||||
|                 _cache.Set(u.Uuid, $"failed : {GenerateRandomAsciiString(32)}", DateTimeOffset.Now.AddMinutes(1)); | ||||
|                 return null; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 // Remove the challenge string and create a session | ||||
|                 _cache.Remove(u.Name); | ||||
|                 _cache.Remove(u.Uuid); | ||||
|                 var s = GenerateRandomAsciiString(64); | ||||
|                 _cache.Set(s, $"{u.Name}@{ip}", DateTimeOffset.Now.AddDays(1)); | ||||
|                 _logger.LogInformation($"Verified {u.Name}@{ip}"); | ||||
|                 _cache.Set(s, $"{u.Uuid}@{ip}", DateTimeOffset.Now.AddDays(1)); | ||||
|                 _logger.LogInformation($"Verified {u.Uuid}@{ip}, Name: {u.Username}"); | ||||
|                 return s; | ||||
|             } | ||||
|         } | ||||
| @@ -112,7 +113,9 @@ public class UserService | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public string? Validate(string token, string ip) | ||||
|     // Id >= 1 : Success, Uid | ||||
|     // Id == -1: Failed | ||||
|     public int Validate(string token, string ip) | ||||
|     { | ||||
|         if (_cache.TryGetValue(token, out string? userAndIp)) | ||||
|         { | ||||
| @@ -120,13 +123,13 @@ public class UserService | ||||
|             { | ||||
|                 _logger.LogError($"Token used from another Host: {token}"); | ||||
|                 Destroy(token); | ||||
|                 return null; | ||||
|                 return -1; | ||||
|             } | ||||
|             // _logger.LogInformation($"Validated {userAndIp}"); | ||||
|             return userAndIp?.Split('@')[0]; | ||||
|             return Convert.ToInt32(userAndIp?.Split('@')[0]); | ||||
|         } | ||||
|         _logger.LogWarning($"Validation failed {token}"); | ||||
|         return null; | ||||
|         return -1; | ||||
|     } | ||||
|      | ||||
|     public void Destroy(string token) | ||||
| @@ -134,16 +137,24 @@ public class UserService | ||||
|         _cache.Remove(token); | ||||
|     } | ||||
|  | ||||
|     public async Task<User?> QueryUser(string user) | ||||
|     public async Task<User?> QueryUser(int uid) | ||||
|     { | ||||
|         var u = await _database.Table<User>().Where(x => x.Name == user).FirstOrDefaultAsync(); | ||||
|         if (uid == -1)  | ||||
|             return null; | ||||
|         var u = await _database.Table<User>().Where(x => x.Uuid == uid).FirstOrDefaultAsync(); | ||||
|         return u; | ||||
|     } | ||||
|      | ||||
|     public async Task<User?> QueryUser(string username) | ||||
|     { | ||||
|         var u = await _database.Table<User>().Where(x => x.Username == username).FirstOrDefaultAsync(); | ||||
|         return u; | ||||
|     } | ||||
|  | ||||
|     public async Task CreateUser(User user) | ||||
|     { | ||||
|         await _database.InsertAsync(user); | ||||
|         _logger.LogInformation($"Created user: {user.Name}, Parent: {user.Parent}, Privilege: {user.Privilege}"); | ||||
|         _logger.LogInformation($"Created user: {user.Username}, Uid: {user.Uuid}, Parent: {user.ParentId}, Privilege: {user.Privilege}"); | ||||
|     } | ||||
|      | ||||
|     static Key GenerateKeyPair() | ||||
| @@ -174,17 +185,44 @@ public class UserService | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     static bool VerifySignature(PublicKey publicKey, byte[] data, byte[] signature) | ||||
|     public static bool VerifySignature(PublicKey publicKey, byte[] data, byte[] signature) | ||||
|     { | ||||
|         var algorithm = SignatureAlgorithm.Ed25519; | ||||
|         return algorithm.Verify(publicKey, data, signature); | ||||
|     } | ||||
|  | ||||
|     public string CreateToken(string user, string ip, TimeSpan lifetime) | ||||
|     public async Task<bool> VerifyAny(byte[] data, byte[] signature) | ||||
|     { | ||||
|         var users = await _database.Table<User>().ToListAsync(); | ||||
|         foreach (var u in users) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var pubKeyBytes = Convert.FromBase64String(u.PublicKey); | ||||
|                 var pubKey = PublicKey.Import( | ||||
|                     SignatureAlgorithm.Ed25519, | ||||
|                     pubKeyBytes, | ||||
|                     KeyBlobFormat.RawPublicKey); | ||||
|  | ||||
|                 if (VerifySignature(pubKey, data, signature)) | ||||
|                 { | ||||
|                     _logger.LogInformation($"Signature verified using user {u.Username}"); | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogWarning(ex, $"Failed to import public key for {u.Username}"); | ||||
|             } | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|      | ||||
|     public string CreateToken(int uid, 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"); | ||||
|         _cache.Set(token, $"{uid}@{ip}", DateTimeOffset.Now.Add(lifetime)); | ||||
|         _logger.LogInformation($"Created token for {uid}@{ip}, valid {lifetime.TotalMinutes} minutes"); | ||||
|         return token; | ||||
|     } | ||||
| } | ||||
| @@ -73,8 +73,6 @@ public static class Helpers | ||||
|         { | ||||
|             return PathType.AccessDenied; | ||||
|         } | ||||
|          | ||||
|         return PathType.NotFound; | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -5,17 +5,21 @@ | ||||
| using System.Buffers; | ||||
| using System.Buffers.Binary; | ||||
| using System.Collections.Concurrent; | ||||
|  | ||||
| using System.Data; | ||||
| using System.Net.Sockets; | ||||
| using System.Runtime.InteropServices; | ||||
| using System.Security.Cryptography; | ||||
|  | ||||
| using System.Text; | ||||
| using Abyss.Components.Services; | ||||
| using Microsoft.AspNetCore.Authentication; | ||||
| using NSec.Cryptography; | ||||
|  | ||||
| using ChaCha20Poly1305 = System.Security.Cryptography.ChaCha20Poly1305; | ||||
|  | ||||
| namespace Abyss.Components.Tools | ||||
| { | ||||
|     // TODO: (complete) Since C25519 has already been used for user authentication, | ||||
|     // TODO: (complete) why not use that public key to verify user identity when establishing a secure channel here? | ||||
|     public sealed class AbyssStream : NetworkStream, IDisposable | ||||
|     { | ||||
|         private const int PublicKeyLength = 32; | ||||
| @@ -61,7 +65,7 @@ namespace Abyss.Components.Tools | ||||
|         /// Handshake: X25519 public exchange (raw) -> shared secret -> HKDF -> AEAD key + saltA + saltB | ||||
|         /// send/recv salts are assigned deterministically by lexicographic comparison of raw public keys. | ||||
|         /// </summary> | ||||
|         public static async Task<AbyssStream> CreateAsync(TcpClient client, byte[]? privateKeyRaw = null, CancellationToken cancellationToken = default) | ||||
|         public static async Task<AbyssStream> CreateAsync(TcpClient client, UserService us, byte[]? privateKeyRaw = null, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             if (client == null) throw new ArgumentNullException(nameof(client)); | ||||
|             var socket = client.Client ?? throw new ArgumentException("TcpClient has no underlying socket"); | ||||
| @@ -102,6 +106,27 @@ namespace Abyss.Components.Tools | ||||
|  | ||||
|             await ReadExactFromSocketAsync(socket, remotePublic, 0, PublicKeyLength, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             var ch = Encoding.UTF8.GetBytes(UserService.GenerateRandomAsciiString(32)); | ||||
|             sent = 0; | ||||
|             while (sent < ch.Length) | ||||
|             { | ||||
|                 var toSend = new ReadOnlyMemory<byte>(ch, sent, ch.Length - sent); | ||||
|                 sent += await socket.SendAsync(toSend, SocketFlags.None, cancellationToken).ConfigureAwait(false); | ||||
|             } | ||||
|              | ||||
|             var rch = new byte[64]; | ||||
|             await ReadExactFromSocketAsync(socket, rch, 0, 64, cancellationToken).ConfigureAwait(false); | ||||
|             bool rau = await us.VerifyAny(ch, rch); | ||||
|             if (!rau) throw new AuthenticationFailureException(""); | ||||
|              | ||||
|             var ack = Encoding.UTF8.GetBytes(UserService.GenerateRandomAsciiString(16)); | ||||
|             sent = 0; | ||||
|             while (sent < ack.Length) | ||||
|             { | ||||
|                 var toSend = new ReadOnlyMemory<byte>(ack, sent, ack.Length - sent); | ||||
|                 sent += await socket.SendAsync(toSend, SocketFlags.None, cancellationToken).ConfigureAwait(false); | ||||
|             } | ||||
|  | ||||
|             // 3) Compute shared secret (X25519) | ||||
|             PublicKey remotePub; | ||||
|             try | ||||
| @@ -394,18 +419,21 @@ namespace Abyss.Components.Tools | ||||
|             } | ||||
|  | ||||
|             var payloadLen = unchecked((uint)(ciphertext.Length + tag.Length)); | ||||
|             var header = new byte[4]; | ||||
|             BinaryPrimitives.WriteUInt32BigEndian(header, payloadLen); | ||||
|  | ||||
|             await base.WriteAsync(header, 0, header.Length, cancellationToken).ConfigureAwait(false); | ||||
|             var packet = new byte[4 + payloadLen]; | ||||
|             BinaryPrimitives.WriteUInt32BigEndian(packet.AsSpan(0, 4), payloadLen); | ||||
|  | ||||
|             if (ciphertext.Length > 0) | ||||
|                 await base.WriteAsync(ciphertext, 0, ciphertext.Length, cancellationToken).ConfigureAwait(false); | ||||
|             await base.WriteAsync(tag, 0, tag.Length, cancellationToken).ConfigureAwait(false); | ||||
|                 ciphertext.CopyTo(packet.AsSpan(4)); | ||||
|             tag.CopyTo(packet.AsSpan(4 + ciphertext.Length)); | ||||
|  | ||||
|             await base.WriteAsync(packet, 0, packet.Length, cancellationToken).ConfigureAwait(false); | ||||
|             await base.FlushAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             Array.Clear(nonce, 0, nonce.Length); | ||||
|             Array.Clear(tag, 0, tag.Length); | ||||
|             Array.Clear(ciphertext, 0, ciphertext.Length); | ||||
|             Array.Clear(packet, 0, packet.Length); | ||||
|         } | ||||
|  | ||||
|         protected override void Dispose(bool disposing) | ||||
| @@ -518,7 +546,7 @@ namespace Abyss.Components.Tools | ||||
|  | ||||
|     public static class TcpClientAbyssExtensions | ||||
|     { | ||||
|         public static Task<AbyssStream> GetAbyssStreamAsync(this TcpClient client, byte[]? privateKeyRaw = null, CancellationToken ct = default) | ||||
|             => AbyssStream.CreateAsync(client, privateKeyRaw, ct); | ||||
|         public static Task<AbyssStream> GetAbyssStreamAsync(this TcpClient client, UserService us, byte[]? privateKeyRaw = null, CancellationToken ct = default) | ||||
|             => AbyssStream.CreateAsync(client, us, privateKeyRaw, ct); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -12,6 +12,8 @@ public class Comic | ||||
|     public List<Bookmark> Bookmarks { get; set; } = new(); | ||||
|     [JsonProperty("author")] | ||||
|     public string Author { get; set; } = "";  | ||||
|     [JsonProperty("tags")] | ||||
|     public List<string> Tags { get; set; } = new(); | ||||
|     [JsonProperty("list")] | ||||
|     public List<string> List { get; set; } = new(); | ||||
| } | ||||
| @@ -1,9 +1,17 @@ | ||||
| using SQLite; | ||||
|  | ||||
| namespace Abyss.Model; | ||||
|  | ||||
| [Table("ResourceAttributes")] | ||||
| public class ResourceAttribute | ||||
| { | ||||
|     public string Uid { get; set; } = "@"; | ||||
|     public string Name { get; set; } = "@"; | ||||
|     public string Owner { get; set; } = "@"; | ||||
|     [PrimaryKey, AutoIncrement] | ||||
|     public int Id { get; set; } | ||||
|      | ||||
|     [Unique, NotNull] | ||||
|     public string Uid { get; init; } = "@"; | ||||
|     [NotNull] | ||||
|     public int Owner { get; set; } | ||||
|     [NotNull] | ||||
|     public string Permission { get; set; } = "--,--,--"; | ||||
| } | ||||
| @@ -9,7 +9,7 @@ public enum TaskType | ||||
| public class Task | ||||
| { | ||||
|     public uint Id; | ||||
|     public string Owner = ""; | ||||
|     public int Owner; | ||||
|     public string Class = ""; | ||||
|     public string Name = ""; | ||||
|     public TaskType Type; | ||||
|   | ||||
| @@ -1,9 +1,18 @@ | ||||
| using SQLite; | ||||
|  | ||||
| namespace Abyss.Model; | ||||
|  | ||||
| [Table("Users")] | ||||
| public class User | ||||
| {  | ||||
|     public string Name { get; set; } = ""; | ||||
|     public string Parent { get; set; } = ""; | ||||
|     [PrimaryKey, AutoIncrement] | ||||
|     public int Uuid { get; set; } | ||||
|     [Unique, NotNull] | ||||
|     public string Username { get; set; } = ""; | ||||
|     [NotNull] | ||||
|     public int ParentId { get; set; } | ||||
|     [NotNull] | ||||
|     public string PublicKey { get; set; } = ""; | ||||
|     [NotNull] | ||||
|     public int Privilege  { get; set; } | ||||
| } | ||||
| @@ -4,7 +4,6 @@ public class UserCreating | ||||
| { | ||||
|     public string Response { get; set; } = ""; | ||||
|     public string Name { get; set; } = ""; | ||||
|     public string Parent { get; set; } = ""; | ||||
|     public string PublicKey { get; set; } = ""; | ||||
|     public int Privilege  { get; set; } | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System.Threading.RateLimiting; | ||||
| using Abyss.Components.Controllers.Middleware; | ||||
| using Abyss.Components.Controllers.Task; | ||||
| using Abyss.Components.Services; | ||||
| using Microsoft.AspNetCore.RateLimiting; | ||||
| @@ -38,11 +39,10 @@ public class Program | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         builder.Services.BuildServiceProvider().GetRequiredService<UserService>(); | ||||
|  | ||||
|         var app = builder.Build(); | ||||
|  | ||||
|         // app.UseHttpsRedirection(); | ||||
|         app.UseMiddleware<BadRequestExceptionMiddleware>(); | ||||
|         app.UseAuthorization(); | ||||
|         app.MapControllers(); | ||||
|          | ||||
|   | ||||
| @@ -9,7 +9,8 @@ | ||||
|       "environmentVariables": { | ||||
|         "ASPNETCORE_ENVIRONMENT": "Development", | ||||
|         "MEDIA_ROOT" : "/storage", | ||||
|         "ALLOWED_PORTS" : "3000" | ||||
|         "ALLOWED_PORTS" : "3000", | ||||
|         "DEBUG_MODE": "Debug" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   | ||||
							
								
								
									
										321
									
								
								Abyss/Toolkits/abyss-cli.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										321
									
								
								Abyss/Toolkits/abyss-cli.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,321 @@ | ||||
| #!/usr/bin/env python3 | ||||
| # abysscli.py | ||||
| """ | ||||
| Abyss CLI — Python 3 实现 | ||||
| Commands: | ||||
|   open <baseUrl> <user> <privateKeyBase64> | ||||
|   destroy <baseUrl> <token> | ||||
|   valid <baseUrl> <token> | ||||
|   create <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege> | ||||
| """ | ||||
| from __future__ import annotations | ||||
| import sys | ||||
| import argparse | ||||
| import base64 | ||||
| import json | ||||
| import typing as t | ||||
| from urllib.parse import quote | ||||
| import requests | ||||
| from requests import Session | ||||
| from nacl.signing import SigningKey | ||||
| from nacl.exceptions import BadSignatureError | ||||
|  | ||||
| # ---- Utilities for Ed25519 handling ---- | ||||
| def generate_keypair_base64() -> t.Tuple[str, str]: | ||||
|     """ | ||||
|     Generate Ed25519 keypair. | ||||
|     Returns (private_base64, public_base64). | ||||
|     private is encoded as 64 bytes: seed(32) || pub(32) to align with many raw-private formats. | ||||
|     public is 32 bytes. | ||||
|     """ | ||||
|     sk = SigningKey.generate() | ||||
|     seed = sk.encode()  # 32 bytes seed | ||||
|     vk = sk.verify_key.encode()  # 32 bytes pubkey | ||||
|     priv_raw = seed + vk  # 64 bytes | ||||
|     return base64.b64encode(priv_raw).decode('ascii'), base64.b64encode(vk).decode('ascii') | ||||
|  | ||||
| def sign_with_private_base64(private_base64: str, data: bytes) -> str: | ||||
|     """ | ||||
|     Accept private key as base64. Supports: | ||||
|       - 32-byte seed (seed only) | ||||
|       - 64-byte raw private (seed + pub) | ||||
|     Returns base64(signature). | ||||
|     """ | ||||
|     try: | ||||
|         raw = base64.b64decode(private_base64) | ||||
|     except Exception as e: | ||||
|         raise ValueError(f"privateKeyBase64 is not valid base64: {e}") | ||||
|     if len(raw) == 32: | ||||
|         seed = raw | ||||
|     elif len(raw) == 64: | ||||
|         seed = raw[:32] | ||||
|     else: | ||||
|         raise ValueError(f"Unsupported private key length: {len(raw)} bytes (expected 32 or 64)") | ||||
|     sk = SigningKey(seed) | ||||
|     sig = sk.sign(data).signature  # 64 bytes | ||||
|     return base64.b64encode(sig).decode('ascii') | ||||
|  | ||||
| # ---- HTTP helpers ---- | ||||
| def create_session(base_url: str) -> Session: | ||||
|     s = requests.Session() | ||||
|     base = base_url.rstrip('/') | ||||
|     s.headers.update({'User-Agent': 'AbyssCli-Python/1.0'}) | ||||
|     s.base_url = base + '/'  # attach attribute for convenience | ||||
|     return s | ||||
|  | ||||
| def _full_url(session: Session, path: str) -> str: | ||||
|     base = getattr(session, 'base_url', '') | ||||
|     # ensure no double slashes issues | ||||
|     return base + path.lstrip('/') | ||||
|  | ||||
| def try_read_response_text(resp: requests.Response) -> str: | ||||
|     try: | ||||
|         return resp.text or "" | ||||
|     except Exception: | ||||
|         return "" | ||||
|  | ||||
| def parse_possibly_json_string(text: str) -> str: | ||||
|     """ | ||||
|     Server sometimes returns a JSON-encoded string like: "username" | ||||
|     Try json.loads first, fall back to trimming quotes. | ||||
|     """ | ||||
|     if text is None: | ||||
|         return "" | ||||
|     text = text.strip() | ||||
|     if not text: | ||||
|         return "" | ||||
|     try: | ||||
|         parsed = json.loads(text) | ||||
|         # If parsed is a string, return it; otherwise return original trimmed | ||||
|         if isinstance(parsed, str): | ||||
|             return parsed | ||||
|         # otherwise return textual representation | ||||
|         return str(parsed) | ||||
|     except Exception: | ||||
|         # fallback trim quotes only at ends if present | ||||
|         if text.startswith('"') and text.endswith('"') and len(text) >= 2: | ||||
|             return text[1:-1] | ||||
|         return text | ||||
|  | ||||
| # ---- Command implementations ---- | ||||
| def cmd_open(args: argparse.Namespace) -> int: | ||||
|     base = args.baseUrl | ||||
|     user = args.user | ||||
|     priv_base64 = args.privateKeyBase64 | ||||
|  | ||||
|     sess = create_session(base) | ||||
|  | ||||
|     # 1. GET challenge | ||||
|     url = _full_url(sess, f"api/user/{quote(user, safe='')}") | ||||
|     try: | ||||
|         r = sess.get(url, timeout=15) | ||||
|     except Exception as e: | ||||
|         print(f"Failed to GET challenge: {e}", file=sys.stderr) | ||||
|         return 1 | ||||
|     if not r.ok: | ||||
|         print(f"Failed to get challenge: HTTP {r.status_code}", file=sys.stderr) | ||||
|         txt = try_read_response_text(r) | ||||
|         if txt: | ||||
|             print(txt, file=sys.stderr) | ||||
|         return 1 | ||||
|  | ||||
|     challenge_text = try_read_response_text(r) | ||||
|     challenge = parse_possibly_json_string(challenge_text) | ||||
|     # challenge is expected to be base64-encoded bytes | ||||
|     try: | ||||
|         challenge_bytes = base64.b64decode(challenge) | ||||
|     except Exception: | ||||
|         print("Challenge is not valid base64.", file=sys.stderr) | ||||
|         return 1 | ||||
|  | ||||
|     # 2. Sign | ||||
|     try: | ||||
|         signature_base64 = sign_with_private_base64(priv_base64, challenge_bytes) | ||||
|     except Exception as e: | ||||
|         print(f"Signing failed: {e}", file=sys.stderr) | ||||
|         return 1 | ||||
|  | ||||
|     # 3. POST response to get token | ||||
|     post_url = _full_url(sess, f"api/user/{quote(user, safe='')}") | ||||
|     payload = {"Response": signature_base64} | ||||
|     try: | ||||
|         r2 = sess.post(post_url, json=payload, timeout=15) | ||||
|     except Exception as e: | ||||
|         print(f"Failed to POST response: {e}", file=sys.stderr) | ||||
|         return 1 | ||||
|     if not r2.ok: | ||||
|         print(f"Authentication failed: HTTP {r2.status_code}", file=sys.stderr) | ||||
|         txt = try_read_response_text(r2) | ||||
|         if txt: | ||||
|             print(txt, file=sys.stderr) | ||||
|         return 1 | ||||
|  | ||||
|     token_text = try_read_response_text(r2) | ||||
|     token = parse_possibly_json_string(token_text) | ||||
|     if not token: | ||||
|         print("Authentication failed or server returned no token.", file=sys.stderr) | ||||
|         return 1 | ||||
|  | ||||
|     print(token) | ||||
|     return 0 | ||||
|  | ||||
| def cmd_destroy(args: argparse.Namespace) -> int: | ||||
|     base = args.baseUrl | ||||
|     token = args.token | ||||
|     sess = create_session(base) | ||||
|     url = _full_url(sess, f"api/user/destroy?token={quote(token, safe='')}") | ||||
|     try: | ||||
|         r = sess.post(url, timeout=15) | ||||
|     except Exception as e: | ||||
|         print(f"Destroy request failed: {e}", file=sys.stderr) | ||||
|         return 1 | ||||
|     if not r.ok: | ||||
|         print(f"Destroy failed: HTTP {r.status_code}", file=sys.stderr) | ||||
|         txt = try_read_response_text(r) | ||||
|         if txt: | ||||
|             print(txt, file=sys.stderr) | ||||
|         return 1 | ||||
|     # some servers return body, but original prints "Success" | ||||
|     print("Success") | ||||
|     return 0 | ||||
|  | ||||
| def cmd_valid(args: argparse.Namespace) -> int: | ||||
|     base = args.baseUrl | ||||
|     token = args.token | ||||
|     sess = create_session(base) | ||||
|     url = _full_url(sess, f"api/user/validate?token={quote(token, safe='')}") | ||||
|     try: | ||||
|         r = sess.post(url, timeout=15) | ||||
|     except Exception as e: | ||||
|         print(f"Validate request failed: {e}", file=sys.stderr) | ||||
|         return 1 | ||||
|     if not r.ok: | ||||
|         print("Invalid") | ||||
|         return 1 | ||||
|     content = try_read_response_text(r) | ||||
|     username = parse_possibly_json_string(content) | ||||
|     if not username: | ||||
|         print("Invalid") | ||||
|         return 1 | ||||
|     print(username) | ||||
|     return 0 | ||||
|  | ||||
| def cmd_create(args: argparse.Namespace) -> int: | ||||
|     base = args.baseUrl | ||||
|     user = args.user | ||||
|     priv_base64 = args.privateKeyBase64 | ||||
|     new_username = args.newUsername | ||||
|     privilege = args.privilege | ||||
|  | ||||
|     sess = create_session(base) | ||||
|  | ||||
|     # 1. Get challenge for creator | ||||
|     url = _full_url(sess, f"api/user/{quote(user, safe='')}") | ||||
|     try: | ||||
|         r = sess.get(url, timeout=15) | ||||
|     except Exception as e: | ||||
|         print(f"Failed to GET challenge for creator: {e}", file=sys.stderr) | ||||
|         return 1 | ||||
|     if not r.ok: | ||||
|         print(f"Failed to get challenge for creator: HTTP {r.status_code}", file=sys.stderr) | ||||
|         txt = try_read_response_text(r) | ||||
|         if txt: | ||||
|             print(txt, file=sys.stderr) | ||||
|         return 1 | ||||
|     challenge_text = try_read_response_text(r) | ||||
|     challenge = parse_possibly_json_string(challenge_text) | ||||
|     try: | ||||
|         challenge_bytes = base64.b64decode(challenge) | ||||
|     except Exception: | ||||
|         print("Challenge is not valid base64.", file=sys.stderr) | ||||
|         return 1 | ||||
|  | ||||
|     # 2. Sign challenge with creator private key | ||||
|     try: | ||||
|         signature_base64 = sign_with_private_base64(priv_base64, challenge_bytes) | ||||
|     except Exception as e: | ||||
|         print(f"Signing failed: {e}", file=sys.stderr) | ||||
|         return 1 | ||||
|  | ||||
|     # 3. Generate key pair for new user | ||||
|     new_priv_b64, new_pub_b64 = generate_keypair_base64() | ||||
|  | ||||
|     # 4. Build create payload and PATCH | ||||
|     payload = { | ||||
|         "Response": signature_base64, | ||||
|         "Name": new_username, | ||||
|         "Parent": user, | ||||
|         "Privilege": int(privilege), | ||||
|         "PublicKey": new_pub_b64 | ||||
|     } | ||||
|     patch_url = _full_url(sess, f"api/user/{quote(user, safe='')}") | ||||
|     try: | ||||
|         r2 = sess.request("PATCH", patch_url, json=payload, timeout=15) | ||||
|     except Exception as e: | ||||
|         print(f"Create request failed: {e}", file=sys.stderr) | ||||
|         return 1 | ||||
|     resp_text = try_read_response_text(r2) | ||||
|     if not r2.ok: | ||||
|         print(f"Create failed: HTTP {r2.status_code}", file=sys.stderr) | ||||
|         if resp_text: | ||||
|             print(resp_text, file=sys.stderr) | ||||
|         return 1 | ||||
|  | ||||
|     print("Success") | ||||
|     print("NewUserPrivateKeyBase64:") | ||||
|     print(new_priv_b64) | ||||
|     print("NewUserPublicKeyBase64:") | ||||
|     print(new_pub_b64) | ||||
|     return 0 | ||||
|  | ||||
| # ---- CLI entrypoint ---- | ||||
| def main(argv: t.Optional[t.List[str]] = None) -> int: | ||||
|     if argv is None: | ||||
|         argv = sys.argv[1:] | ||||
|     parser = argparse.ArgumentParser(prog="AbyssCli", description="Abyss CLI (Python)") | ||||
|     sub = parser.add_subparsers(dest="cmd") | ||||
|  | ||||
|     p_open = sub.add_parser("open", help="open <baseUrl> <user> <privateKeyBase64>") | ||||
|     p_open.add_argument("baseUrl") | ||||
|     p_open.add_argument("user") | ||||
|     p_open.add_argument("privateKeyBase64") | ||||
|  | ||||
|     p_destroy = sub.add_parser("destroy", help="destroy <baseUrl> <token>") | ||||
|     p_destroy.add_argument("baseUrl") | ||||
|     p_destroy.add_argument("token") | ||||
|  | ||||
|     p_valid = sub.add_parser("valid", help="valid <baseUrl> <token>") | ||||
|     p_valid.add_argument("baseUrl") | ||||
|     p_valid.add_argument("token") | ||||
|  | ||||
|     p_create = sub.add_parser("create", help="create <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>") | ||||
|     p_create.add_argument("baseUrl") | ||||
|     p_create.add_argument("user") | ||||
|     p_create.add_argument("privateKeyBase64") | ||||
|     p_create.add_argument("newUsername") | ||||
|     p_create.add_argument("privilege", type=int) | ||||
|  | ||||
|     if not argv: | ||||
|         parser.print_help() | ||||
|         return 1 | ||||
|     args = parser.parse_args(argv) | ||||
|  | ||||
|     try: | ||||
|         if args.cmd == "open": | ||||
|             return cmd_open(args) | ||||
|         elif args.cmd == "destroy": | ||||
|             return cmd_destroy(args) | ||||
|         elif args.cmd == "valid": | ||||
|             return cmd_valid(args) | ||||
|         elif args.cmd == "create": | ||||
|             return cmd_create(args) | ||||
|         else: | ||||
|             print("Unknown command.", file=sys.stderr) | ||||
|             parser.print_help() | ||||
|             return 2 | ||||
|     except Exception as ex: | ||||
|         print(f"Error: {ex}", file=sys.stderr) | ||||
|         return 3 | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     sys.exit(main()) | ||||
| @@ -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) | ||||
|   | ||||
| @@ -1,16 +0,0 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|  | ||||
|     <PropertyGroup> | ||||
|         <OutputType>Exe</OutputType> | ||||
|         <TargetFramework>net9.0</TargetFramework> | ||||
|         <ImplicitUsings>enable</ImplicitUsings> | ||||
|         <Nullable>enable</Nullable> | ||||
|         <PublishAot>true</PublishAot> | ||||
|         <InvariantGlobalization>true</InvariantGlobalization> | ||||
|     </PropertyGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|       <PackageReference Include="NSec.Cryptography" Version="25.4.0" /> | ||||
|     </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
| @@ -1,364 +0,0 @@ | ||||
| // Program.cs | ||||
| using System; | ||||
| using System.Net.Http; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading.Tasks; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization.Metadata; | ||||
| using NSec.Cryptography; | ||||
|  | ||||
| public class ChallengeRequestBody | ||||
| { | ||||
|     public string Response { get; set; } = ""; | ||||
| } | ||||
|  | ||||
| public class CreateRequestBody | ||||
| { | ||||
|     public string Response { get; set; } = ""; | ||||
|     public string Name { get; set; } = ""; | ||||
|     public string Parent { get; set; } = ""; | ||||
|     public int Privilege { get; set; } | ||||
|     public string PublicKey { get; set; } = ""; | ||||
| } | ||||
|  | ||||
| public static class Ed25519Utils | ||||
| { | ||||
|     public static (string privateBase64, string publicBase64) GenerateKeyPairBase64() | ||||
|     { | ||||
|         var algorithm = SignatureAlgorithm.Ed25519; | ||||
|         var creationParameters = new KeyCreationParameters | ||||
|         { | ||||
|             ExportPolicy = KeyExportPolicies.AllowPlaintextExport | ||||
|         }; | ||||
|         using var key = Key.Create(algorithm, creationParameters); | ||||
|         var priv = key.Export(KeyBlobFormat.RawPrivateKey); | ||||
|         var pub = key.Export(KeyBlobFormat.RawPublicKey); | ||||
|         return (Convert.ToBase64String(priv), Convert.ToBase64String(pub)); | ||||
|     } | ||||
|  | ||||
|     public static string SignBase64PrivateKey(string privateKeyBase64, byte[] dataToSign) | ||||
|     { | ||||
|         var algorithm = SignatureAlgorithm.Ed25519; | ||||
|         var privateBytes = Convert.FromBase64String(privateKeyBase64); | ||||
|         using var key = Key.Import(algorithm, privateBytes, KeyBlobFormat.RawPrivateKey); | ||||
|         var sig = algorithm.Sign(key, dataToSign); | ||||
|         return Convert.ToBase64String(sig); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public static class Program | ||||
| { | ||||
|     static async Task<int> Main(string[] args) | ||||
|     { | ||||
|         if (args == null || args.Length == 0) | ||||
|         { | ||||
|             PrintUsage(); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         var cmd = args[0].ToLowerInvariant(); | ||||
|         try | ||||
|         { | ||||
|             switch (cmd) | ||||
|             { | ||||
|                 case "open": | ||||
|                     return await CmdOpen(args); | ||||
|                 case "destroy": | ||||
|                     return await CmdDestroy(args); | ||||
|                 case "valid": | ||||
|                     return await CmdValid(args); | ||||
|                 case "create": | ||||
|                     return await CmdCreate(args); | ||||
|                 default: | ||||
|                     Console.Error.WriteLine("Unknown command."); | ||||
|                     PrintUsage(); | ||||
|                     return 2; | ||||
|             } | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             Console.Error.WriteLine($"Error: {ex.Message}"); | ||||
|             return 3; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     static void PrintUsage() | ||||
|     { | ||||
|         Console.WriteLine("Usage:"); | ||||
|         Console.WriteLine("  AbyssCli open <baseUrl> <user> <privateKeyBase64>"); | ||||
|         Console.WriteLine("  AbyssCli destroy <baseUrl> <token>"); | ||||
|         Console.WriteLine("  AbyssCli valid <baseUrl> <token>"); | ||||
|         Console.WriteLine("  AbyssCli create <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>"); | ||||
|     } | ||||
|  | ||||
|     static HttpClient CreateHttpClient(string baseUrl) | ||||
|     { | ||||
|         var client = new HttpClient(); | ||||
|         client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); | ||||
|         return client; | ||||
|     } | ||||
|  | ||||
|     static async Task<int> CmdOpen(string[] args) | ||||
|     { | ||||
|         if (args.Length != 4) | ||||
|         { | ||||
|             Console.Error.WriteLine("open requires 3 arguments: <baseUrl> <user> <privateKeyBase64>"); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         var baseUrl = args[1]; | ||||
|         var user = args[2]; | ||||
|         var privateKeyBase64 = args[3]; | ||||
|  | ||||
|         using var client = CreateHttpClient(baseUrl); | ||||
|  | ||||
|         // 1. GET challenge | ||||
|         var challenge = await GetChallenge(client, user); | ||||
|         if (challenge == null) | ||||
|         { | ||||
|             Console.Error.WriteLine("Failed to get challenge."); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         // 2. Sign challenge (challenge is base64 string) | ||||
|         byte[] challengeBytes; | ||||
|         try | ||||
|         { | ||||
|             challengeBytes = Convert.FromBase64String(challenge); | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             Console.Error.WriteLine("Challenge is not valid base64."); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         string signatureBase64; | ||||
|         try | ||||
|         { | ||||
|             signatureBase64 = Ed25519Utils.SignBase64PrivateKey(privateKeyBase64, challengeBytes); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             Console.Error.WriteLine($"Signing failed: {ex.Message}"); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         // 3. POST response to get token | ||||
|         var token = await PostResponseForToken(client, user, signatureBase64); | ||||
|         if (token == null) | ||||
|         { | ||||
|             Console.Error.WriteLine("Authentication failed or server returned no token."); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         Console.WriteLine(token); | ||||
|         return 0; | ||||
|     } | ||||
|  | ||||
|     static async Task<int> CmdDestroy(string[] args) | ||||
|     { | ||||
|         if (args.Length != 3) | ||||
|         { | ||||
|             Console.Error.WriteLine("destroy requires 2 arguments: <baseUrl> <token>"); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         var baseUrl = args[1]; | ||||
|         var token = args[2]; | ||||
|  | ||||
|         using var client = CreateHttpClient(baseUrl); | ||||
|  | ||||
|         var resp = await client.PostAsync($"api/user/destroy?token={token}", null); | ||||
|         if (!resp.IsSuccessStatusCode) | ||||
|         { | ||||
|             Console.Error.WriteLine($"Destroy failed: {resp.StatusCode}"); | ||||
|             var txt = await TryReadResponseText(resp); | ||||
|             if (!string.IsNullOrEmpty(txt)) Console.Error.WriteLine(txt); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         var body = await resp.Content.ReadAsStringAsync(); | ||||
|         Console.WriteLine("Success"); | ||||
|         return 0; | ||||
|     } | ||||
|  | ||||
|     static async Task<int> CmdValid(string[] args) | ||||
|     { | ||||
|         if (args.Length != 3) | ||||
|         { | ||||
|             Console.Error.WriteLine("valid requires 2 arguments: <baseUrl> <token>"); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         var baseUrl = args[1]; | ||||
|         var token = args[2]; | ||||
|  | ||||
|         using var client = CreateHttpClient(baseUrl); | ||||
|  | ||||
|         var resp = await client.PostAsync($"api/user/validate?token={token}", null); | ||||
|         if (!resp.IsSuccessStatusCode) | ||||
|         { | ||||
|             Console.WriteLine("Invalid"); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         var content = await resp.Content.ReadAsStringAsync(); | ||||
|         // server likely returns JSON string (e.g. "username"), try to parse JSON string | ||||
|         try | ||||
|         { | ||||
|             var username = JsonSerializer.Deserialize<string>(content, jsonOptions); | ||||
|             if (username == null) | ||||
|             { | ||||
|                 Console.WriteLine("Invalid"); | ||||
|                 return 1; | ||||
|             } | ||||
|             Console.WriteLine(username); | ||||
|             return 0; | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             // fallback | ||||
|             Console.WriteLine(content.Trim('"')); | ||||
|             return 0; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     static async Task<int> CmdCreate(string[] args) | ||||
|     { | ||||
|         if (args.Length != 6) | ||||
|         { | ||||
|             Console.Error.WriteLine("create requires 5 arguments: <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>"); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         var baseUrl = args[1]; | ||||
|         var user = args[2]; | ||||
|         var privateKeyBase64 = args[3]; | ||||
|         var newUsername = args[4]; | ||||
|         if (!int.TryParse(args[5], out var privilege)) | ||||
|         { | ||||
|             Console.Error.WriteLine("Privilege must be an integer."); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         using var client = CreateHttpClient(baseUrl); | ||||
|  | ||||
|         // 1. Get challenge for creator user | ||||
|         var challenge = await GetChallenge(client, user); | ||||
|         if (challenge == null) | ||||
|         { | ||||
|             Console.Error.WriteLine("Failed to get challenge for creator."); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         byte[] challengeBytes; | ||||
|         try | ||||
|         { | ||||
|             challengeBytes = Convert.FromBase64String(challenge); | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             Console.Error.WriteLine("Challenge is not valid base64."); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         string signatureBase64; | ||||
|         try | ||||
|         { | ||||
|             signatureBase64 = Ed25519Utils.SignBase64PrivateKey(privateKeyBase64, challengeBytes); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             Console.Error.WriteLine($"Signing failed: {ex.Message}"); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         // 2. Generate key pair for new user | ||||
|         var (newPrivBase64, newPubBase64) = Ed25519Utils.GenerateKeyPairBase64(); | ||||
|  | ||||
|         // 3. Build create payload | ||||
|         var payload = new CreateRequestBody | ||||
|         { | ||||
|             Response = signatureBase64, | ||||
|             Name = newUsername, | ||||
|             Parent = user, | ||||
|             Privilege = privilege, | ||||
|             PublicKey = newPubBase64 | ||||
|         }; | ||||
|  | ||||
|         var json = JsonSerializer.Serialize(payload, jsonOptions); | ||||
|         var request = new HttpRequestMessage(new HttpMethod("PATCH"), $"api/user/{Uri.EscapeDataString(user)}") | ||||
|         { | ||||
|             Content = new StringContent(json, Encoding.UTF8, "application/json") | ||||
|         }; | ||||
|  | ||||
|         var resp = await client.SendAsync(request); | ||||
|         var respText = await TryReadResponseText(resp); | ||||
|         if (!resp.IsSuccessStatusCode) | ||||
|         { | ||||
|             Console.Error.WriteLine($"Create failed: {resp.StatusCode}"); | ||||
|             if (!string.IsNullOrEmpty(respText)) Console.Error.WriteLine(respText); | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         Console.WriteLine("Success"); | ||||
|         Console.WriteLine("NewUserPrivateKeyBase64:"); | ||||
|         Console.WriteLine(newPrivBase64); | ||||
|         Console.WriteLine("NewUserPublicKeyBase64:"); | ||||
|         Console.WriteLine(newPubBase64); | ||||
|         return 0; | ||||
|     } | ||||
|  | ||||
|     static async Task<string?> GetChallenge(HttpClient client, string user) | ||||
|     { | ||||
|         var resp = await client.GetAsync($"api/user/{Uri.EscapeDataString(user)}"); | ||||
|         if (!resp.IsSuccessStatusCode) return null; | ||||
|         var content = await resp.Content.ReadAsStringAsync(); | ||||
|  | ||||
|         // server probably returns JSON string; try to deserialize to string | ||||
|         try | ||||
|         { | ||||
|             var s = JsonSerializer.Deserialize<string>(content, jsonOptions); | ||||
|             if (s != null) return s; | ||||
|         } | ||||
|         catch { /* ignore */ } | ||||
|  | ||||
|         // fallback: trim quotes | ||||
|         return content.Trim('"'); | ||||
|     } | ||||
|  | ||||
|     static async Task<string?> PostResponseForToken(HttpClient client, string user, string signatureBase64) | ||||
|     { | ||||
|         var body = new ChallengeRequestBody { Response = signatureBase64 }; | ||||
|         var json = JsonSerializer.Serialize(body, jsonOptions); | ||||
|         var resp = await client.PostAsync($"api/user/{Uri.EscapeDataString(user)}", | ||||
|             new StringContent(json, Encoding.UTF8, "application/json")); | ||||
|         if (!resp.IsSuccessStatusCode) return null; | ||||
|         var content = await resp.Content.ReadAsStringAsync(); | ||||
|         try | ||||
|         { | ||||
|             var token = JsonSerializer.Deserialize<string>(content, jsonOptions); | ||||
|             if (!string.IsNullOrEmpty(token)) return token; | ||||
|         } | ||||
|         catch { /* ignore */ } | ||||
|         return content.Trim('"'); | ||||
|     } | ||||
|  | ||||
|     static async Task<string> TryReadResponseText(HttpResponseMessage resp) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             return await resp.Content.ReadAsStringAsync(); | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             return ""; | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     static readonly JsonSerializerOptions jsonOptions = new() | ||||
|     { | ||||
|         TypeInfoResolver = new DefaultJsonTypeInfoResolver() | ||||
|     }; | ||||
| } | ||||
							
								
								
									
										55
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										55
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,16 +1,59 @@ | ||||
| _<div align="center"> | ||||
| <div align="center"> | ||||
|  | ||||
| # Abyss (Server for Aether) | ||||
|  | ||||
| [](https://github.com/rootacite/Abyss) | ||||
|  | ||||
| _🚀This is the server of the multimedia application Aether, which can also be extended to other purposes🚀_ | ||||
| 🚀This is the server of the multimedia application Aether, which can also be extended to other purposes🚀 | ||||
|  | ||||
| <img src="abyss_clip.png" width="25%" alt="Logo"> | ||||
|  | ||||
| </div> | ||||
|  | ||||
| <br/> | ||||
| <br/> | ||||
| <br/> | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Description | ||||
|  | ||||
| **Abyss** is a modern, self-hosted media server and secure proxy platform built with **.NET 9**. It is designed to provide a highly secure, extensible, and efficient solution for managing and streaming media content (images, videos, live streams) while enforcing fine-grained access control and cryptographic authentication. | ||||
|  | ||||
| ### 🎯 Key Features | ||||
|  | ||||
| - **Media Management**: Organize and serve images, videos, and live streams with structured directory support. | ||||
| - **User Authentication**: Challenge-response authentication using **Ed25519** signatures. No private keys are ever transmitted. | ||||
| - **Role-Based Access Control (RBAC)**: Hierarchical user privileges with configurable permissions for resources. | ||||
| - **Secure Proxy**: Built-in HTTP/S proxy with end-to-end encrypted tunneling using **X25519** key exchange and **ChaCha20-Poly1305** encryption. | ||||
| - **Resource-Level Permissions**: Fine-grained control over files and directories using a SQLite-based attribute system. | ||||
| - **Task System**: Support for background tasks such as media uploads and processing with chunk-based integrity validation. | ||||
| - **RESTful API**: Fully documented API endpoints for media access, user management, and task control. | ||||
|  | ||||
| ### 🛠️ Technology Stack | ||||
|  | ||||
| - **Backend**: ASP.NET Core 9, MVC, Dependency Injection | ||||
| - **Database**: SQLite with async ORM support | ||||
| - **Cryptography**: NSec.Cryptography (Ed25519, X25519), ChaCha20Poly1305 | ||||
| - **Media Handling**: Range requests, MIME type detection, chunked uploads | ||||
| - **Security**: Rate limiting, IP binding, token expiration, secure headers | ||||
|  | ||||
| ### 🔐 Security Highlights | ||||
|  | ||||
| - Zero-trust architecture: All requests require valid tokens bound to IP addresses. | ||||
| - No plaintext private key transmission. | ||||
| - All media and metadata access is validated against a permission database. | ||||
| - Secure tunneling with forward secrecy via ephemeral key exchange. | ||||
|  | ||||
| ### 📦 Use Cases | ||||
|  | ||||
| - Personal media library with access control | ||||
| - Secure internal video streaming platform | ||||
| - Proxy server with authenticated tunneling | ||||
| - Task-driven media processing pipeline | ||||
|  | ||||
| ### 🌱 Extensibility | ||||
|  | ||||
| Abyss is designed with modularity in mind. Its service-based architecture allows easy integration of new media types, authentication providers, or storage backends. | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Development environment | ||||
|  | ||||
| @@ -326,4 +369,4 @@ These endpoints provide access to static image resources. A valid token is requi | ||||
| - [ ] Add P/D method to all controllers to achieve dynamic modification of media items | ||||
| - [x] Implement identity management module | ||||
| - [ ] Add a description of the media library directory structure in the READMD document | ||||
| - [x] Add API interface instructions in the READMD document_ | ||||
| - [x] Add API interface instructions in the READMD document | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								abyss_clip.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								abyss_clip.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 510 KiB | 
		Reference in New Issue
	
	Block a user