Compare commits
10 Commits
8465ec5b2a
...
46ff611706
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46ff611706 | ||
|
|
f1e636a79d | ||
|
|
87536a1508 | ||
|
|
a7c522a61f | ||
|
|
3723ea32a7 | ||
|
|
e7d24aa20b | ||
|
|
a2f6eb1fba | ||
|
|
57c37bee51 | ||
|
|
a228d523a2 | ||
|
|
40c041444a |
27
.idea/.idea.Abyss/.idea/workspace.xml
generated
27
.idea/.idea.Abyss/.idea/workspace.xml
generated
@@ -9,10 +9,7 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="bf317275-3039-49bb-a475-725a800a0cce" name="Changes" comment="">
|
<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$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
|
||||||
</list>
|
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
<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/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://$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="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="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/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/Controllers/Task/TaskController.cs" root0="FORCE_HIGHLIGHTING" />
|
||||||
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/AbyssService.cs" root0="FORCE_HIGHLIGHTING" />
|
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/AbyssService.cs" root0="FORCE_HIGHLIGHTING" />
|
||||||
@@ -210,6 +213,22 @@
|
|||||||
<workItem from="1757782027930" duration="308000" />
|
<workItem from="1757782027930" duration="308000" />
|
||||||
<workItem from="1757830765557" duration="1218000" />
|
<workItem from="1757830765557" duration="1218000" />
|
||||||
<workItem from="1757862781213" duration="341000" />
|
<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>
|
</task>
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Abyss", "Abyss\Abyss.csproj", "{3337C1CD-2419-4922-BC92-AF1A825DDF23}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Abyss", "Abyss\Abyss.csproj", "{3337C1CD-2419-4922-BC92-AF1A825DDF23}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AbyssCli", "AbyssCli\AbyssCli.csproj", "{D7D668D4-61E7-4AA4-B615-A162FABAD333}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
EndGlobal
|
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_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_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_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_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_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>
|
<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="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
|
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
|
||||||
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
|
<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="Standart.Hash.xxHash" Version="4.0.5" />
|
||||||
<PackageReference Include="System.IO.Hashing" Version="10.0.0-preview.7.25380.108" />
|
<PackageReference Include="System.IO.Hashing" Version="10.0.0-preview.7.25380.108" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using Task = System.Threading.Tasks.Task;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[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");
|
public readonly string ImageFolder = Path.Combine(config.MediaRoot, "Images");
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ namespace Abyss.Components.Controllers.Media;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[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");
|
public readonly string LiveFolder = Path.Combine(config.MediaRoot, "Live");
|
||||||
|
|
||||||
[HttpPost("{id}")]
|
[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]);
|
var d = Helpers.SafePathCombine(LiveFolder, [id]);
|
||||||
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Abyss.Components.Services;
|
using Abyss.Components.Services;
|
||||||
using Abyss.Components.Static;
|
using Abyss.Components.Static;
|
||||||
using Abyss.Components.Tools;
|
using Abyss.Components.Tools;
|
||||||
using Abyss.Model;
|
using Abyss.Model;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.StaticFiles;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Abyss.Components.Controllers.Media;
|
namespace Abyss.Components.Controllers.Media;
|
||||||
@@ -12,17 +12,17 @@ using Task = System.Threading.Tasks.Task;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[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;
|
private ILogger<VideoController> _logger = logger;
|
||||||
|
|
||||||
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
|
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
|
||||||
|
|
||||||
[HttpPost("init")]
|
[HttpPost("init")]
|
||||||
public async Task<IActionResult> InitAsync(string token, string owner)
|
public async Task<IActionResult> InitAsync(string token, string owner)
|
||||||
{
|
{
|
||||||
var r = await rs.Initialize(VideoFolder, token, owner, Ip);
|
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" });
|
return StatusCode(403, new { message = "403 Denied" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,10 +30,10 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
|
|||||||
public async Task<IActionResult> GetClass(string token)
|
public async Task<IActionResult> GetClass(string token)
|
||||||
{
|
{
|
||||||
var r = (await rs.Query(VideoFolder, token, Ip))?.SortLikeWindows();
|
var r = (await rs.Query(VideoFolder, token, Ip))?.SortLikeWindows();
|
||||||
|
|
||||||
if(r == null)
|
if (r == null)
|
||||||
return StatusCode(401, new { message = "Unauthorized" });
|
return StatusCode(401, new { message = "Unauthorized" });
|
||||||
|
|
||||||
return Ok(r);
|
return Ok(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,24 +45,21 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
|
|||||||
var r = await rs.Query(d, token, Ip);
|
var r = await rs.Query(d, token, Ip);
|
||||||
if (r == null) return StatusCode(401, new { message = "Unauthorized" });
|
if (r == null) return StatusCode(401, new { message = "Unauthorized" });
|
||||||
|
|
||||||
var rv = r.Select(x =>
|
var rv = r.Select(x => { return Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"]); }).ToArray();
|
||||||
{
|
|
||||||
return Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"]);
|
|
||||||
}).ToArray();
|
|
||||||
|
|
||||||
for (int i = 0; i < rv.Length; i++)
|
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] ?? "");
|
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();
|
.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}")]
|
[HttpGet("{klass}/{id}")]
|
||||||
public async Task<IActionResult> QueryVideo(string klass, string id, string token)
|
public async Task<IActionResult> QueryVideo(string klass, string id, string token)
|
||||||
{
|
{
|
||||||
@@ -70,27 +67,26 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
|
|||||||
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
||||||
|
|
||||||
var r = await rs.Get(d, token, Ip);
|
var r = await rs.Get(d, token, Ip);
|
||||||
if (!r) return StatusCode(403, new { message = "403 Denied" });
|
if (!r) return StatusCode(403, new { message = "403 Denied" });
|
||||||
|
|
||||||
return Ok(await System.IO.File.ReadAllTextAsync(d));
|
return Ok(await System.IO.File.ReadAllTextAsync(d));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{klass}/bulkquery")]
|
[HttpPost("{klass}/bulkquery")]
|
||||||
public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id, [FromRoute] string klass)
|
public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id,
|
||||||
|
[FromRoute] string klass)
|
||||||
{
|
{
|
||||||
List<string> result = new List<string>();
|
|
||||||
|
|
||||||
var db = id.Select(x => Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"])).ToArray();
|
var db = id.Select(x => Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"])).ToArray();
|
||||||
if(db.Any(x => x == null))
|
if (db.Any(x => x == null))
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
|
|
||||||
if(!await rs.GetAll(db!, token, Ip))
|
if (!await rs.GetAll(db!, token, Ip))
|
||||||
return StatusCode(403, new { message = "403 Denied" });
|
return StatusCode(403, new { message = "403 Denied" });
|
||||||
|
|
||||||
var rc = db.Select(x => System.IO.File.ReadAllTextAsync(x!)).ToArray();
|
var rc = db.Select(x => System.IO.File.ReadAllTextAsync(x!)).ToArray();
|
||||||
string[] rcs = await Task.WhenAll(rc);
|
string[] rcs = await Task.WhenAll(rc);
|
||||||
var rjs = rcs.Select(JsonConvert.DeserializeObject<Video>).Select(x => x!).ToList();
|
var rjs = rcs.Select(JsonConvert.DeserializeObject<Video>).Select(x => x!).ToList();
|
||||||
|
|
||||||
return Ok(JsonConvert.SerializeObject(rjs));
|
return Ok(JsonConvert.SerializeObject(rjs));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +95,10 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
|
|||||||
{
|
{
|
||||||
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "cover.jpg"]);
|
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "cover.jpg"]);
|
||||||
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
||||||
|
|
||||||
var r = await rs.Get(d, token, Ip);
|
var r = await rs.Get(d, token, Ip);
|
||||||
if (!r) return StatusCode(403, new { message = "403 Denied" });
|
if (!r) return StatusCode(403, new { message = "403 Denied" });
|
||||||
|
|
||||||
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
|
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,21 +107,123 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
|
|||||||
{
|
{
|
||||||
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "gallery", pic]);
|
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "gallery", pic]);
|
||||||
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
||||||
|
|
||||||
var r = await rs.Get(d, token, Ip);
|
var r = await rs.Get(d, token, Ip);
|
||||||
if (!r) return StatusCode(403, new { message = "403 Denied" });
|
if (!r) return StatusCode(403, new { message = "403 Denied" });
|
||||||
|
|
||||||
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
|
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")]
|
[HttpGet("{klass}/{id}/av")]
|
||||||
public async Task<IActionResult> Av(string klass, string id, string token)
|
public async Task<IActionResult> Av(string klass, string id, string token)
|
||||||
{
|
{
|
||||||
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "video.mp4"]);
|
var folder = Helpers.SafePathCombine(VideoFolder, new[] { klass, id });
|
||||||
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
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" };
|
||||||
if (!r) return StatusCode(403, new { message = "403 Denied" });
|
|
||||||
return PhysicalFile(d, "video/mp4", enableRangeProcessing: true);
|
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" });
|
||||||
|
|
||||||
|
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
|
// UserController.cs
|
||||||
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
@@ -13,53 +12,49 @@ namespace Abyss.Components.Controllers.Security;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[EnableRateLimiting("Fixed")]
|
[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}")]
|
[HttpGet("{user}")]
|
||||||
public async Task<IActionResult> Challenge(string user)
|
public async Task<IActionResult> Challenge(string user)
|
||||||
{
|
{
|
||||||
var c = await _user.Challenge(user);
|
var c = await userService.Challenge(user);
|
||||||
if(c == null)
|
if (c == null)
|
||||||
return StatusCode(403, new { message = "Access forbidden" });
|
return StatusCode(403, new { message = "Access forbidden" });
|
||||||
|
|
||||||
return Ok(c);
|
return Ok(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{user}")]
|
[HttpPost("{user}")]
|
||||||
public async Task<IActionResult> Challenge(string user, [FromBody] ChallengeResponse response)
|
public async Task<IActionResult> Challenge(string user, [FromBody] ChallengeResponse response)
|
||||||
{
|
{
|
||||||
var r = await _user.Verify(user, response.Response, Ip);
|
var r = await userService.Verify(user, response.Response, Ip);
|
||||||
if(r == null)
|
if (r == null)
|
||||||
return StatusCode(403, new { message = "Access forbidden" });
|
return StatusCode(403, new { message = "Access forbidden" });
|
||||||
|
|
||||||
return Ok(r);
|
return Ok(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("validate")]
|
[HttpPost("validate")]
|
||||||
public IActionResult Validate(string token)
|
public IActionResult Validate(string token)
|
||||||
{
|
{
|
||||||
var u = _user.Validate(token, Ip);
|
var u = userService.Validate(token, Ip);
|
||||||
if (u == null)
|
if (u == -1)
|
||||||
{
|
{
|
||||||
return StatusCode(401, new { message = "Invalid" });
|
return StatusCode(401, new { message = "Invalid" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(u);
|
return Ok(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("destroy")]
|
[HttpPost("destroy")]
|
||||||
public IActionResult Destroy(string token)
|
public IActionResult Destroy(string token)
|
||||||
{
|
{
|
||||||
var u = _user.Validate(token, Ip);
|
var u = userService.Validate(token, Ip);
|
||||||
if (u == null)
|
if (u == -1)
|
||||||
{
|
{
|
||||||
return StatusCode(401, new { message = "Invalid" });
|
return StatusCode(401, new { message = "Invalid" });
|
||||||
}
|
}
|
||||||
|
|
||||||
_user.Destroy(token);
|
userService.Destroy(token);
|
||||||
return Ok("Success");
|
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)
|
public async Task<IActionResult> Create(string user, [FromBody] UserCreating creating)
|
||||||
{
|
{
|
||||||
// Valid token
|
// Valid token
|
||||||
var r = await _user.Verify(user, creating.Response, Ip);
|
var r = await userService.Verify(user, creating.Response, Ip);
|
||||||
if(r == null)
|
if (r == null)
|
||||||
return StatusCode(403, new { message = "Denied" });
|
return StatusCode(403, new { message = "Denied" });
|
||||||
|
|
||||||
// User exists ?
|
// User exists ?
|
||||||
var cu = await _user.QueryUser(creating.Name);
|
var cu = await userService.QueryUser(creating.Name);
|
||||||
if(cu != null)
|
if (cu != null)
|
||||||
return StatusCode(403, new { message = "Denied" });
|
return StatusCode(403, new { message = "Denied" });
|
||||||
|
|
||||||
// Valid username string
|
// Valid username string
|
||||||
if(!IsAlphanumeric(creating.Name))
|
if (!IsAlphanumeric(creating.Name))
|
||||||
return StatusCode(403, new { message = "Denied" });
|
return StatusCode(403, new { message = "Denied" });
|
||||||
|
|
||||||
// Valid parent && Privilege
|
// Valid parent && Privilege
|
||||||
var ou = await _user.QueryUser(_user.Validate(r, Ip) ?? "");
|
var ou = await userService.QueryUser(userService.Validate(r, Ip));
|
||||||
if(creating.Parent != (_user.Validate(r, Ip) ?? "") || creating.Privilege > ou?.Privilege)
|
if (creating.Privilege > ou?.Privilege || ou == null)
|
||||||
return StatusCode(403, new { message = "Denied" });
|
return StatusCode(403, new { message = "Denied" });
|
||||||
|
|
||||||
await _user.CreateUser(new User()
|
await userService.CreateUser(new User
|
||||||
{
|
{
|
||||||
Name = creating.Name,
|
Username = creating.Name,
|
||||||
Parent = _user.Validate(r, Ip) ?? "",
|
ParentId = ou.Uuid,
|
||||||
Privilege = creating.Privilege,
|
Privilege = creating.Privilege,
|
||||||
PublicKey = creating.PublicKey,
|
PublicKey = creating.PublicKey,
|
||||||
} );
|
});
|
||||||
|
|
||||||
_user.Destroy(r);
|
userService.Destroy(r);
|
||||||
return Ok("Success");
|
return Ok("Success");
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{user}/open")]
|
[HttpGet("{user}/open")]
|
||||||
public async Task<IActionResult> Open(string user, [FromQuery] string token, [FromQuery] string? bindIp = null)
|
public async Task<IActionResult> Open(string user, [FromQuery] string token, [FromQuery] string? bindIp = null)
|
||||||
{
|
{
|
||||||
var caller = _user.Validate(token, Ip);
|
var caller = userService.Validate(token, Ip);
|
||||||
if (caller == null || caller != "root")
|
if (caller != 1)
|
||||||
{
|
{
|
||||||
return StatusCode(403, new { message = "Access forbidden" });
|
return StatusCode(403, new { message = "Access forbidden" });
|
||||||
}
|
}
|
||||||
|
|
||||||
var target = await _user.QueryUser(user);
|
var target = await userService.QueryUser(user);
|
||||||
if (target == null)
|
if (target == null)
|
||||||
{
|
{
|
||||||
return StatusCode(404, new { message = "User not found" });
|
return StatusCode(404, new { message = "User not found" });
|
||||||
@@ -114,12 +109,13 @@ public class UserController(UserService user, ILogger<UserController> logger) :
|
|||||||
|
|
||||||
var ipToBind = string.IsNullOrWhiteSpace(bindIp) ? Ip : bindIp;
|
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 });
|
return Ok(new { token = t, user, boundIp = ipToBind });
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsAlphanumeric(string input)
|
public static bool IsAlphanumeric(string input)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(input))
|
if (string.IsNullOrEmpty(input))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace Abyss.Components.Controllers.Task;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[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");
|
public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks");
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using System.Text;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Abyss.Components.Static;
|
using Abyss.Components.Static;
|
||||||
using Abyss.Model;
|
using Abyss.Model;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using SQLite;
|
using SQLite;
|
||||||
using System.IO.Hashing;
|
using System.IO.Hashing;
|
||||||
|
|
||||||
@@ -21,19 +20,15 @@ public class ResourceService
|
|||||||
{
|
{
|
||||||
private readonly ILogger<ResourceService> _logger;
|
private readonly ILogger<ResourceService> _logger;
|
||||||
private readonly ConfigureService _config;
|
private readonly ConfigureService _config;
|
||||||
private readonly IMemoryCache _cache;
|
|
||||||
private readonly UserService _user;
|
private readonly UserService _user;
|
||||||
private readonly SQLiteAsyncConnection _database;
|
private readonly SQLiteAsyncConnection _database;
|
||||||
|
|
||||||
private static readonly Regex PermissionRegex =
|
private static readonly Regex PermissionRegex = new("^([r-][w-]),([r-][w-]),([r-][w-])$", RegexOptions.Compiled);
|
||||||
new(@"^([r-][w-]),([r-][w-]),([r-][w-])$", RegexOptions.Compiled);
|
|
||||||
|
|
||||||
public ResourceService(ILogger<ResourceService> logger, ConfigureService config, IMemoryCache cache,
|
public ResourceService(ILogger<ResourceService> logger, ConfigureService config, UserService user)
|
||||||
UserService user)
|
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_config = config;
|
_config = config;
|
||||||
_cache = cache;
|
|
||||||
_user = user;
|
_user = user;
|
||||||
|
|
||||||
_database = new SQLiteAsyncConnection(config.RaDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
|
_database = new SQLiteAsyncConnection(config.RaDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
|
||||||
@@ -42,13 +37,13 @@ public class ResourceService
|
|||||||
var tasksPath = Helpers.SafePathCombine(_config.MediaRoot, "Tasks");
|
var tasksPath = Helpers.SafePathCombine(_config.MediaRoot, "Tasks");
|
||||||
if (tasksPath != null)
|
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");
|
var livePath = Helpers.SafePathCombine(_config.MediaRoot, "Live");
|
||||||
if (livePath != null)
|
if (livePath != null)
|
||||||
{
|
{
|
||||||
InsertRaRow(livePath, "root", "rw,r-,r-", true).Wait();
|
InsertRaRow(livePath, 1, "rw,r-,r-", true).Wait();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,12 +52,210 @@ public class ResourceService
|
|||||||
{
|
{
|
||||||
var b = Encoding.UTF8.GetBytes(path);
|
var b = Encoding.UTF8.GetBytes(path);
|
||||||
var r = XxHash128.Hash(b, 0x11451419);
|
var r = XxHash128.Hash(b, 0x11451419);
|
||||||
return Convert.ToBase64String(r ?? []);
|
return Convert.ToBase64String(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ValidAll(string[] paths, string token, OperationType type, string ip)
|
public async Task<Dictionary<string, bool>> ValidAny(string[] paths, string token, OperationType type, string ip)
|
||||||
{
|
{
|
||||||
if (paths == null || paths.Length == 0)
|
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");
|
_logger.LogError("ValidAll called with empty path set");
|
||||||
return false;
|
return false;
|
||||||
@@ -74,7 +267,7 @@ public class ResourceService
|
|||||||
var relPaths = new List<string>(paths.Length);
|
var relPaths = new List<string>(paths.Length);
|
||||||
foreach (var p in paths)
|
foreach (var p in paths)
|
||||||
{
|
{
|
||||||
if (p == null || !p.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
|
if (!p.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogError($"Path outside media root or null: {p}");
|
_logger.LogError($"Path outside media root or null: {p}");
|
||||||
return false;
|
return false;
|
||||||
@@ -84,15 +277,15 @@ public class ResourceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. validate token and user once
|
// 2. validate token and user once
|
||||||
string? username = _user.Validate(token, ip);
|
int uuid = _user.Validate(token, ip);
|
||||||
if (username == null)
|
if (uuid == -1)
|
||||||
{
|
{
|
||||||
_logger.LogError($"Invalid token: {token}");
|
_logger.LogError($"Invalid token: {token}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
User? user = await _user.QueryUser(username);
|
User? user = await _user.QueryUser(uuid);
|
||||||
if (user == null || user.Name != username)
|
if (user == null || user.Uuid != uuid)
|
||||||
{
|
{
|
||||||
_logger.LogError($"Verification failed: {token}");
|
_logger.LogError($"Verification failed: {token}");
|
||||||
return false;
|
return false;
|
||||||
@@ -100,6 +293,8 @@ public class ResourceService
|
|||||||
|
|
||||||
// 3. build uid -> required ops map (avoid duplicate Uid calculations)
|
// 3. build uid -> required ops map (avoid duplicate Uid calculations)
|
||||||
var uidToOps = new Dictionary<string, HashSet<OperationType>>(StringComparer.OrdinalIgnoreCase);
|
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)
|
foreach (var rel in relPaths)
|
||||||
{
|
{
|
||||||
var parts = rel
|
var parts = rel
|
||||||
@@ -117,6 +312,7 @@ public class ResourceService
|
|||||||
{
|
{
|
||||||
ops = new HashSet<OperationType>();
|
ops = new HashSet<OperationType>();
|
||||||
uidToOps[uidDir] = ops;
|
uidToOps[uidDir] = ops;
|
||||||
|
uidToExampleRelPath[uidDir] = subPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
ops.Add(OperationType.Read);
|
ops.Add(OperationType.Read);
|
||||||
@@ -129,16 +325,40 @@ public class ResourceService
|
|||||||
{
|
{
|
||||||
resOps = new HashSet<OperationType>();
|
resOps = new HashSet<OperationType>();
|
||||||
uidToOps[uidRes] = resOps;
|
uidToOps[uidRes] = resOps;
|
||||||
|
uidToExampleRelPath[uidRes] = resourcePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
resOps.Add(type);
|
resOps.Add(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. batch query DB for all UIDs
|
// 4. batch query DB for all UIDs using parameterized IN (...) and chunking to respect SQLite param limits
|
||||||
var uidsNeeded = uidToOps.Keys.ToList();
|
var uidsNeeded = uidToOps.Keys.ToList();
|
||||||
var rasList = await _database.Table<ResourceAttribute>()
|
var rasList = new List<ResourceAttribute>();
|
||||||
.Where(r => uidsNeeded.Contains(r.Uid))
|
|
||||||
.ToListAsync();
|
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);
|
var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
@@ -148,10 +368,11 @@ public class ResourceService
|
|||||||
foreach (var kv in uidToOps)
|
foreach (var kv in uidToOps)
|
||||||
{
|
{
|
||||||
var uid = kv.Key;
|
var uid = kv.Key;
|
||||||
if (!raDict.TryGetValue(uid, out var ra) || ra == null)
|
if (!raDict.TryGetValue(uid, out var ra))
|
||||||
{
|
{
|
||||||
// find an example path string for logging would require reverse map; keep uid for clarity
|
var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid);
|
||||||
_logger.LogError($"Permission check failed (missing resource attribute): User: {username}, Uid: {uid}");
|
_logger.LogError(
|
||||||
|
$"Permission check failed (missing resource attribute): User: {uuid}, Resource: {examplePath}, Uid: {uid}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +387,9 @@ public class ResourceService
|
|||||||
|
|
||||||
if (!ok)
|
if (!ok)
|
||||||
{
|
{
|
||||||
_logger.LogError($"Permission check failed: User: {username}, Uid: {uid}, Type: {op}");
|
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 false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +398,7 @@ public class ResourceService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<bool> Valid(string path, string token, OperationType type, string ip)
|
public async Task<bool> Valid(string path, string token, OperationType type, string ip)
|
||||||
{
|
{
|
||||||
// Path is abs path here, due to Helpers.SafePathCombine
|
// Path is abs path here, due to Helpers.SafePathCombine
|
||||||
@@ -183,16 +407,16 @@ public class ResourceService
|
|||||||
|
|
||||||
path = Path.GetRelativePath(_config.MediaRoot, path);
|
path = Path.GetRelativePath(_config.MediaRoot, path);
|
||||||
|
|
||||||
string? username = _user.Validate(token, ip);
|
int uuid = _user.Validate(token, ip);
|
||||||
if (username == null)
|
if (uuid == -1)
|
||||||
{
|
{
|
||||||
// No permission granted for invalid tokens
|
// No permission granted for invalid tokens
|
||||||
_logger.LogError($"Invalid token: {token}");
|
_logger.LogError($"Invalid token: {token}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
User? user = await _user.QueryUser(username);
|
User? user = await _user.QueryUser(uuid);
|
||||||
if (user == null || user.Name != username)
|
if (user == null || user.Uuid != uuid)
|
||||||
{
|
{
|
||||||
_logger.LogError($"Verification failed: {token}");
|
_logger.LogError($"Verification failed: {token}");
|
||||||
return false; // Two-factor authentication
|
return false; // Two-factor authentication
|
||||||
@@ -205,34 +429,38 @@ public class ResourceService
|
|||||||
{
|
{
|
||||||
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
|
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
|
||||||
var uidDir = Uid(subPath);
|
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();
|
.FirstOrDefaultAsync();
|
||||||
if (raDir == null)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await CheckPermission(user, raDir, OperationType.Read))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var uid = Uid(path);
|
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();
|
.FirstOrDefaultAsync();
|
||||||
if (ra == null)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var l = await CheckPermission(user, ra, type);
|
var l = await CheckPermission(user, ra, type);
|
||||||
if (!l)
|
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;
|
return l;
|
||||||
@@ -250,7 +478,7 @@ public class ResourceService
|
|||||||
var owner = await _user.QueryUser(ra.Owner);
|
var owner = await _user.QueryUser(ra.Owner);
|
||||||
if (owner == null) return false;
|
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 isPeer = !isOwner && user.Privilege == owner.Privilege;
|
||||||
bool isOther = !isOwner && !isPeer;
|
bool isOther = !isOwner && !isPeer;
|
||||||
|
|
||||||
@@ -267,7 +495,7 @@ public class ResourceService
|
|||||||
case OperationType.Write:
|
case OperationType.Write:
|
||||||
return currentPerm.Contains('w') || (user.Privilege > owner.Privilege);
|
return currentPerm.Contains('w') || (user.Privilege > owner.Privilege);
|
||||||
case OperationType.Security:
|
case OperationType.Security:
|
||||||
return (isOwner && currentPerm.Contains('w')) || user.Name == "root";
|
return (isOwner && currentPerm.Contains('w')) || user.Uuid == 1;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -281,8 +509,45 @@ public class ResourceService
|
|||||||
if (Helpers.GetPathType(path) != PathType.Directory)
|
if (Helpers.GetPathType(path) != PathType.Directory)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var files = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly);
|
try
|
||||||
return files.Select(x => Path.GetRelativePath(path, x)).ToArray();
|
{
|
||||||
|
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)
|
public async Task<bool> Get(string path, string token, string ip)
|
||||||
@@ -300,17 +565,25 @@ public class ResourceService
|
|||||||
return await Valid(path, token, OperationType.Write, 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
|
// TODO: Use a more elegant Debug mode
|
||||||
if (_config.DebugMode == "Debug")
|
if (_config.DebugMode == "Debug")
|
||||||
goto debug;
|
goto debug;
|
||||||
// 1. Authorization: Verify the operation is performed by 'root'
|
// 1. Authorization: Verify the operation is performed by 'root'
|
||||||
var requester = _user.Validate(token, ip);
|
var requester = _user.Validate(token, ip);
|
||||||
if (requester != "root")
|
if (requester != 1)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,10 +595,10 @@ public class ResourceService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ownerUser = await _user.QueryUser(username);
|
var ownerUser = await _user.QueryUser(owner);
|
||||||
if (ownerUser == null)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,8 +622,7 @@ public class ResourceService
|
|||||||
newResources.Add(new ResourceAttribute
|
newResources.Add(new ResourceAttribute
|
||||||
{
|
{
|
||||||
Uid = uid,
|
Uid = uid,
|
||||||
Name = currentPath,
|
Owner = owner,
|
||||||
Owner = username,
|
|
||||||
Permission = "rw,--,--"
|
Permission = "rw,--,--"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -361,7 +633,7 @@ public class ResourceService
|
|||||||
{
|
{
|
||||||
await _database.InsertAllAsync(newResources);
|
await _database.InsertAllAsync(newResources);
|
||||||
_logger.LogInformation(
|
_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
|
else
|
||||||
{
|
{
|
||||||
@@ -378,23 +650,23 @@ public class ResourceService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> Put(string path, string token, string ip)
|
// public async Task<bool> Put(string path, string token, string ip)
|
||||||
{
|
// {
|
||||||
throw new NotImplementedException();
|
// throw new NotImplementedException();
|
||||||
}
|
// }
|
||||||
|
|
||||||
public async Task<bool> Delete(string path, string token, string ip)
|
// public async Task<bool> Delete(string path, string token, string ip)
|
||||||
{
|
// {
|
||||||
throw new NotImplementedException();
|
// throw new NotImplementedException();
|
||||||
}
|
// }
|
||||||
|
|
||||||
public async Task<bool> Exclude(string path, string token, string ip)
|
public async Task<bool> Exclude(string path, string token, string ip)
|
||||||
{
|
{
|
||||||
var requester = _user.Validate(token, ip);
|
var requester = _user.Validate(token, ip);
|
||||||
if (requester != "root")
|
if (requester != 1)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
$"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to exclude resource '{path}'.");
|
$"Permission denied: Non-root user '{requester}' attempted to exclude resource '{path}'.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,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);
|
var requester = _user.Validate(token, ip);
|
||||||
if (requester != "root")
|
if (requester != 1)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +739,6 @@ public class ResourceService
|
|||||||
var newResource = new ResourceAttribute
|
var newResource = new ResourceAttribute
|
||||||
{
|
{
|
||||||
Uid = uid,
|
Uid = uid,
|
||||||
Name = relPath,
|
|
||||||
Owner = owner,
|
Owner = owner,
|
||||||
Permission = permission
|
Permission = permission
|
||||||
};
|
};
|
||||||
@@ -509,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))
|
// Validate permission format first
|
||||||
return false;
|
|
||||||
|
|
||||||
// Validate the permission format using the existing regex
|
|
||||||
if (!PermissionRegex.IsMatch(permission))
|
if (!PermissionRegex.IsMatch(permission))
|
||||||
{
|
{
|
||||||
_logger.LogError($"Invalid permission format: {permission}");
|
_logger.LogError($"Invalid permission format: {permission}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize path to full path
|
||||||
|
path = Path.GetFullPath(path);
|
||||||
|
|
||||||
|
// Collect targets and permission checks
|
||||||
|
List<string> targets = new List<string>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
path = Path.GetRelativePath(_config.MediaRoot, path);
|
if (recursive && Directory.Exists(path))
|
||||||
var uid = Uid(path);
|
|
||||||
var resource = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
if (resource == null)
|
|
||||||
{
|
{
|
||||||
_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation($"Successfully validated chmod on '{path}'.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
resource.Permission = permission;
|
// Chunked bulk UPDATE using SQL "UPDATE ... WHERE Uid IN (...)"
|
||||||
var rowsAffected = await _database.UpdateAsync(resource);
|
int updatedCount = 0;
|
||||||
|
const int sqliteMaxVariableNumber = 900; // leave some headroom for other params
|
||||||
if (rowsAffected > 0)
|
for (int i = 0; i < relUids.Count; i += sqliteMaxVariableNumber)
|
||||||
{
|
{
|
||||||
_logger.LogInformation($"Successfully changed permissions for '{path}' to '{permission}'");
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogError($"Failed to update permissions for: {path}");
|
_logger.LogWarning($"Chmod: no resources updated for '{path}' (recursive={recursive})");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,13 +881,9 @@ public class ResourceService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Chown(string path, string token, int owner, string ip, bool recursive = false)
|
||||||
public async Task<bool> Chown(string path, string token, string owner, string ip)
|
|
||||||
{
|
{
|
||||||
if (!await Valid(path, token, OperationType.Security, ip))
|
// Validate new owner exists
|
||||||
return false;
|
|
||||||
|
|
||||||
// Validate that the new owner exists
|
|
||||||
var newOwner = await _user.QueryUser(owner);
|
var newOwner = await _user.QueryUser(owner);
|
||||||
if (newOwner == null)
|
if (newOwner == null)
|
||||||
{
|
{
|
||||||
@@ -568,29 +891,84 @@ public class ResourceService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
path = Path.GetFullPath(path);
|
||||||
|
|
||||||
|
// Permission checks and target collection
|
||||||
|
List<string> targets = new List<string>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
path = Path.GetRelativePath(_config.MediaRoot, path);
|
if (recursive && Directory.Exists(path))
|
||||||
var uid = Uid(path);
|
|
||||||
var resource = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
if (resource == null)
|
|
||||||
{
|
{
|
||||||
_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 chown on '{path}'");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
resource.Owner = owner;
|
// Chunked bulk UPDATE: SET Owner = ? WHERE Uid IN (...)
|
||||||
var rowsAffected = await _database.UpdateAsync(resource);
|
int updatedCount = 0;
|
||||||
|
const int sqliteMaxVariableNumber = 900;
|
||||||
if (rowsAffected > 0)
|
for (int i = 0; i < relUids.Count; i += sqliteMaxVariableNumber)
|
||||||
{
|
{
|
||||||
_logger.LogInformation($"Successfully changed ownership of '{path}' to '{owner}'");
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogError($"Failed to change ownership for: {path}");
|
_logger.LogWarning($"Chown: no resources updated for '{path}' (recursive={recursive})");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,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))
|
if (!PermissionRegex.IsMatch(permission))
|
||||||
{
|
{
|
||||||
@@ -615,7 +994,6 @@ public class ResourceService
|
|||||||
return await _database.InsertOrReplaceAsync(new ResourceAttribute()
|
return await _database.InsertOrReplaceAsync(new ResourceAttribute()
|
||||||
{
|
{
|
||||||
Uid = Uid(path),
|
Uid = Uid(path),
|
||||||
Name = path,
|
|
||||||
Owner = owner,
|
Owner = owner,
|
||||||
Permission = permission,
|
Permission = permission,
|
||||||
}) == 1;
|
}) == 1;
|
||||||
@@ -624,10 +1002,37 @@ public class ResourceService
|
|||||||
return await _database.InsertAsync(new ResourceAttribute()
|
return await _database.InsertAsync(new ResourceAttribute()
|
||||||
{
|
{
|
||||||
Uid = Uid(path),
|
Uid = Uid(path),
|
||||||
Name = path,
|
|
||||||
Owner = owner,
|
Owner = owner,
|
||||||
Permission = permission,
|
Permission = permission,
|
||||||
}) == 1;
|
}) == 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))
|
if(!await rs.Valid(VideoFolder, token, OperationType.Write, ip))
|
||||||
return null;
|
return null;
|
||||||
var u = user.Validate(token, ip);
|
var u = user.Validate(token, ip);
|
||||||
if(u == null)
|
if(u == -1)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var r = new TaskCreationResponse()
|
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"));
|
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
|
// 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,
|
Name = creation.Name,
|
||||||
Owner = u,
|
Owner = u,
|
||||||
@@ -83,7 +83,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
|
|||||||
Type = TaskType.Video
|
Type = TaskType.Video
|
||||||
};
|
};
|
||||||
|
|
||||||
await System.IO.File.WriteAllTextAsync(
|
await File.WriteAllTextAsync(
|
||||||
Path.Combine(TaskFolder, r.Id.ToString(), "task.json"),
|
Path.Combine(TaskFolder, r.Id.ToString(), "task.json"),
|
||||||
JsonConvert.SerializeObject(v, Formatting.Indented));
|
JsonConvert.SerializeObject(v, Formatting.Indented));
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,20 @@ namespace Abyss.Components.Services;
|
|||||||
public class UserService
|
public class UserService
|
||||||
{
|
{
|
||||||
private readonly ILogger<UserService> _logger;
|
private readonly ILogger<UserService> _logger;
|
||||||
private readonly ConfigureService _config;
|
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
private readonly SQLiteAsyncConnection _database;
|
private readonly SQLiteAsyncConnection _database;
|
||||||
|
|
||||||
public UserService(ILogger<UserService> logger, ConfigureService config, IMemoryCache cache)
|
public UserService(ILogger<UserService> logger, ConfigureService config, IMemoryCache cache)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_config = config;
|
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
|
||||||
_database = new SQLiteAsyncConnection(config.UserDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
|
_database = new SQLiteAsyncConnection(config.UserDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
|
||||||
_database.CreateTableAsync<User>().Wait();
|
_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")
|
if (config.DebugMode == "Debug")
|
||||||
_cache.Set("root", $"root@127.0.0.1", DateTimeOffset.Now.AddHours(1));
|
_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.
|
// Test token, can only be used locally. Will be destroyed in one hour.
|
||||||
|
|
||||||
if (rootUser == null)
|
if (rootUser == null)
|
||||||
@@ -50,8 +48,9 @@ public class UserService
|
|||||||
Console.WriteLine("key: '" + privateKeyBase64 + "'");
|
Console.WriteLine("key: '" + privateKeyBase64 + "'");
|
||||||
_database.InsertAsync(new User()
|
_database.InsertAsync(new User()
|
||||||
{
|
{
|
||||||
Name = "root",
|
Uuid = 1,
|
||||||
Parent = "root",
|
Username = "root",
|
||||||
|
ParentId = 1,
|
||||||
PublicKey = publicKeyBase64,
|
PublicKey = publicKeyBase64,
|
||||||
Privilege = 1145141919,
|
Privilege = 1145141919,
|
||||||
}).Wait();
|
}).Wait();
|
||||||
@@ -61,15 +60,16 @@ public class UserService
|
|||||||
}
|
}
|
||||||
public async Task<string?> Challenge(string user)
|
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
|
if (u == null) // Error: User not exists
|
||||||
return null;
|
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)));
|
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;
|
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
|
// 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)
|
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
|
if (u == null) // Error: User not exists
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (_cache.TryGetValue(u.Name, out string? challenge))
|
|
||||||
|
if (_cache.TryGetValue(u.Uuid, out string? challenge))
|
||||||
{
|
{
|
||||||
bool isVerified = VerifySignature(
|
bool isVerified = VerifySignature(
|
||||||
PublicKey.Import(
|
PublicKey.Import(
|
||||||
@@ -95,16 +96,16 @@ public class UserService
|
|||||||
if (!isVerified)
|
if (!isVerified)
|
||||||
{
|
{
|
||||||
// Verification failed, set the challenge string to random to prevent duplicate verification
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Remove the challenge string and create a session
|
// Remove the challenge string and create a session
|
||||||
_cache.Remove(u.Name);
|
_cache.Remove(u.Uuid);
|
||||||
var s = GenerateRandomAsciiString(64);
|
var s = GenerateRandomAsciiString(64);
|
||||||
_cache.Set(s, $"{u.Name}@{ip}", DateTimeOffset.Now.AddDays(1));
|
_cache.Set(s, $"{u.Uuid}@{ip}", DateTimeOffset.Now.AddDays(1));
|
||||||
_logger.LogInformation($"Verified {u.Name}@{ip}");
|
_logger.LogInformation($"Verified {u.Uuid}@{ip}, Name: {u.Username}");
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,9 @@ public class UserService
|
|||||||
return null;
|
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))
|
if (_cache.TryGetValue(token, out string? userAndIp))
|
||||||
{
|
{
|
||||||
@@ -120,13 +123,13 @@ public class UserService
|
|||||||
{
|
{
|
||||||
_logger.LogError($"Token used from another Host: {token}");
|
_logger.LogError($"Token used from another Host: {token}");
|
||||||
Destroy(token);
|
Destroy(token);
|
||||||
return null;
|
return -1;
|
||||||
}
|
}
|
||||||
// _logger.LogInformation($"Validated {userAndIp}");
|
// _logger.LogInformation($"Validated {userAndIp}");
|
||||||
return userAndIp?.Split('@')[0];
|
return Convert.ToInt32(userAndIp?.Split('@')[0]);
|
||||||
}
|
}
|
||||||
_logger.LogWarning($"Validation failed {token}");
|
_logger.LogWarning($"Validation failed {token}");
|
||||||
return null;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Destroy(string token)
|
public void Destroy(string token)
|
||||||
@@ -134,16 +137,24 @@ public class UserService
|
|||||||
_cache.Remove(token);
|
_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;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateUser(User user)
|
public async Task CreateUser(User user)
|
||||||
{
|
{
|
||||||
await _database.InsertAsync(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()
|
static Key GenerateKeyPair()
|
||||||
@@ -195,23 +206,23 @@ public class UserService
|
|||||||
|
|
||||||
if (VerifySignature(pubKey, data, signature))
|
if (VerifySignature(pubKey, data, signature))
|
||||||
{
|
{
|
||||||
_logger.LogInformation($"Signature verified using user {u.Name}");
|
_logger.LogInformation($"Signature verified using user {u.Username}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, $"Failed to import public key for {u.Name}");
|
_logger.LogWarning(ex, $"Failed to import public key for {u.Username}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string CreateToken(string user, string ip, TimeSpan lifetime)
|
public string CreateToken(int uid, string ip, TimeSpan lifetime)
|
||||||
{
|
{
|
||||||
var token = GenerateRandomAsciiString(64);
|
var token = GenerateRandomAsciiString(64);
|
||||||
_cache.Set(token, $"{user}@{ip}", DateTimeOffset.Now.Add(lifetime));
|
_cache.Set(token, $"{uid}@{ip}", DateTimeOffset.Now.Add(lifetime));
|
||||||
_logger.LogInformation($"Created token for {user}@{ip}, valid {lifetime.TotalMinutes} minutes");
|
_logger.LogInformation($"Created token for {uid}@{ip}, valid {lifetime.TotalMinutes} minutes");
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,8 +73,6 @@ public static class Helpers
|
|||||||
{
|
{
|
||||||
return PathType.AccessDenied;
|
return PathType.AccessDenied;
|
||||||
}
|
}
|
||||||
|
|
||||||
return PathType.NotFound;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
using SQLite;
|
||||||
|
|
||||||
namespace Abyss.Model;
|
namespace Abyss.Model;
|
||||||
|
|
||||||
|
[Table("ResourceAttributes")]
|
||||||
public class ResourceAttribute
|
public class ResourceAttribute
|
||||||
{
|
{
|
||||||
public string Uid { get; set; } = "@";
|
[PrimaryKey, AutoIncrement]
|
||||||
public string Name { get; set; } = "@";
|
public int Id { get; set; }
|
||||||
public string Owner { get; set; } = "@";
|
|
||||||
|
[Unique, NotNull]
|
||||||
|
public string Uid { get; init; } = "@";
|
||||||
|
[NotNull]
|
||||||
|
public int Owner { get; set; }
|
||||||
|
[NotNull]
|
||||||
public string Permission { get; set; } = "--,--,--";
|
public string Permission { get; set; } = "--,--,--";
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ public enum TaskType
|
|||||||
public class Task
|
public class Task
|
||||||
{
|
{
|
||||||
public uint Id;
|
public uint Id;
|
||||||
public string Owner = "";
|
public int Owner;
|
||||||
public string Class = "";
|
public string Class = "";
|
||||||
public string Name = "";
|
public string Name = "";
|
||||||
public TaskType Type;
|
public TaskType Type;
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
using SQLite;
|
||||||
|
|
||||||
namespace Abyss.Model;
|
namespace Abyss.Model;
|
||||||
|
|
||||||
|
[Table("Users")]
|
||||||
public class User
|
public class User
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = "";
|
[PrimaryKey, AutoIncrement]
|
||||||
public string Parent { get; set; } = "";
|
public int Uuid { get; set; }
|
||||||
|
[Unique, NotNull]
|
||||||
|
public string Username { get; set; } = "";
|
||||||
|
[NotNull]
|
||||||
|
public int ParentId { get; set; }
|
||||||
|
[NotNull]
|
||||||
public string PublicKey { get; set; } = "";
|
public string PublicKey { get; set; } = "";
|
||||||
|
[NotNull]
|
||||||
public int Privilege { get; set; }
|
public int Privilege { get; set; }
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,6 @@ public class UserCreating
|
|||||||
{
|
{
|
||||||
public string Response { get; set; } = "";
|
public string Response { get; set; } = "";
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public string Parent { get; set; } = "";
|
|
||||||
public string PublicKey { get; set; } = "";
|
public string PublicKey { get; set; } = "";
|
||||||
public int Privilege { get; set; }
|
public int Privilege { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
|
using Abyss.Components.Controllers.Middleware;
|
||||||
using Abyss.Components.Controllers.Task;
|
using Abyss.Components.Controllers.Task;
|
||||||
using Abyss.Components.Services;
|
using Abyss.Components.Services;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
@@ -38,11 +39,10 @@ public class Program
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.BuildServiceProvider().GetRequiredService<UserService>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// app.UseHttpsRedirection();
|
// app.UseHttpsRedirection();
|
||||||
|
app.UseMiddleware<BadRequestExceptionMiddleware>();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
|||||||
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
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
ALLOWED_VIDEO_EXTS = [".mp4", ".mkv", ".webm", ".mov", ".ogg", ".ts", ".m2ts"]
|
||||||
|
|
||||||
def get_video_duration(video_path):
|
def get_video_duration(video_path):
|
||||||
"""Get video duration in milliseconds using ffprobe"""
|
"""Get video duration in milliseconds using ffprobe"""
|
||||||
try:
|
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.
|
Extracts thumbnails from a video and saves them to the gallery directory.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if ffmpeg is installed
|
|
||||||
subprocess.run(['ffmpeg', '-version'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
subprocess.run(['ffmpeg', '-version'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
print("Error: ffmpeg is not installed or not in your PATH. Skipping thumbnail creation.")
|
print("Error: ffmpeg is not installed or not in your PATH. Skipping thumbnail creation.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get video duration using ffprobe
|
|
||||||
duration_cmd = [
|
duration_cmd = [
|
||||||
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
||||||
'-of', 'default=noprint_wrappers=1:nokey=1', str(video_path)
|
'-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:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f"Error creating cover image: {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):
|
def update_summary(base_path, name_input=None, author_input=None):
|
||||||
"""
|
"""
|
||||||
Updates the summary.json file for a given path.
|
Updates the summary.json file for a given path.
|
||||||
name_input and author_input are optional, used for the '-a' mode.
|
name_input and author_input are optional, used for the '-a' mode.
|
||||||
"""
|
"""
|
||||||
summary_path = base_path / "summary.json"
|
summary_path = base_path / "summary.json"
|
||||||
video_path = base_path / "video.mp4"
|
|
||||||
gallery_path = base_path / "gallery"
|
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 template
|
||||||
default_summary = {
|
default_summary = {
|
||||||
"name": name_input if name_input is not None else "null",
|
"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:
|
except json.JSONDecodeError:
|
||||||
print("Warning: Invalid JSON in summary.json, using defaults")
|
print("Warning: Invalid JSON in summary.json, using defaults")
|
||||||
|
|
||||||
# Update duration from video file
|
# Update duration from video file if found
|
||||||
if video_path.exists():
|
if video_path and video_path.exists():
|
||||||
default_summary["duration"] = get_video_duration(video_path)
|
default_summary["duration"] = get_video_duration(video_path)
|
||||||
else:
|
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
|
# Update gallery from directory
|
||||||
if gallery_path.exists() and gallery_path.is_dir():
|
if gallery_path.exists() and gallery_path.is_dir():
|
||||||
@@ -179,12 +206,15 @@ def main():
|
|||||||
print("Usage: python script.py <command> [arguments]")
|
print("Usage: python script.py <command> [arguments]")
|
||||||
print("Commands:")
|
print("Commands:")
|
||||||
print(" -u <path> Update the summary.json in the specified path.")
|
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).")
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
command = sys.argv[1]
|
command = sys.argv[1]
|
||||||
|
|
||||||
|
# global -y flag (if present anywhere)
|
||||||
|
assume_yes = '-y' in sys.argv
|
||||||
|
|
||||||
if command == '-u':
|
if command == '-u':
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
print("Usage: python script.py -u <path>")
|
print("Usage: python script.py -u <path>")
|
||||||
@@ -196,12 +226,14 @@ def main():
|
|||||||
update_summary(base_path)
|
update_summary(base_path)
|
||||||
|
|
||||||
elif command == '-a':
|
elif command == '-a':
|
||||||
if len(sys.argv) != 4:
|
# allow invocation with optional -y flag anywhere; expecting at least video and base path
|
||||||
print("Usage: python script.py -a <video_file> <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)
|
sys.exit(1)
|
||||||
|
|
||||||
video_source_path = Path(sys.argv[2])
|
video_source_path = Path(params[0])
|
||||||
base_path = Path(sys.argv[3])
|
base_path = Path(params[1])
|
||||||
|
|
||||||
if not video_source_path.exists() or not video_source_path.is_file():
|
if not video_source_path.exists() or not video_source_path.is_file():
|
||||||
print(f"Error: Video file not found: {video_source_path}")
|
print(f"Error: Video file not found: {video_source_path}")
|
||||||
@@ -221,24 +253,34 @@ def main():
|
|||||||
gallery_path.mkdir(exist_ok=True)
|
gallery_path.mkdir(exist_ok=True)
|
||||||
print(f"New project directory created at {new_project_path}")
|
print(f"New project directory created at {new_project_path}")
|
||||||
|
|
||||||
# Copy video file to the new directory
|
# Copy video file to the new directory, preserving extension in the target name
|
||||||
shutil.copy(video_source_path, new_project_path / "video.mp4")
|
dest_video_name = "video" + video_source_path.suffix.lower()
|
||||||
print(f"Video copied to {new_project_path / 'video.mp4'}")
|
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
|
# Auto-generate thumbnails
|
||||||
video_dest_path = new_project_path / "video.mp4"
|
|
||||||
create_thumbnails(video_dest_path, gallery_path)
|
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%
|
||||||
print("\nEnter the video name (press Enter to use the original filename):")
|
cover_path = new_project_path / "cover.jpg"
|
||||||
video_name = input(f"Video Name [{video_source_path.stem}]: ")
|
create_cover(video_dest_path, cover_path, 0.5)
|
||||||
if not video_name:
|
|
||||||
video_name = video_source_path.stem
|
|
||||||
|
|
||||||
print("\nEnter the author's name (press Enter to use 'Anonymous'):")
|
# Get user input for name and author, unless assume_yes is set
|
||||||
video_author = input("Author Name [Anonymous]: ")
|
if assume_yes:
|
||||||
if not video_author:
|
video_name = video_source_path.stem
|
||||||
video_author = "Anonymous"
|
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:
|
||||||
|
video_name = video_source_path.stem
|
||||||
|
|
||||||
|
print("\nEnter the author's name (press Enter to use 'Anonymous'):")
|
||||||
|
video_author = input("Author Name [Anonymous]: ")
|
||||||
|
if not video_author:
|
||||||
|
video_author = "Anonymous"
|
||||||
|
|
||||||
# Update the summary with user input or default values
|
# Update the summary with user input or default values
|
||||||
update_summary(new_project_path, name_input=video_name, author_input=video_author)
|
update_summary(new_project_path, name_input=video_name, author_input=video_author)
|
||||||
@@ -249,7 +291,11 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
base_path = Path(sys.argv[2])
|
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"
|
cover_path = base_path / "cover.jpg"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -261,7 +307,7 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if not video_path.exists() or not video_path.is_file():
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
create_cover(video_path, cover_path, time_percent)
|
create_cover(video_path, cover_path, time_percent)
|
||||||
@@ -272,4 +318,4 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -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()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user