Compare commits

22 Commits

Author SHA1 Message Date
acite
46ff611706 [feat] Added Init endpoint to RootController. 2025-09-20 15:18:32 +08:00
acite
f1e636a79d [optimize] Database optimizition 2025-09-20 15:03:05 +08:00
acite
87536a1508 [optimize] Refactoring AbyssCli project to Python script 2025-09-20 14:41:47 +08:00
acite
a7c522a61f [feat] Optional recursive 2025-09-20 14:22:08 +08:00
acite
3723ea32a7 [feat] Video system optimization2 2025-09-20 02:41:53 +08:00
acite
e7d24aa20b [feat] Video system optimization 2025-09-20 02:16:43 +08:00
acite
a2f6eb1fba [feat] Further improvement of the permission system 2025-09-20 00:42:23 +08:00
acite
57c37bee51 [doc] Removed doc 2025-09-17 23:01:19 +08:00
acite
a228d523a2 [fix] Exception capture mechanism 2025-09-17 19:45:11 +08:00
acite
40c041444a [update] Refactoring database logic and optimizing queries 2025-09-17 18:47:12 +08:00
acite
8465ec5b2a [doc] Icon& Doc 2025-09-14 23:19:16 +08:00
acite
ec7306ade2 [doc] Description Updated 2025-09-14 14:34:16 +08:00
acite
9aa987d52a [optimize] merge network write 2025-09-14 00:52:08 +08:00
acite
e174238d3c [feat] Abyss Protocol authentication 2025-09-13 17:01:12 +08:00
acite
cad92f8fa5 [fix] No tags in Comic Class 2025-09-13 14:46:45 +08:00
acite
9b6a4a9982 [fix] Bulk query permission 2025-09-13 14:31:59 +08:00
acite
197cf525fb [feat] Bulk query 2025-09-13 13:06:55 +08:00
acite
ae93b75e41 [merge] Merge branch 'dev-abyss' 2025-09-13 00:37:21 +08:00
acite
3f90ea9476 [fix] ReadAsync Magic Number 2025-09-13 00:36:59 +08:00
acite
23b43f15b5 [feat] Abyss protocol 2025-09-12 22:51:27 +08:00
acite
d2e11817db [feat] Safer client authentication 2025-09-11 13:55:24 +08:00
acite
f9e4510553 [merge] Merge branch 'dev-live' 2025-09-09 22:17:02 +08:00
37 changed files with 2916 additions and 738 deletions

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="dataSourceStorageLocal" created-in="RD-252.23892.524">
<component name="dataSourceStorageLocal" created-in="RD-252.25557.182">
<data-source name="user" uuid="91acd9d8-5f8b-442f-9d50-17006d4e1ac7">
<database-info product="SQLite" version="3.45.1" jdbc-version="4.2" driver-name="SQLite JDBC" driver-version="3.45.1.0" dbms="SQLITE" exact-version="3.45.1" exact-driver-version="3.45">
<identifier-quote-string>&quot;</identifier-quote-string>

View File

@@ -9,15 +9,7 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="bf317275-3039-49bb-a475-725a800a0cce" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/LiveController.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/.idea.Abyss/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.Abyss/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Abyss.csproj" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Abyss.csproj" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Services/ResourceService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Services/ResourceService.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Services/UserService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Services/UserService.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Static/Helpers.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Static/Helpers.cs" afterDir="false" />
</list>
<list default="true" id="bf317275-3039-49bb-a475-725a800a0cce" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
@@ -31,24 +23,32 @@
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/011a191356a243438f987de3ec3d6c6230800/04/8419ff35/ServiceProvider.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/457530be4752476295767457c3639889d1a000/25/817def70/ConfiguredValueTaskAwaitable`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/457530be4752476295767457c3639889d1a000/4c/4b962087/Monitor.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/457530be4752476295767457c3639889d1a000/af/aac0eaa5/ExceptionDispatchInfo.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/457530be4752476295767457c3639889d1a000/d0/3b166e9e/String.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/5df2accb46d040ccbbbe8331bf4d24b61daa00/df/93debd37/ControllerBase.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://$PROJECT_DIR$/Abyss/Components/Controllers/AbyssController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Media/LiveController.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Media/LiveController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Media/LiveController.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Middleware/BadRequestExceptionMiddleware.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Security/RootController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/RootController.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/RootController.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Task/TaskController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Services/ConfigureService.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/AbyssService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/ConfigureService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Services/ConfigureService.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/ResourceService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Services/ResourceService.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Services/ResourceService.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/TaskService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/UserService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Static/Helpers.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Tools/AbyssStream.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Tools/HttpHelper.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Tools/HttpReader.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Bookmark.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/ChallengeResponse.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Chip.cs" root0="FORCE_HIGHLIGHTING" />
@@ -75,30 +75,30 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
".NET Launch Settings Profile.Abyss: http.executor": "Run",
".NET Launch Settings Profile.Abyss: https.executor": "Debug",
".NET Project.AbyssCli.executor": "Run",
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
"ModuleVcsDetector.initialDetectionPerformed": "true",
"Publish to folder.Publish Abyss to folder x86.executor": "Run",
"Publish to folder.Publish Abyss to folder.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"XThreadsFramesViewSplitterKey": "0.30266345",
"git-widget-placeholder": "dev-live",
"last_opened_file_path": "/storage/Images/31/summary.json",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable",
"vue.rearranger.settings.migration": "true"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;.NET Launch Settings Profile.Abyss: http.executor&quot;: &quot;Debug&quot;,
&quot;.NET Launch Settings Profile.Abyss: https.executor&quot;: &quot;Debug&quot;,
&quot;.NET Project.AbyssCli.executor&quot;: &quot;Run&quot;,
&quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;Publish to folder.Publish Abyss to folder x86.executor&quot;: &quot;Run&quot;,
&quot;Publish to folder.Publish Abyss to folder.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;XThreadsFramesViewSplitterKey&quot;: &quot;0.30266345&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/acite/embd/WebProjects/Abyss/README.md&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}]]></component>
}</component>
<component name="RunManager" selected="Publish to folder.Publish Abyss to folder">
<configuration name="Publish Abyss to folder x86" type="DotNetFolderPublish" factoryName="Publish to folder">
<riderPublish configuration="Release" platform="Any CPU" produce_single_file="true" ready_to_run="true" self_contained="true" target_folder="/opt/security/https/server" target_framework="net9.0" uuid_high="3690631506471504162" uuid_low="-4858628519588143325">
@@ -155,25 +155,8 @@
<option name="Build" />
</method>
</configuration>
<configuration name="Abyss: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Abyss/Abyss.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<list>
<item itemvalue=".NET Launch Settings Profile.Abyss: http" />
<item itemvalue=".NET Launch Settings Profile.Abyss: https" />
<item itemvalue=".NET Project.AbyssCli" />
<item itemvalue="Publish to folder.Publish Abyss to folder" />
<item itemvalue="Publish to folder.Publish Abyss to folder x86" />
@@ -207,6 +190,45 @@
<workItem from="1757076719875" duration="601000" />
<workItem from="1757219779961" duration="112000" />
<workItem from="1757386288260" duration="3634000" />
<workItem from="1757428682321" duration="171000" />
<workItem from="1757429030386" duration="20000" />
<workItem from="1757508119360" duration="1704000" />
<workItem from="1757519520290" duration="14000" />
<workItem from="1757567561745" duration="2452000" />
<workItem from="1757597908282" duration="9750000" />
<workItem from="1757648650473" duration="9000" />
<workItem from="1757649246468" duration="4023000" />
<workItem from="1757653914660" duration="1923000" />
<workItem from="1757680205207" duration="3000" />
<workItem from="1757684000965" duration="2511000" />
<workItem from="1757687641035" duration="2969000" />
<workItem from="1757693751836" duration="667000" />
<workItem from="1757694833696" duration="11000" />
<workItem from="1757695721386" duration="749000" />
<workItem from="1757702942841" duration="32000" />
<workItem from="1757735249561" duration="5523000" />
<workItem from="1757742881713" duration="2285000" />
<workItem from="1757745929389" duration="93000" />
<workItem from="1757751423586" duration="2687000" />
<workItem from="1757782027930" duration="308000" />
<workItem from="1757830765557" duration="1218000" />
<workItem from="1757862781213" duration="341000" />
<workItem from="1757918235256" duration="1000" />
<workItem from="1758040123892" duration="21000" />
<workItem from="1758040188148" duration="1000" />
<workItem from="1758049713959" duration="86000" />
<workItem from="1758084310862" duration="17701000" />
<workItem from="1758121232981" duration="69000" />
<workItem from="1758279286341" duration="6796000" />
<workItem from="1758303096075" duration="2560000" />
<workItem from="1758307172642" duration="157000" />
<workItem from="1758307433345" duration="34000" />
<workItem from="1758344749532" duration="238000" />
<workItem from="1758345893755" duration="2662000" />
<workItem from="1758349313244" duration="24000" />
<workItem from="1758349710909" duration="16000" />
<workItem from="1758350096355" duration="452000" />
<workItem from="1758350848039" duration="946000" />
</task>
<servers />
</component>
@@ -226,7 +248,7 @@
<entry key="branch">
<value>
<list>
<option value="dev-task" />
<option value="main" />
</list>
</value>
</entry>

View File

@@ -2,8 +2,6 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Abyss", "Abyss\Abyss.csproj", "{3337C1CD-2419-4922-BC92-AF1A825DDF23}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AbyssCli", "AbyssCli\AbyssCli.csproj", "{D7D668D4-61E7-4AA4-B615-A162FABAD333}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -14,9 +12,5 @@ Global
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.Build.0 = Release|Any CPU
{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -1,7 +1,11 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConfiguredValueTaskAwaitable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003F25_003F817def70_003FConfiguredValueTaskAwaitable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5df2accb46d040ccbbbe8331bf4d24b61daa00_003Fdf_003F93debd37_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003Faf_003Faac0eaa5_003FExceptionDispatchInfo_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_003AServiceProvider_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F011a191356a243438f987de3ec3d6c6230800_003F04_003F8419ff35_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASQLiteAsyncConnection_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F61fe11e9d86b4d2a9bd2b806929b7d381a400_003Fe9_003F67f4a40e_003FSQLiteAsyncConnection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AString_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003Fd0_003F3b166e9e_003FString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003F6b_003F2e4babaf_003FTask_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AxxHash128_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7598e47d5cdf4107ba88f8220720fdc89000_003Fa6_003F79d67871_003FxxHash128_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

View File

@@ -13,6 +13,10 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.11" />
<PackageReference Include="SQLitePCLRaw.core" Version="3.0.2" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.11" />
<PackageReference Include="SQLitePCLRaw.provider.e_sqlite3" Version="3.0.2" />
<PackageReference Include="Standart.Hash.xxHash" Version="4.0.5" />
<PackageReference Include="System.IO.Hashing" Version="10.0.0-preview.7.25380.108" />
</ItemGroup>

View File

@@ -9,9 +9,11 @@ using Newtonsoft.Json.Linq;
namespace Abyss.Components.Controllers.Media;
using System.IO;
using Task = System.Threading.Tasks.Task;
[ApiController]
[Route("api/[controller]")]
public class ImageController(ILogger<ImageController> logger, ResourceService rs, ConfigureService config) : Controller
public class ImageController(ResourceService rs, ConfigureService config) : BaseController
{
public readonly string ImageFolder = Path.Combine(config.MediaRoot, "Images");
@@ -46,6 +48,25 @@ public class ImageController(ILogger<ImageController> logger, ResourceService rs
return Ok(await System.IO.File.ReadAllTextAsync(d));
}
[HttpPost("bulkquery")]
public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id)
{
List<string> result = new List<string>();
var db = id.Select(x => Helpers.SafePathCombine(ImageFolder, [x, "summary.json"])).ToArray();
if (db.Any(x => x == null))
return BadRequest();
if(!await rs.GetAll(db!, token, Ip))
return StatusCode(403, new { message = "403 Denied" });
var rc = db.Select(x => System.IO.File.ReadAllTextAsync(x!)).ToArray();
string[] rcs = await Task.WhenAll(rc);
var rjs = rcs.Select(JsonConvert.DeserializeObject<Comic>).Select(x => x!).ToArray();
return Ok(JsonConvert.SerializeObject(rjs));
}
[HttpPost("{id}/bookmark")]
public async Task<IActionResult> Bookmark(string id, string token, [FromBody] Bookmark bookmark)
{
@@ -78,6 +99,4 @@ public class ImageController(ILogger<ImageController> logger, ResourceService rs
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
}

View File

@@ -6,12 +6,12 @@ namespace Abyss.Components.Controllers.Media;
[ApiController]
[Route("api/[controller]")]
public class LiveController(ILogger<LiveController> logger, ResourceService rs, ConfigureService config): Controller
public class LiveController(ResourceService rs, ConfigureService config): BaseController
{
public readonly string LiveFolder = Path.Combine(config.MediaRoot, "Live");
[HttpPost("{id}")]
public async Task<IActionResult> AddLive(string id, string token, string owner)
public async Task<IActionResult> AddLive(string id, string token, int owner)
{
var d = Helpers.SafePathCombine(LiveFolder, [id]);
if (d == null) return StatusCode(403, new { message = "403 Denied" });
@@ -33,25 +33,17 @@ public class LiveController(ILogger<LiveController> logger, ResourceService rs,
return r ? Ok("Success") : BadRequest();
}
[HttpGet("{id}/{item}")]
public async Task<IActionResult> GetLive(string id, string? token, string item)
[HttpGet("{id}/{token}/{item}")]
public async Task<IActionResult> GetLive(string id, string token, string item)
{
var d = Helpers.SafePathCombine(LiveFolder, [id, item]);
var f = Helpers.SafePathCombine(LiveFolder, [id]);
if (d == null || f == null) return BadRequest();
// TODO: ffplay does not add the m3u8 query parameter in ts requests, so special treatment is given to ts here
// TODO: It should be pointed out that this implementation is not secure and should be modified in subsequent updates
if (d.EndsWith(".ts"))
{
if(System.IO.File.Exists(d))
return PhysicalFile(d, Helpers.GetContentType(d));
else
return NotFound();
}
// TODO: (History)ffplay does not add the m3u8 query parameter in ts requests, so special treatment is given to ts here
// TODO: (History)It should be pointed out that this implementation is not secure and should be modified in subsequent updates
if(token == null)
return StatusCode(403, new { message = "403 Denied" });
// TODO: It's still not very elegant, but it's a bit better to some extent
bool r = await rs.Valid(f, token, OperationType.Read, Ip);
if(!r) return StatusCode(403, new { message = "403 Denied" });
@@ -61,6 +53,4 @@ public class LiveController(ILogger<LiveController> logger, ResourceService rs,
else
return NotFound();
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
}

View File

@@ -1,19 +1,21 @@
using System.Diagnostics;
using Abyss.Components.Services;
using Abyss.Components.Static;
using Abyss.Components.Tools;
using Abyss.Model;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Newtonsoft.Json;
namespace Abyss.Components.Controllers.Media;
using Task = System.Threading.Tasks.Task;
[ApiController]
[Route("api/[controller]")]
public class VideoController(ILogger<VideoController> logger, ResourceService rs, ConfigureService config) : Controller
public class VideoController(ILogger<VideoController> logger, ResourceService rs, ConfigureService config)
: BaseController
{
private ILogger<VideoController> _logger = logger;
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
[HttpPost("init")]
@@ -43,10 +45,7 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
var r = await rs.Query(d, token, Ip);
if (r == null) return StatusCode(401, new { message = "Unauthorized" });
var rv = r.Select(x =>
{
return Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"]);
}).ToArray();
var rv = r.Select(x => { return Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"]); }).ToArray();
for (int i = 0; i < rv.Length; i++)
{
@@ -58,7 +57,7 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
.Select(x => JsonConvert.DeserializeObject<Video>(x)).ToArray();
return Ok(sv.Zip(r, (x, y) => (x, y)).NaturalSort(x => x.x.name).Select(x => x.y).ToArray());
return Ok(sv.Zip(r, (x, y) => (x, y)).NaturalSort(x => x.x!.name).Select(x => x.y).ToArray());
}
[HttpGet("{klass}/{id}")]
@@ -73,6 +72,24 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
return Ok(await System.IO.File.ReadAllTextAsync(d));
}
[HttpPost("{klass}/bulkquery")]
public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id,
[FromRoute] string klass)
{
var db = id.Select(x => Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"])).ToArray();
if (db.Any(x => x == null))
return BadRequest();
if (!await rs.GetAll(db!, token, Ip))
return StatusCode(403, new { message = "403 Denied" });
var rc = db.Select(x => System.IO.File.ReadAllTextAsync(x!)).ToArray();
string[] rcs = await Task.WhenAll(rc);
var rjs = rcs.Select(JsonConvert.DeserializeObject<Video>).Select(x => x!).ToList();
return Ok(JsonConvert.SerializeObject(rjs));
}
[HttpGet("{klass}/{id}/cover")]
public async Task<IActionResult> Cover(string klass, string id, string token)
{
@@ -82,8 +99,6 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
var r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
_logger.LogInformation($"Cover found for {id}");
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
}
@@ -99,16 +114,116 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
}
[HttpGet("{klass}/{id}/subtitle")]
public async Task<IActionResult> Subtitle(string klass, string id, string token)
{
var folder = Helpers.SafePathCombine(VideoFolder, new[] { klass, id });
if (folder == null)
return StatusCode(403, new { message = "403 Denied" });
string? subtitlePath = null;
try
{
var preferredVtt = Path.Combine(folder, "subtitle.vtt");
if (System.IO.File.Exists(preferredVtt))
{
subtitlePath = preferredVtt;
}
else
{
subtitlePath = Directory.EnumerateFiles(folder, "*.vtt").FirstOrDefault();
if (subtitlePath == null)
{
var preferredAss = Path.Combine(folder, "subtitle.ass");
if (System.IO.File.Exists(preferredAss))
{
subtitlePath = preferredAss;
}
else
{
subtitlePath = Directory.EnumerateFiles(folder, "*.ass").FirstOrDefault();
}
}
}
}
catch (DirectoryNotFoundException)
{
return NotFound(new { message = "video folder not found" });
}
if (subtitlePath == null)
return NotFound(new { message = "subtitle not found" });
var r = await rs.Get(subtitlePath, token, Ip);
if (!r)
return StatusCode(403, new { message = "403 Denied" });
var ext = Path.GetExtension(subtitlePath).ToLowerInvariant();
var contentType = ext switch
{
".vtt" => "text/vtt",
".ass" => "text/x-ssa",
_ => "text/plain"
};
return PhysicalFile(subtitlePath, contentType, enableRangeProcessing: false);
}
[HttpGet("{klass}/{id}/av")]
public async Task<IActionResult> Av(string klass, string id, string token)
{
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "video.mp4"]);
if (d == null) return StatusCode(403, new { message = "403 Denied" });
var folder = Helpers.SafePathCombine(VideoFolder, new[] { klass, id });
if (folder == null) return StatusCode(403, new { message = "403 Denied" });
var r = await rs.Get(d, token, Ip);
var allowedExt = new[] { ".mp4", ".mkv", ".webm", ".mov", ".ogg" };
string? videoPath = null;
foreach (var ext in allowedExt)
{
var p = Path.Combine(folder, "video" + ext);
if (System.IO.File.Exists(p))
{
videoPath = p;
break;
}
}
if (videoPath == null)
{
try
{
videoPath = Directory.EnumerateFiles(folder)
.FirstOrDefault(f => allowedExt.Contains(Path.GetExtension(f).ToLowerInvariant()));
}
catch (DirectoryNotFoundException)
{
return NotFound(new { message = "video folder not found" });
}
}
if (videoPath == null) return NotFound(new { message = "video not found" });
var r = await rs.Get(videoPath, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
return PhysicalFile(d, "video/mp4", enableRangeProcessing: true);
var provider = new FileExtensionContentTypeProvider();
if (!provider.TryGetContentType(videoPath, out var contentType))
{
var ext = Path.GetExtension(videoPath).ToLowerInvariant();
contentType = ext switch
{
".mkv" => "video/x-matroska",
".mp4" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
".ogg" => "video/ogg",
_ => "application/octet-stream",
};
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
return PhysicalFile(videoPath, contentType, enableRangeProcessing: true);
}
}

View File

@@ -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");
}
}
}

View File

@@ -1,13 +0,0 @@
明确几个此目录下的API的开发理念
- 永远不传输私钥
root用户的私钥仅通过服务器shell配置
私钥在客户端生成,仅将公钥传输到服务器
token通过挑战-响应机制创建,加密传输
- 用户管理
创建任何新用户都必须通过一个已有用户的token且新用户权限等级不大于该用户
root用户的权限等级为 **114514**

View 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;
}
}

View File

@@ -1,8 +1,8 @@
// UserController.cs
using System.Text.RegularExpressions;
using Abyss.Components.Services;
using Abyss.Components.Static;
using Abyss.Model;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
@@ -12,15 +12,12 @@ namespace Abyss.Components.Controllers.Security;
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("Fixed")]
public class UserController(UserService user, ILogger<UserController> logger) : Controller
public class UserController(UserService userService, ILogger<UserController> logger) : BaseController
{
private readonly ILogger<UserController> _logger = logger;
private readonly UserService _user = user;
[HttpGet("{user}")]
public async Task<IActionResult> Challenge(string user)
{
var c = await _user.Challenge(user);
var c = await userService.Challenge(user);
if (c == null)
return StatusCode(403, new { message = "Access forbidden" });
@@ -30,18 +27,17 @@ public class UserController(UserService user, ILogger<UserController> logger) :
[HttpPost("{user}")]
public async Task<IActionResult> Challenge(string user, [FromBody] ChallengeResponse response)
{
var r = await _user.Verify(user, response.Response, Ip);
var r = await userService.Verify(user, response.Response, Ip);
if (r == null)
return StatusCode(403, new { message = "Access forbidden" });
return Ok(r);
}
[HttpPost("validate")]
public IActionResult Validate(string token)
{
var u = _user.Validate(token, Ip);
if (u == null)
var u = userService.Validate(token, Ip);
if (u == -1)
{
return StatusCode(401, new { message = "Invalid" });
}
@@ -52,13 +48,13 @@ public class UserController(UserService user, ILogger<UserController> logger) :
[HttpPost("destroy")]
public IActionResult Destroy(string token)
{
var u = _user.Validate(token, Ip);
if (u == null)
var u = userService.Validate(token, Ip);
if (u == -1)
{
return StatusCode(401, new { message = "Invalid" });
}
_user.Destroy(token);
userService.Destroy(token);
return Ok("Success");
}
@@ -66,12 +62,12 @@ public class UserController(UserService user, ILogger<UserController> logger) :
public async Task<IActionResult> Create(string user, [FromBody] UserCreating creating)
{
// Valid token
var r = await _user.Verify(user, creating.Response, Ip);
var r = await userService.Verify(user, creating.Response, Ip);
if (r == null)
return StatusCode(403, new { message = "Denied" });
// User exists ?
var cu = await _user.QueryUser(creating.Name);
var cu = await userService.QueryUser(creating.Name);
if (cu != null)
return StatusCode(403, new { message = "Denied" });
@@ -80,32 +76,32 @@ public class UserController(UserService user, ILogger<UserController> logger) :
return StatusCode(403, new { message = "Denied" });
// Valid parent && Privilege
var ou = await _user.QueryUser(_user.Validate(r, Ip) ?? "");
if(creating.Parent != (_user.Validate(r, Ip) ?? "") || creating.Privilege > ou?.Privilege)
var ou = await userService.QueryUser(userService.Validate(r, Ip));
if (creating.Privilege > ou?.Privilege || ou == null)
return StatusCode(403, new { message = "Denied" });
await _user.CreateUser(new User()
await userService.CreateUser(new User
{
Name = creating.Name,
Parent = _user.Validate(r, Ip) ?? "",
Username = creating.Name,
ParentId = ou.Uuid,
Privilege = creating.Privilege,
PublicKey = creating.PublicKey,
});
_user.Destroy(r);
userService.Destroy(r);
return Ok("Success");
}
[HttpGet("{user}/open")]
public async Task<IActionResult> Open(string user, [FromQuery] string token, [FromQuery] string? bindIp = null)
{
var caller = _user.Validate(token, Ip);
if (caller == null || caller != "root")
var caller = userService.Validate(token, Ip);
if (caller != 1)
{
return StatusCode(403, new { message = "Access forbidden" });
}
var target = await _user.QueryUser(user);
var target = await userService.QueryUser(user);
if (target == null)
{
return StatusCode(404, new { message = "User not found" });
@@ -113,9 +109,10 @@ public class UserController(UserService user, ILogger<UserController> logger) :
var ipToBind = string.IsNullOrWhiteSpace(bindIp) ? Ip : bindIp;
var t = _user.CreateToken(user, ipToBind, TimeSpan.FromHours(1));
var t = userService.CreateToken(target.Uuid, ipToBind, TimeSpan.FromHours(1));
_logger.LogInformation("Root created 1h token for {User}, bound to {BindIp}, request from {ReqIp}", user, ipToBind, Ip);
logger.LogInformation("Root created 1h token for {User}, bound to {BindIp}, request from {ReqIp}", user,
ipToBind, Ip);
return Ok(new { token = t, user, boundIp = ipToBind });
}
@@ -125,6 +122,4 @@ public class UserController(UserService user, ILogger<UserController> logger) :
return false;
return Regex.IsMatch(input, @"^[a-zA-Z0-9]+$");
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
}

View File

@@ -10,7 +10,7 @@ namespace Abyss.Components.Controllers.Task;
[ApiController]
[Route("api/[controller]")]
public class TaskController(ILogger<TaskController> logger, ConfigureService config, TaskService taskService) : Controller
public class TaskController(ConfigureService config, TaskService taskService) : Controller
{
public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks");

View File

@@ -0,0 +1,189 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Abyss.Components.Tools;
namespace Abyss.Components.Services;
public class AbyssService(ILogger<AbyssService> logger, ConfigureService config, UserService user) : IHostedService, IDisposable
{
private Task? _executingTask;
private CancellationTokenSource? _cts;
private readonly TcpListener _listener = new TcpListener(IPAddress.Any, 4096);
public readonly int[] AllowedPorts = config.AllowedPorts.Split(' ').Select(int.Parse).ToArray();
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_executingTask = ExecuteAsync(_cts.Token);
return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask;
}
private async Task UpStreamTunnelAsync(AbyssStream client, NetworkStream upstream, CancellationToken token)
{
var tunnelUp = Task.Run(async () =>
{
byte[] buffer = new byte[4096];
while (!token.IsCancellationRequested)
{
int bytesRead = await client.ReadAsync(buffer, 0, buffer.Length, token);
if (bytesRead == 0)
break;
await upstream.WriteAsync(buffer, 0, bytesRead, token);
}
});
var tunnelDown = Task.Run(async () =>
{
byte[] buffer = new byte[4096];
while (!token.IsCancellationRequested)
{
int bytesRead = await upstream.ReadAsync(buffer, 0, buffer.Length, token);
if (bytesRead == 0)
break;
await client.WriteAsync(buffer, 0, bytesRead, token);
}
});
await Task.WhenAny(tunnelUp, tunnelDown);
return;
}
private async Task ClientHandlerAsync(TcpClient client, CancellationToken cancellationToken)
{
try
{
await using var stream = await client.GetAbyssStreamAsync(ct: cancellationToken, us: user);
var request = HttpHelper.Parse(await HttpReader.ReadHttpMessageAsync(stream, cancellationToken));
var port = 80;
var sp = request.RequestUri?.ToString().Split(':') ?? [];
if (sp.Length == 2)
{
port = int.Parse(sp[1]);
}
if (request.Method == "CONNECT")
{
TcpClient upClient = new TcpClient();
await upClient.ConnectAsync("127.0.0.1", port, cancellationToken);
if (!upClient.Connected)
{
var err1 = HttpHelper.BuildHttpResponse(
504,
"Gateway Timeout",
new Dictionary<string, string>
{
["Proxy-Agent"] = "Abyss/0.1",
["Content-Length"] = "0"
});
await stream.WriteAsync(Encoding.UTF8.GetBytes(err1), cancellationToken);
throw new Exception("Gateway Timeout");
}
var upstream = upClient.GetStream();
var response = HttpHelper.BuildHttpResponse(
200,
"Connection established",
new Dictionary<string, string>
{
["Proxy-Agent"] = "Abyss/0.1",
["Connection"] = "keep-alive"
});
await stream.WriteAsync(Encoding.UTF8.GetBytes(response), cancellationToken);
// Connection established
logger.LogInformation($"Tunnel for {client.Client.RemoteEndPoint} and upstream {upClient.Client.RemoteEndPoint} created");
await UpStreamTunnelAsync(stream, upstream, cancellationToken);
logger.LogInformation($"Tunnel for {client.Client.RemoteEndPoint} and upstream {upClient.Client.RemoteEndPoint} will be release");
upstream.Close();
upClient.Close();
upClient.Dispose();
}
else
{
string htmlContent = """
<html>
<head>
<title>405 Method Not Allowed</title>
</head>
<body>
<h1>Method Not Allowed</h1>
<p>The requested HTTP method is not supported by this proxy server.</p>
</body>
</html>
""";
byte[] responseBytes = Encoding.UTF8.GetBytes(htmlContent);
var response = HttpHelper.BuildHttpResponse(
405,
"Method Not Allowed",
new Dictionary<string, string>
{
["Allow"] = "CONNECT",
["Content-Type"] = "text/html; charset=utf-8",
["Content-Length"] = responseBytes.Length.ToString()
}, htmlContent);
await stream.WriteAsync(Encoding.UTF8.GetBytes(response), cancellationToken);
throw new Exception("Method Not Allowed");
}
}
catch (Exception e)
{
logger.LogError(e.Message);
}
finally
{
client.Close();
client.Dispose();
}
}
private async Task ExecuteAsync(CancellationToken stoppingToken)
{
_listener.Start();
while (!stoppingToken.IsCancellationRequested)
{
try
{
var c = await _listener.AcceptTcpClientAsync(stoppingToken);
_ = Task.Run(() => ClientHandlerAsync(c, stoppingToken), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred in background service");
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
_listener.Stop();
logger.LogInformation("TCP listener stopped");
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_executingTask == null)
return;
try
{
_cts?.CancelAsync();
}
finally
{
await Task.WhenAny(_executingTask,
Task.Delay(Timeout.Infinite, cancellationToken));
}
}
public void Dispose()
{
_cts?.Dispose();
}
}

View File

@@ -4,6 +4,7 @@ public class ConfigureService
{
public string MediaRoot { get; set; } = Environment.GetEnvironmentVariable("MEDIA_ROOT") ?? "/opt";
public string DebugMode { get; set; } = Environment.GetEnvironmentVariable("DEBUG_MODE") ?? "Production";
public string AllowedPorts { get; set; } = Environment.GetEnvironmentVariable("ALLOWED_PORTS") ?? "443"; // Split with ' '
public string Version { get; } = "Alpha v0.1";
public string UserDatabase { get; set; } = "user.db";
public string RaDatabase { get; set; } = "ra.db";

View File

@@ -4,7 +4,6 @@ using System.Text;
using System.Text.RegularExpressions;
using Abyss.Components.Static;
using Abyss.Model;
using Microsoft.Extensions.Caching.Memory;
using SQLite;
using System.IO.Hashing;
@@ -21,19 +20,15 @@ public class ResourceService
{
private readonly ILogger<ResourceService> _logger;
private readonly ConfigureService _config;
private readonly IMemoryCache _cache;
private readonly UserService _user;
private readonly SQLiteAsyncConnection _database;
private static readonly Regex PermissionRegex =
new(@"^([r-][w-]),([r-][w-]),([r-][w-])$", RegexOptions.Compiled);
private static readonly Regex PermissionRegex = new("^([r-][w-]),([r-][w-]),([r-][w-])$", RegexOptions.Compiled);
public ResourceService(ILogger<ResourceService> logger, ConfigureService config, IMemoryCache cache,
UserService user)
public ResourceService(ILogger<ResourceService> logger, ConfigureService config, UserService user)
{
_logger = logger;
_config = config;
_cache = cache;
_user = user;
_database = new SQLiteAsyncConnection(config.RaDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
@@ -42,13 +37,13 @@ public class ResourceService
var tasksPath = Helpers.SafePathCombine(_config.MediaRoot, "Tasks");
if (tasksPath != null)
{
InsertRaRow(tasksPath, "root", "rw,r-,r-", true).Wait();
InsertRaRow(tasksPath, 1, "rw,r-,r-", true).Wait();
}
var livePath = Helpers.SafePathCombine(_config.MediaRoot, "Live");
if (livePath != null)
{
InsertRaRow(livePath, "root", "rw,r-,r-", true).Wait();
InsertRaRow(livePath, 1, "rw,r-,r-", true).Wait();
}
}
@@ -57,9 +52,353 @@ public class ResourceService
{
var b = Encoding.UTF8.GetBytes(path);
var r = XxHash128.Hash(b, 0x11451419);
return Convert.ToBase64String(r ?? []);
return Convert.ToBase64String(r);
}
public async Task<Dictionary<string, bool>> ValidAny(string[] paths, string token, OperationType type, string ip)
{
var result = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
if (paths.Length == 0)
return result; // empty input -> empty result
// Normalize media root
var mediaRootFull = Path.GetFullPath(_config.MediaRoot);
// Prepare normalized full paths and early-check outside-media-root
var fullPaths = new List<string>(paths.Length);
foreach (var p in paths)
{
try
{
var full = Path.GetFullPath(p);
// record normalized path as key
if (!full.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError($"Path outside media root or null: {p}");
result[full] = false;
}
else
{
fullPaths.Add(full);
// initialize to false; will set true when all checks pass
result[full] = false;
}
}
catch (Exception ex)
{
// malformed path -> mark false and continue
_logger.LogError(ex, $"Invalid path encountered in ValidAny: {p}");
try
{
result[Path.GetFullPath(p)] = false;
}
catch
{
/* ignore */
}
}
}
if (fullPaths.Count == 0)
return result;
// Validate token and user once
int uuid = _user.Validate(token, ip);
if (uuid == -1)
{
_logger.LogError($"Invalid token: {token}");
// all previously-initialized keys remain false
return result;
}
User? user = await _user.QueryUser(uuid);
if (user == null || user.Uuid != uuid)
{
_logger.LogError($"Verification failed: {token}");
return result;
}
// Build mapping: for each input path -> list of required (uid, op)
// Also build uid -> set of ops needed overall for batching
var pathToReqs = new Dictionary<string, List<(string uid, OperationType op)>>(StringComparer.OrdinalIgnoreCase);
var uidToOps = new Dictionary<string, HashSet<OperationType>>(StringComparer.OrdinalIgnoreCase);
var uidToExampleRelPath = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var full in fullPaths)
{
try
{
// rel path relative to media root for Uid calculation
var rel = Path.GetRelativePath(_config.MediaRoot, full);
var parts = rel
.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
StringSplitOptions.RemoveEmptyEntries)
.Where(s => !string.IsNullOrEmpty(s))
.ToArray();
var reqs = new List<(string uid, OperationType op)>();
// parents: each prefix requires Read
for (int i = 0; i < parts.Length - 1; i++)
{
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
var uidDir = Uid(subPath);
reqs.Add((uidDir, OperationType.Read));
if (!uidToOps.TryGetValue(uidDir, out var ops))
{
ops = new HashSet<OperationType>();
uidToOps[uidDir] = ops;
uidToExampleRelPath[uidDir] = subPath;
}
ops.Add(OperationType.Read);
}
// resource itself requires requested 'type'
var resourcePath = (parts.Length == 0) ? string.Empty : Path.Combine(parts);
var uidRes = Uid(resourcePath);
reqs.Add((uidRes, type));
if (!uidToOps.TryGetValue(uidRes, out var resOps))
{
resOps = new HashSet<OperationType>();
uidToOps[uidRes] = resOps;
uidToExampleRelPath[uidRes] = resourcePath;
}
resOps.Add(type);
pathToReqs[full] = reqs;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error building requirements for path '{full}' in ValidAny.");
// leave result[full] as false
}
}
// Batch query DB for all UIDs (chunked)
var uidsNeeded = uidToOps.Keys.ToList();
var rasList = new List<ResourceAttribute>();
const int sqliteMaxVariableNumber = 900;
if (uidsNeeded.Count > 0)
{
for (int i = 0; i < uidsNeeded.Count; i += sqliteMaxVariableNumber)
{
var chunk = uidsNeeded.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
var queryArgs = chunk.Cast<object>().ToArray();
var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})";
var chunkResult = await _database.QueryAsync<ResourceAttribute>(sql, queryArgs);
rasList.AddRange(chunkResult);
}
}
var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
// Check each uid+op once and cache results
var permCache = new Dictionary<(string uid, OperationType op), bool>();
foreach (var kv in uidToOps)
{
var uid = kv.Key;
var ops = kv.Value;
if (!raDict.TryGetValue(uid, out var ra))
{
// missing resource attribute -> all ops for this uid are false
foreach (var op in ops)
{
permCache[(uid, op)] = false;
var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid);
_logger.LogDebug($"ValidAny: missing ResourceAttribute for Uid={uid}, example='{examplePath}'");
}
continue;
}
foreach (var op in ops)
{
var key = (uid, op);
if (!permCache.TryGetValue(key, out var ok))
{
ok = await CheckPermission(user, ra, op);
permCache[key] = ok;
}
}
}
// Compose results per original path
foreach (var kv in pathToReqs)
{
var full = kv.Key;
var reqs = kv.Value;
bool allOk = true;
foreach (var (uid, op) in reqs)
{
if (!permCache.TryGetValue((uid, op), out var ok) || !ok)
{
allOk = false;
break;
}
}
result[full] = allOk;
}
return result;
}
private async Task<bool> ValidAll(string[] paths, string token, OperationType type, string ip)
{
if (paths.Length == 0)
{
_logger.LogError("ValidAll called with empty path set");
return false;
}
var mediaRootFull = Path.GetFullPath(_config.MediaRoot);
// 1. basic path checks & normalize to relative
var relPaths = new List<string>(paths.Length);
foreach (var p in paths)
{
if (!p.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError($"Path outside media root or null: {p}");
return false;
}
relPaths.Add(Path.GetRelativePath(_config.MediaRoot, Path.GetFullPath(p)));
}
// 2. validate token and user once
int uuid = _user.Validate(token, ip);
if (uuid == -1)
{
_logger.LogError($"Invalid token: {token}");
return false;
}
User? user = await _user.QueryUser(uuid);
if (user == null || user.Uuid != uuid)
{
_logger.LogError($"Verification failed: {token}");
return false;
}
// 3. build uid -> required ops map (avoid duplicate Uid calculations)
var uidToOps = new Dictionary<string, HashSet<OperationType>>(StringComparer.OrdinalIgnoreCase);
var uidToExampleRelPath =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // for better logging
foreach (var rel in relPaths)
{
var parts = rel
.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
StringSplitOptions.RemoveEmptyEntries)
.Where(s => !string.IsNullOrEmpty(s))
.ToArray();
// parents (each prefix) require Read
for (int i = 0; i < parts.Length - 1; i++)
{
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
var uidDir = Uid(subPath);
if (!uidToOps.TryGetValue(uidDir, out var ops))
{
ops = new HashSet<OperationType>();
uidToOps[uidDir] = ops;
uidToExampleRelPath[uidDir] = subPath;
}
ops.Add(OperationType.Read);
}
// resource itself requires requested 'type'
var resourcePath = (parts.Length == 0) ? string.Empty : Path.Combine(parts);
var uidRes = Uid(resourcePath);
if (!uidToOps.TryGetValue(uidRes, out var resOps))
{
resOps = new HashSet<OperationType>();
uidToOps[uidRes] = resOps;
uidToExampleRelPath[uidRes] = resourcePath;
}
resOps.Add(type);
}
// 4. batch query DB for all UIDs using parameterized IN (...) and chunking to respect SQLite param limits
var uidsNeeded = uidToOps.Keys.ToList();
var rasList = new List<ResourceAttribute>();
const int sqliteMaxVariableNumber = 900; // keep below default 999 for safety
if (uidsNeeded.Count > 0)
{
if (uidsNeeded.Count <= sqliteMaxVariableNumber)
{
var placeholders = string.Join(",", uidsNeeded.Select(_ => "?"));
var queryArgs = uidsNeeded.Cast<object>().ToArray();
var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})";
var chunkResult = await _database.QueryAsync<ResourceAttribute>(sql, queryArgs);
rasList.AddRange(chunkResult);
}
else
{
for (int i = 0; i < uidsNeeded.Count; i += sqliteMaxVariableNumber)
{
var chunk = uidsNeeded.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
var queryArgs = chunk.Cast<object>().ToArray();
var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})";
var chunkResult = await _database.QueryAsync<ResourceAttribute>(sql, queryArgs);
rasList.AddRange(chunkResult);
}
}
}
var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
// 5. check each uid once per required operation (cache results per uid+op)
var permCache = new Dictionary<(string uid, OperationType op), bool>(); // avoid repeated CheckPermission
foreach (var kv in uidToOps)
{
var uid = kv.Key;
if (!raDict.TryGetValue(uid, out var ra))
{
var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid);
_logger.LogError(
$"Permission check failed (missing resource attribute): User: {uuid}, Resource: {examplePath}, Uid: {uid}");
return false;
}
foreach (var op in kv.Value)
{
var key = (uid, op);
if (!permCache.TryGetValue(key, out var ok))
{
ok = await CheckPermission(user, ra, op);
permCache[key] = ok;
}
if (!ok)
{
var examplePath = uidToExampleRelPath.TryGetValue(uid, out var p) ? p : uid;
_logger.LogError(
$"Permission check failed: User: {uuid}, Resource: {examplePath}, Uid: {uid}, Type: {op}");
return false;
}
}
}
return true;
}
public async Task<bool> Valid(string path, string token, OperationType type, string ip)
{
// Path is abs path here, due to Helpers.SafePathCombine
@@ -68,16 +407,16 @@ public class ResourceService
path = Path.GetRelativePath(_config.MediaRoot, path);
string? username = _user.Validate(token, ip);
if (username == null)
int uuid = _user.Validate(token, ip);
if (uuid == -1)
{
// No permission granted for invalid tokens
_logger.LogError($"Invalid token: {token}");
return false;
}
User? user = await _user.QueryUser(username);
if (user == null || user.Name != username)
User? user = await _user.QueryUser(uuid);
if (user == null || user.Uuid != uuid)
{
_logger.LogError($"Verification failed: {token}");
return false; // Two-factor authentication
@@ -90,34 +429,38 @@ public class ResourceService
{
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
var uidDir = Uid(subPath);
var raDir = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uidDir)
var raDir = await _database
.Table<ResourceAttribute>()
.Where(r => r.Uid == uidDir)
.FirstOrDefaultAsync();
if (raDir == null)
{
_logger.LogError($"Permission denied: {username} has no read access to parent directory {subPath}");
_logger.LogError($"Permission denied: {uuid} has no read access to parent directory {subPath}");
return false;
}
if (!await CheckPermission(user, raDir, OperationType.Read))
{
_logger.LogError($"Permission denied: {username} has no read access to parent directory {subPath}");
_logger.LogError($"Permission denied: {uuid} has no read access to parent directory {subPath}");
return false;
}
}
var uid = Uid(path);
ResourceAttribute? ra = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid)
ResourceAttribute? ra = await _database
.Table<ResourceAttribute>()
.Where(r => r.Uid == uid)
.FirstOrDefaultAsync();
if (ra == null)
{
_logger.LogError($"Permission check failed: User: {username}, Resource: {path}, Type: {type.ToString()} ");
_logger.LogError($"Permission check failed: User: {uuid}, Resource: {path}, Type: {type.ToString()} ");
return false;
}
var l = await CheckPermission(user, ra, type);
if (!l)
{
_logger.LogError($"Permission check failed: User: {username}, Resource: {path}, Type: {type.ToString()} ");
_logger.LogError($"Permission check failed: User: {uuid}, Resource: {path}, Type: {type.ToString()} ");
}
return l;
@@ -135,7 +478,7 @@ public class ResourceService
var owner = await _user.QueryUser(ra.Owner);
if (owner == null) return false;
bool isOwner = ra.Owner == user.Name;
bool isOwner = ra.Owner == user.Uuid;
bool isPeer = !isOwner && user.Privilege == owner.Privilege;
bool isOther = !isOwner && !isPeer;
@@ -152,7 +495,7 @@ public class ResourceService
case OperationType.Write:
return currentPerm.Contains('w') || (user.Privilege > owner.Privilege);
case OperationType.Security:
return (isOwner && currentPerm.Contains('w')) || user.Name == "root";
return (isOwner && currentPerm.Contains('w')) || user.Uuid == 1;
default:
return false;
}
@@ -166,8 +509,45 @@ public class ResourceService
if (Helpers.GetPathType(path) != PathType.Directory)
return null;
var files = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly);
return files.Select(x => Path.GetRelativePath(path, x)).ToArray();
try
{
var entries = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly).ToArray();
if (entries.Length == 0)
return Array.Empty<string>();
var validMap = await ValidAny(entries, token, OperationType.Read, ip);
var allowed = new List<string>(entries.Length);
foreach (var entry in entries)
{
try
{
var full = Path.GetFullPath(entry);
if (validMap.TryGetValue(full, out var ok) && ok)
{
allowed.Add(Path.GetRelativePath(path, entry));
}
else
{
_logger.LogDebug(
$"Query: access denied or not managed for '{entry}' (user token: {token}) - item skipped.");
}
}
catch (Exception exEntry)
{
_logger.LogError(exEntry, $"Error processing entry '{entry}' in Query.");
}
}
return allowed.ToArray();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error while listing directory '{path}' in Query.");
return null;
}
}
public async Task<bool> Get(string path, string token, string ip)
@@ -175,22 +555,35 @@ public class ResourceService
return await Valid(path, token, OperationType.Read, ip);
}
public async Task<bool> GetAll(string[] path, string token, string ip)
{
return await ValidAll(path, token, OperationType.Read, ip);
}
public async Task<bool> Update(string path, string token, string ip)
{
return await Valid(path, token, OperationType.Write, ip);
}
public async Task<bool> Initialize(string path, string token, string username, string ip)
public async Task<bool> Initialize(string path, string token, string owner, string ip)
{
var u = await _user.QueryUser(owner);
if (u == null || u.Uuid == -1) return false;
return await Initialize(path, token, u.Uuid, ip);
}
public async Task<bool> Initialize(string path, string token, int owner, string ip)
{
// TODO: Use a more elegant Debug mode
if (_config.DebugMode == "Debug")
goto debug;
// 1. Authorization: Verify the operation is performed by 'root'
var requester = _user.Validate(token, ip);
if (requester != "root")
if (requester != 1)
{
_logger.LogWarning(
$"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to initialize resources.");
$"Permission denied: Non-root user '{requester}' attempted to initialize resources.");
return false;
}
@@ -202,10 +595,10 @@ public class ResourceService
return false;
}
var ownerUser = await _user.QueryUser(username);
var ownerUser = await _user.QueryUser(owner);
if (ownerUser == null)
{
_logger.LogError($"Initialization failed: Owner user '{username}' does not exist.");
_logger.LogError($"Initialization failed: Owner user '{owner}' does not exist.");
return false;
}
@@ -229,8 +622,7 @@ public class ResourceService
newResources.Add(new ResourceAttribute
{
Uid = uid,
Name = currentPath,
Owner = username,
Owner = owner,
Permission = "rw,--,--"
});
}
@@ -241,7 +633,7 @@ public class ResourceService
{
await _database.InsertAllAsync(newResources);
_logger.LogInformation(
$"Successfully initialized {newResources.Count} new resources under '{path}' for user '{username}'.");
$"Successfully initialized {newResources.Count} new resources under '{path}' for user '{owner}'.");
}
else
{
@@ -258,22 +650,23 @@ public class ResourceService
}
}
public async Task<bool> Put(string path, string token, string ip)
{
throw new NotImplementedException();
}
// public async Task<bool> Put(string path, string token, string ip)
// {
// throw new NotImplementedException();
// }
public async Task<bool> Delete(string path, string token, string ip)
{
throw new NotImplementedException();
}
// public async Task<bool> Delete(string path, string token, string ip)
// {
// throw new NotImplementedException();
// }
public async Task<bool> Exclude(string path, string token, string ip)
{
var requester = _user.Validate(token, ip);
if (requester != "root")
if (requester != 1)
{
_logger.LogWarning($"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to exclude resource '{path}'.");
_logger.LogWarning(
$"Permission denied: Non-root user '{requester}' attempted to exclude resource '{path}'.");
return false;
}
@@ -308,13 +701,13 @@ public class ResourceService
}
}
public async Task<bool> Include(string path, string token, string ip, string owner, string permission)
public async Task<bool> Include(string path, string token, string ip, int owner, string permission)
{
var requester = _user.Validate(token, ip);
if (requester != "root")
if (requester != 1)
{
_logger.LogWarning(
$"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to include resource '{path}'.");
$"Permission denied: Non-root user '{requester}' attempted to include resource '{path}'.");
return false;
}
@@ -346,7 +739,6 @@ public class ResourceService
var newResource = new ResourceAttribute
{
Uid = uid,
Name = relPath,
Owner = owner,
Permission = permission
};
@@ -388,41 +780,97 @@ public class ResourceService
}
}
public async Task<bool> Chmod(string path, string token, string permission, string ip)
public async Task<bool> Chmod(string path, string token, string permission, string ip, bool recursive = false)
{
if (!await Valid(path, token, OperationType.Security, ip))
return false;
// Validate the permission format using the existing regex
// Validate permission format first
if (!PermissionRegex.IsMatch(permission))
{
_logger.LogError($"Invalid permission format: {permission}");
return false;
}
// Normalize path to full path
path = Path.GetFullPath(path);
// Collect targets and permission checks
List<string> targets = new List<string>();
try
{
path = Path.GetRelativePath(_config.MediaRoot, path);
var uid = Uid(path);
var resource = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
if (resource == null)
if (recursive && Directory.Exists(path))
{
_logger.LogError($"Resource not found: {path}");
_logger.LogInformation($"Recursive directory '{path}'.");
targets.Add(path);
foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
{
targets.Add(entry);
}
if (!await ValidAll(targets.ToArray(), token, OperationType.Security, ip))
{
_logger.LogWarning($"Permission denied for recursive chmod on '{path}'");
return false;
}
resource.Permission = permission;
var rowsAffected = await _database.UpdateAsync(resource);
if (rowsAffected > 0)
_logger.LogInformation($"Successfully validated chmod on '{path}'.");
}
else
{
_logger.LogInformation($"Successfully changed permissions for '{path}' to '{permission}'");
if (!await Valid(path, token, OperationType.Security, ip))
{
_logger.LogWarning($"Permission denied for chmod on '{path}'");
return false;
}
targets.Add(path);
}
// Build distinct UIDs
var relUids = targets
.Select(t => Path.GetRelativePath(_config.MediaRoot, t))
.Select(rel => Uid(rel))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (relUids.Count == 0)
{
_logger.LogWarning($"No targets resolved for chmod on '{path}'");
return false;
}
// Chunked bulk UPDATE using SQL "UPDATE ... WHERE Uid IN (...)"
int updatedCount = 0;
const int sqliteMaxVariableNumber = 900; // leave some headroom for other params
for (int i = 0; i < relUids.Count; i += sqliteMaxVariableNumber)
{
var chunk = relUids.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
// First param is permission, rest are Uid values
var args = new List<object> { permission };
args.AddRange(chunk);
var sql = $"UPDATE ResourceAttributes SET Permission = ? WHERE Uid IN ({placeholders})";
try
{
var rowsAffected = await _database.ExecuteAsync(sql, args.ToArray());
updatedCount += rowsAffected;
_logger.LogInformation($"Chmod chunk updated {rowsAffected} rows (chunk size {chunk.Count}).");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error executing chmod update chunk for path '{path}'.");
// continue with other chunks; do not abort whole operation on one chunk error
}
}
if (updatedCount > 0)
{
_logger.LogInformation(
$"Chmod: updated permissions for {updatedCount} resource(s) (root='{path}', recursive={recursive})");
return true;
}
else
{
_logger.LogError($"Failed to update permissions for: {path}");
_logger.LogWarning($"Chmod: no resources updated for '{path}' (recursive={recursive})");
return false;
}
}
@@ -433,13 +881,9 @@ public class ResourceService
}
}
public async Task<bool> Chown(string path, string token, string owner, string ip)
public async Task<bool> Chown(string path, string token, int owner, string ip, bool recursive = false)
{
if (!await Valid(path, token, OperationType.Security, ip))
return false;
// Validate that the new owner exists
// Validate new owner exists
var newOwner = await _user.QueryUser(owner);
if (newOwner == null)
{
@@ -447,29 +891,84 @@ public class ResourceService
return false;
}
// Normalize
path = Path.GetFullPath(path);
// Permission checks and target collection
List<string> targets = new List<string>();
try
{
path = Path.GetRelativePath(_config.MediaRoot, path);
var uid = Uid(path);
var resource = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
if (resource == null)
if (recursive && Directory.Exists(path))
{
_logger.LogError($"Resource not found: {path}");
targets.Add(path);
foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
{
targets.Add(entry);
}
if (!await ValidAll(targets.ToArray(), token, OperationType.Security, ip))
{
_logger.LogWarning($"Permission denied for recursive chown on '{path}'");
return false;
}
}
else
{
if (!await Valid(path, token, OperationType.Security, ip))
{
_logger.LogWarning($"Permission denied for chown on '{path}'");
return false;
}
resource.Owner = owner;
var rowsAffected = await _database.UpdateAsync(resource);
targets.Add(path);
}
if (rowsAffected > 0)
// Build distinct UIDs
var relUids = targets
.Select(t => Path.GetRelativePath(_config.MediaRoot, t))
.Select(rel => Uid(rel))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (relUids.Count == 0)
{
_logger.LogInformation($"Successfully changed ownership of '{path}' to '{owner}'");
_logger.LogWarning($"No targets resolved for chown on '{path}'");
return false;
}
// Chunked bulk UPDATE: SET Owner = ? WHERE Uid IN (...)
int updatedCount = 0;
const int sqliteMaxVariableNumber = 900;
for (int i = 0; i < relUids.Count; i += sqliteMaxVariableNumber)
{
var chunk = relUids.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
var args = new List<object> { owner };
args.AddRange(chunk);
var sql = $"UPDATE ResourceAttributes SET Owner = ? WHERE Uid IN ({placeholders})";
try
{
var rowsAffected = await _database.ExecuteAsync(sql, args.ToArray());
updatedCount += rowsAffected;
_logger.LogInformation($"Chown chunk updated {rowsAffected} rows (chunk size {chunk.Count}).");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error executing chown update chunk for path '{path}'.");
// continue with remaining chunks
}
}
if (updatedCount > 0)
{
_logger.LogInformation(
$"Chown: changed owner for {updatedCount} resource(s) (root='{path}', recursive={recursive})");
return true;
}
else
{
_logger.LogError($"Failed to change ownership for: {path}");
_logger.LogWarning($"Chown: no resources updated for '{path}' (recursive={recursive})");
return false;
}
}
@@ -480,7 +979,8 @@ public class ResourceService
}
}
private async Task<bool> InsertRaRow(string fullPath, string owner, string permission, bool update = false)
private async Task<bool> InsertRaRow(string fullPath, int owner, string permission, bool update = false)
{
if (!PermissionRegex.IsMatch(permission))
{
@@ -494,7 +994,6 @@ public class ResourceService
return await _database.InsertOrReplaceAsync(new ResourceAttribute()
{
Uid = Uid(path),
Name = path,
Owner = owner,
Permission = permission,
}) == 1;
@@ -503,10 +1002,37 @@ public class ResourceService
return await _database.InsertAsync(new ResourceAttribute()
{
Uid = Uid(path),
Name = path,
Owner = owner,
Permission = permission,
}) == 1;
}
}
public async Task<ResourceAttribute?> GetAttribute(string path)
{
try
{
// normalize to full path
var full = Path.GetFullPath(path);
// ensure it's under media root
var mediaRootFull = Path.GetFullPath(_config.MediaRoot);
if (!full.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
return null;
var rel = Path.GetRelativePath(_config.MediaRoot, full);
var uid = Uid(rel);
var ra = await _database.Table<ResourceAttribute>()
.Where(r => r.Uid == uid)
.FirstOrDefaultAsync();
return ra;
}
catch (Exception ex)
{
_logger.LogError(ex, $"GetAttribute failed for path '{path}'");
return null;
}
}
}

View File

@@ -61,7 +61,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
if(!await rs.Valid(VideoFolder, token, OperationType.Write, ip))
return null;
var u = user.Validate(token, ip);
if(u == null)
if(u == -1)
return null;
var r = new TaskCreationResponse()
@@ -74,7 +74,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
Directory.CreateDirectory(Path.Combine(TaskFolder, r.Id.ToString(), "gallery"));
// It shouldn't be a problem to spell it directly like this, as all the parameters are generated by myself
Task v = new Task()
Task v = new Task
{
Name = creation.Name,
Owner = u,
@@ -83,7 +83,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
Type = TaskType.Video
};
await System.IO.File.WriteAllTextAsync(
await File.WriteAllTextAsync(
Path.Combine(TaskFolder, r.Id.ToString(), "task.json"),
JsonConvert.SerializeObject(v, Formatting.Indented));
@@ -239,7 +239,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
return -1;
}
}
catch (Exception ex)
catch (Exception)
{
return -1;
}

View File

@@ -14,22 +14,20 @@ namespace Abyss.Components.Services;
public class UserService
{
private readonly ILogger<UserService> _logger;
private readonly ConfigureService _config;
private readonly IMemoryCache _cache;
private readonly SQLiteAsyncConnection _database;
public UserService(ILogger<UserService> logger, ConfigureService config, IMemoryCache cache)
{
_logger = logger;
_config = config;
_cache = cache;
_database = new SQLiteAsyncConnection(config.UserDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
_database.CreateTableAsync<User>().Wait();
var rootUser = _database.Table<User>().Where(x => x.Name == "root").FirstOrDefaultAsync().Result;
var rootUser = _database.Table<User>().Where(x => x.Uuid == 1).FirstOrDefaultAsync().Result;
if (_config.DebugMode == "Debug")
_cache.Set("root", $"root@127.0.0.1", DateTimeOffset.Now.AddHours(1));
if (config.DebugMode == "Debug")
_cache.Set("abyss", $"1@127.0.0.1", DateTimeOffset.Now.AddHours(1));
// Test token, can only be used locally. Will be destroyed in one hour.
if (rootUser == null)
@@ -50,8 +48,9 @@ public class UserService
Console.WriteLine("key: '" + privateKeyBase64 + "'");
_database.InsertAsync(new User()
{
Name = "root",
Parent = "root",
Uuid = 1,
Username = "root",
ParentId = 1,
PublicKey = publicKeyBase64,
Privilege = 1145141919,
}).Wait();
@@ -61,15 +60,16 @@ public class UserService
}
public async Task<string?> Challenge(string user)
{
var u = await _database.Table<User>().Where(x => x.Name == user).FirstOrDefaultAsync();
var u = await _database.Table<User>().Where(x => x.Username == user).FirstOrDefaultAsync();
if (u == null) // Error: User not exists
return null;
if (_cache.TryGetValue(u.Name, out var challenge)) // The previous challenge has not yet expired
_cache.Remove(u.Name);
if (_cache.TryGetValue(u.Uuid, out _)) // The previous challenge has not yet expired
_cache.Remove(u.Uuid);
var c = Convert.ToBase64String(Encoding.UTF8.GetBytes(GenerateRandomAsciiString(32)));
_cache.Set(u.Name,c, DateTimeOffset.Now.AddMinutes(1));
_cache.Set(u.Uuid, c, DateTimeOffset.Now.AddMinutes(1));
return c;
}
@@ -77,12 +77,13 @@ public class UserService
// but the source that obtains the token must be the same as the source that uses the token in the future
public async Task<string?> Verify(string user, string response, string ip)
{
var u = await _database.Table<User>().Where(x => x.Name == user).FirstOrDefaultAsync();
var u = await _database.Table<User>().Where(x => x.Username == user).FirstOrDefaultAsync();
if (u == null) // Error: User not exists
{
return null;
}
if (_cache.TryGetValue(u.Name, out string? challenge))
if (_cache.TryGetValue(u.Uuid, out string? challenge))
{
bool isVerified = VerifySignature(
PublicKey.Import(
@@ -95,16 +96,16 @@ public class UserService
if (!isVerified)
{
// Verification failed, set the challenge string to random to prevent duplicate verification
_cache.Set(u.Name, $"failed : {GenerateRandomAsciiString(32)}", DateTimeOffset.Now.AddMinutes(1));
_cache.Set(u.Uuid, $"failed : {GenerateRandomAsciiString(32)}", DateTimeOffset.Now.AddMinutes(1));
return null;
}
else
{
// Remove the challenge string and create a session
_cache.Remove(u.Name);
_cache.Remove(u.Uuid);
var s = GenerateRandomAsciiString(64);
_cache.Set(s, $"{u.Name}@{ip}", DateTimeOffset.Now.AddDays(1));
_logger.LogInformation($"Verified {u.Name}@{ip}");
_cache.Set(s, $"{u.Uuid}@{ip}", DateTimeOffset.Now.AddDays(1));
_logger.LogInformation($"Verified {u.Uuid}@{ip}, Name: {u.Username}");
return s;
}
}
@@ -112,21 +113,23 @@ public class UserService
return null;
}
public string? Validate(string token, string ip)
// Id >= 1 : Success, Uid
// Id == -1: Failed
public int Validate(string token, string ip)
{
if (_cache.TryGetValue(token, out string? userAndIp))
{
if (ip != userAndIp?.Split('@')[1])
if (ip != userAndIp?.Split('@')[1] && ip != "127.0.0.1")
{
_logger.LogError($"Token used from another Host: {token}");
Destroy(token);
return null;
return -1;
}
// _logger.LogInformation($"Validated {userAndIp}");
return userAndIp?.Split('@')[0];
return Convert.ToInt32(userAndIp?.Split('@')[0]);
}
_logger.LogWarning($"Validation failed {token}");
return null;
return -1;
}
public void Destroy(string token)
@@ -134,16 +137,24 @@ public class UserService
_cache.Remove(token);
}
public async Task<User?> QueryUser(string user)
public async Task<User?> QueryUser(int uid)
{
var u = await _database.Table<User>().Where(x => x.Name == user).FirstOrDefaultAsync();
if (uid == -1)
return null;
var u = await _database.Table<User>().Where(x => x.Uuid == uid).FirstOrDefaultAsync();
return u;
}
public async Task<User?> QueryUser(string username)
{
var u = await _database.Table<User>().Where(x => x.Username == username).FirstOrDefaultAsync();
return u;
}
public async Task CreateUser(User user)
{
await _database.InsertAsync(user);
_logger.LogInformation($"Created user: {user.Name}, Parent: {user.Parent}, Privilege: {user.Privilege}");
_logger.LogInformation($"Created user: {user.Username}, Uid: {user.Uuid}, Parent: {user.ParentId}, Privilege: {user.Privilege}");
}
static Key GenerateKeyPair()
@@ -174,17 +185,44 @@ public class UserService
}
}
static bool VerifySignature(PublicKey publicKey, byte[] data, byte[] signature)
public static bool VerifySignature(PublicKey publicKey, byte[] data, byte[] signature)
{
var algorithm = SignatureAlgorithm.Ed25519;
return algorithm.Verify(publicKey, data, signature);
}
public string CreateToken(string user, string ip, TimeSpan lifetime)
public async Task<bool> VerifyAny(byte[] data, byte[] signature)
{
var users = await _database.Table<User>().ToListAsync();
foreach (var u in users)
{
try
{
var pubKeyBytes = Convert.FromBase64String(u.PublicKey);
var pubKey = PublicKey.Import(
SignatureAlgorithm.Ed25519,
pubKeyBytes,
KeyBlobFormat.RawPublicKey);
if (VerifySignature(pubKey, data, signature))
{
_logger.LogInformation($"Signature verified using user {u.Username}");
return true;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, $"Failed to import public key for {u.Username}");
}
}
return false;
}
public string CreateToken(int uid, string ip, TimeSpan lifetime)
{
var token = GenerateRandomAsciiString(64);
_cache.Set(token, $"{user}@{ip}", DateTimeOffset.Now.Add(lifetime));
_logger.LogInformation($"Created token for {user}@{ip}, valid {lifetime.TotalMinutes} minutes");
_cache.Set(token, $"{uid}@{ip}", DateTimeOffset.Now.Add(lifetime));
_logger.LogInformation($"Created token for {uid}@{ip}, valid {lifetime.TotalMinutes} minutes");
return token;
}
}

View File

@@ -0,0 +1,57 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
namespace Abyss.Components.Static;
public abstract class BaseController : Controller
{
private string? _ip;
protected string Ip
{
get
{
if (_ip != null)
return _ip;
_ip = GetClientIpAddress();
if (string.IsNullOrEmpty(_ip))
throw new InvalidOperationException("invalid IP");
return _ip;
}
}
private string? GetClientIpAddress()
{
var remoteIp = HttpContext.Connection.RemoteIpAddress;
if (remoteIp != null && (IPAddress.IsLoopback(remoteIp) || remoteIp.ToString() == "::1"))
{
return remoteIp.ToString();
}
string? ip = remoteIp?.ToString();
if (HttpContext.Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
{
var forwardedIps = forwardedFor.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrEmpty(x))
.ToArray();
if (forwardedIps.Length > 0)
{
ip = forwardedIps[0];
}
}
if (string.IsNullOrEmpty(ip) && HttpContext.Request.Headers.TryGetValue("X-Real-IP", out var realIp))
{
ip = realIp.ToString();
}
return ip;
}
}

View File

@@ -73,8 +73,6 @@ public static class Helpers
{
return PathType.AccessDenied;
}
return PathType.NotFound;
}
}

View File

@@ -0,0 +1,552 @@
// Target: .NET 9
// NuGet: NSec.Cryptography (for X25519)
// Note: ChaCha20Poly1305 is used from System.Security.Cryptography (available in .NET 7+ / .NET 9)
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Data;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Abyss.Components.Services;
using Microsoft.AspNetCore.Authentication;
using NSec.Cryptography;
using ChaCha20Poly1305 = System.Security.Cryptography.ChaCha20Poly1305;
namespace Abyss.Components.Tools
{
// TODO: (complete) Since C25519 has already been used for user authentication,
// TODO: (complete) why not use that public key to verify user identity when establishing a secure channel here?
public sealed class AbyssStream : NetworkStream, IDisposable
{
private const int PublicKeyLength = 32;
private const int AeadKeyLen = 32;
private const int NonceSaltLen = 4;
private const int AeadTagLen = 16;
private const int NonceLen = 12; // 4-byte salt + 8-byte counter
private const int MaxPlaintextFrame = 64 * 1024; // 64 KiB per frame
private readonly ChaCha20Poly1305 _aead;
private readonly byte[] _sendNonceSalt = new byte[NonceSaltLen];
private readonly byte[] _recvNonceSalt = new byte[NonceSaltLen];
// Counters and locks
private ulong _sendCounter;
private ulong _recvCounter;
private readonly object _sendLock = new();
private readonly object _aeadLock = new();
// Inbound leftover cache (FIFO)
private readonly ConcurrentQueue<byte[]> _leftoverQueue = new();
private byte[]? _currentLeftoverSegment;
private int _currentLeftoverOffset;
private bool _disposed;
private AbyssStream(Socket socket, bool ownsSocket, byte[] aeadKey, byte[] sendSalt, byte[] recvSalt)
: base(socket, ownsSocket)
{
if (aeadKey == null || aeadKey.Length != AeadKeyLen) throw new ArgumentException(nameof(aeadKey));
if (sendSalt == null || sendSalt.Length != NonceSaltLen) throw new ArgumentException(nameof(sendSalt));
if (recvSalt == null || recvSalt.Length != NonceSaltLen) throw new ArgumentException(nameof(recvSalt));
Array.Copy(sendSalt, 0, _sendNonceSalt, 0, NonceSaltLen);
Array.Copy(recvSalt, 0, _recvNonceSalt, 0, NonceSaltLen);
// ChaCha20Poly1305 is in System.Security.Cryptography in .NET 9
_aead = new ChaCha20Poly1305(aeadKey);
}
/// <summary>
/// Create an AbyssStream over an established TcpClient.
/// Handshake: X25519 public exchange (raw) -> shared secret -> HKDF -> AEAD key + saltA + saltB
/// send/recv salts are assigned deterministically by lexicographic comparison of raw public keys.
/// </summary>
public static async Task<AbyssStream> CreateAsync(TcpClient client, UserService us, byte[]? privateKeyRaw = null, CancellationToken cancellationToken = default)
{
if (client == null) throw new ArgumentNullException(nameof(client));
var socket = client.Client ?? throw new ArgumentException("TcpClient has no underlying socket");
// 1) Prepare local X25519 key (NSec)
Key? localKey = null;
try
{
if (privateKeyRaw != null)
{
if (privateKeyRaw.Length != KeyAgreementAlgorithm.X25519.PrivateKeySize)
throw new ArgumentException($"privateKeyRaw must be {KeyAgreementAlgorithm.X25519.PrivateKeySize} bytes");
localKey = Key.Import(KeyAgreementAlgorithm.X25519, privateKeyRaw, KeyBlobFormat.RawPrivateKey);
}
else
{
var creationParams = new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport };
localKey = Key.Create(KeyAgreementAlgorithm.X25519, creationParams);
}
}
catch
{
localKey?.Dispose();
throw;
}
var localPublic = localKey.Export(KeyBlobFormat.RawPublicKey);
// 2) Exchange public keys using raw socket APIs
var remotePublic = new byte[PublicKeyLength];
var sent = 0;
while (sent < PublicKeyLength)
{
var toSend = new ReadOnlyMemory<byte>(localPublic, sent, PublicKeyLength - sent);
sent += await socket.SendAsync(toSend, SocketFlags.None, cancellationToken).ConfigureAwait(false);
}
await ReadExactFromSocketAsync(socket, remotePublic, 0, PublicKeyLength, cancellationToken).ConfigureAwait(false);
var ch = Encoding.UTF8.GetBytes(UserService.GenerateRandomAsciiString(32));
sent = 0;
while (sent < ch.Length)
{
var toSend = new ReadOnlyMemory<byte>(ch, sent, ch.Length - sent);
sent += await socket.SendAsync(toSend, SocketFlags.None, cancellationToken).ConfigureAwait(false);
}
var rch = new byte[64];
await ReadExactFromSocketAsync(socket, rch, 0, 64, cancellationToken).ConfigureAwait(false);
bool rau = await us.VerifyAny(ch, rch);
if (!rau) throw new AuthenticationFailureException("");
var ack = Encoding.UTF8.GetBytes(UserService.GenerateRandomAsciiString(16));
sent = 0;
while (sent < ack.Length)
{
var toSend = new ReadOnlyMemory<byte>(ack, sent, ack.Length - sent);
sent += await socket.SendAsync(toSend, SocketFlags.None, cancellationToken).ConfigureAwait(false);
}
// 3) Compute shared secret (X25519)
PublicKey remotePub;
try
{
remotePub = PublicKey.Import(KeyAgreementAlgorithm.X25519, remotePublic, KeyBlobFormat.RawPublicKey);
}
catch (Exception ex)
{
localKey.Dispose();
throw new InvalidOperationException("Failed to import remote public key", ex);
}
byte[] aeadKey;
byte[] saltA;
byte[] saltB;
using (var shared = KeyAgreementAlgorithm.X25519.Agree(localKey, remotePub))
{
if (shared == null)
throw new InvalidOperationException("Failed to agree remote public key");
// Derive AEAD key and two independent nonce salts directly from the SharedSecret,
// using HKDF-SHA256 within NSec (no raw shared-secret export).
aeadKey = KeyDerivationAlgorithm.HkdfSha256.DeriveBytes(
shared,
salt: null,
info: System.Text.Encoding.ASCII.GetBytes("Abyss-AEAD-Key"),
count: AeadKeyLen);
saltA = KeyDerivationAlgorithm.HkdfSha256.DeriveBytes(
shared,
salt: null,
info: System.Text.Encoding.ASCII.GetBytes("Abyss-Nonce-Salt-A"),
count: NonceSaltLen);
saltB = KeyDerivationAlgorithm.HkdfSha256.DeriveBytes(
shared,
salt: null,
info: System.Text.Encoding.ASCII.GetBytes("Abyss-Nonce-Salt-B"),
count: NonceSaltLen);
}
// localKey no longer needed
localKey.Dispose();
// Deterministic assignment by lexicographic comparison of raw public keys
byte[] sendSalt, recvSalt;
int cmp = CompareByteArrayLexicographic(localPublic, remotePublic);
if (cmp < 0)
{
sendSalt = saltA;
recvSalt = saltB;
}
else if (cmp > 0)
{
sendSalt = saltB;
recvSalt = saltA;
}
else
{
// extremely unlikely: identical public keys; fallback
sendSalt = saltA;
recvSalt = saltB;
}
Array.Clear(localPublic, 0, localPublic.Length);
Array.Clear(remotePublic, 0, remotePublic.Length);
var abyss = new AbyssStream(socket, ownsSocket: true, aeadKey: aeadKey, sendSalt: sendSalt, recvSalt: recvSalt);
Array.Clear(aeadKey, 0, aeadKey.Length);
Array.Clear(saltA, 0, saltA.Length);
Array.Clear(saltB, 0, saltB.Length);
return abyss;
}
public override int Read(byte[] buffer, int offset, int count)
{
return ReadAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult();
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
if (buffer == null) throw new ArgumentNullException(nameof(buffer));
if (offset < 0 || count < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException();
ThrowIfDisposed();
// Serve leftover first if any (immediately return any available bytes)
if (EnsureCurrentLeftoverSegment())
{
var seg = _currentLeftoverSegment;
var avail = seg!.Length - _currentLeftoverOffset;
var toCopy = Math.Min(avail, count);
Array.Copy(seg, _currentLeftoverOffset, buffer, offset, toCopy);
_currentLeftoverOffset += toCopy;
if (_currentLeftoverOffset >= seg.Length)
{
_currentLeftoverSegment = null;
_currentLeftoverOffset = 0;
}
return toCopy;
}
// No leftover -> read exactly one frame and decrypt
var plaintext = await ReadOneFrameAndDecryptAsync(cancellationToken).ConfigureAwait(false);
if (plaintext == null || plaintext.Length == 0)
{
// EOF
return 0;
}
if (plaintext.Length <= count)
{
Array.Copy(plaintext, 0, buffer, offset, plaintext.Length);
return plaintext.Length;
}
else
{
Array.Copy(plaintext, 0, buffer, offset, count);
var leftoverLen = plaintext.Length - count;
var leftover = new byte[leftoverLen];
Array.Copy(plaintext, count, leftover, 0, leftoverLen);
_leftoverQueue.Enqueue(leftover);
return count;
}
}
private async Task<byte[]?> ReadOneFrameAndDecryptAsync(CancellationToken cancellationToken)
{
var header = new byte[4];
await ReadExactFromBaseAsync(header, 0, 4, cancellationToken).ConfigureAwait(false);
var payloadLen = (int)BinaryPrimitives.ReadUInt32BigEndian(header);
if (payloadLen > MaxPlaintextFrame) throw new InvalidDataException("payload too big");
if (payloadLen < AeadTagLen) throw new InvalidDataException("payload too small");
var payload = new byte[payloadLen];
await ReadExactFromBaseAsync(payload, 0, payloadLen, cancellationToken).ConfigureAwait(false);
var ciphertextLen = payloadLen - AeadTagLen;
var ciphertext = new byte[ciphertextLen];
var tag = new byte[AeadTagLen];
if (ciphertextLen > 0) Array.Copy(payload, 0, ciphertext, 0, ciphertextLen);
Array.Copy(payload, ciphertextLen, tag, 0, AeadTagLen);
// compute remote nonce using recv counter (no role bit)
ulong remoteCounterValue = _recvCounter;
_recvCounter++;
var nonce = new byte[NonceLen];
Array.Copy(_recvNonceSalt, 0, nonce, 0, NonceSaltLen);
BinaryPrimitives.WriteUInt64BigEndian(nonce.AsSpan(NonceSaltLen), remoteCounterValue);
var plaintext = new byte[ciphertextLen];
try
{
lock (_aeadLock)
{
_aead.Decrypt(nonce, ciphertext, tag, plaintext);
}
}
catch (CryptographicException)
{
Dispose();
throw new CryptographicException("AEAD authentication failed; connection closed.");
}
finally
{
Array.Clear(nonce, 0, nonce.Length);
Array.Clear(payload, 0, payload.Length);
Array.Clear(ciphertext, 0, ciphertext.Length);
Array.Clear(tag, 0, tag.Length);
}
return plaintext;
}
private async Task ReadExactFromBaseAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
if (count == 0) return;
var read = 0;
while (read < count)
{
var n = await base.ReadAsync(buffer, offset + read, count - read, cancellationToken).ConfigureAwait(false);
if (n == 0)
{
if (read == 0)
throw new EndOfStreamException("Remote closed connection while reading.");
throw new EndOfStreamException("Remote closed connection unexpectedly during read.");
}
read += n;
}
}
private static async Task ReadExactFromSocketAsync(Socket socket, byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
if (count == 0) return;
var received = 0;
while (received < count)
{
var mem = new Memory<byte>(buffer, offset + received, count - received);
var r = await socket.ReceiveAsync(mem, SocketFlags.None, cancellationToken).ConfigureAwait(false);
if (r == 0)
{
if (received == 0)
throw new EndOfStreamException("Remote closed connection while reading from socket.");
throw new EndOfStreamException("Remote closed connection unexpectedly during socket read.");
}
received += r;
}
}
private static int CompareByteArrayLexicographic(byte[] a, byte[] b)
{
if (a == null || b == null) throw new ArgumentNullException();
var min = Math.Min(a.Length, b.Length);
for (int i = 0; i < min; i++)
{
if (a[i] < b[i]) return -1;
if (a[i] > b[i]) return 1;
}
if (a.Length < b.Length) return -1;
if (a.Length > b.Length) return 1;
return 0;
}
private bool EnsureCurrentLeftoverSegment()
{
if (_currentLeftoverSegment != null && _currentLeftoverOffset < _currentLeftoverSegment.Length) return true;
if (_leftoverQueue.TryDequeue(out var next))
{
_currentLeftoverSegment = next;
_currentLeftoverOffset = 0;
return true;
}
return false;
}
public override void Write(byte[] buffer, int offset, int count)
{
if (buffer == null) throw new ArgumentNullException(nameof(buffer));
if (offset < 0 || count < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException();
ThrowIfDisposed();
WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult();
}
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
if (buffer == null) throw new ArgumentNullException(nameof(buffer));
if (offset < 0 || count < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException();
ThrowIfDisposed();
int remaining = count;
int idx = offset;
while (remaining > 0)
{
var chunk = Math.Min(remaining, MaxPlaintextFrame);
var mem = new ReadOnlyMemory<byte>(buffer, idx, chunk);
await SendPlaintextChunkAsync(mem, cancellationToken).ConfigureAwait(false);
idx += chunk;
remaining -= chunk;
}
}
public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task SendPlaintextChunkAsync(ReadOnlyMemory<byte> plaintext, CancellationToken cancellationToken)
{
ThrowIfDisposed();
var ciphertext = new byte[plaintext.Length];
var tag = new byte[AeadTagLen];
var nonce = new byte[NonceLen];
ulong counterValue;
lock (_sendLock)
{
counterValue = _sendCounter;
_sendCounter++;
}
Array.Copy(_sendNonceSalt, 0, nonce, 0, NonceSaltLen);
BinaryPrimitives.WriteUInt64BigEndian(nonce.AsSpan(NonceSaltLen), counterValue);
lock (_aeadLock)
{
_aead.Encrypt(nonce, plaintext.Span, ciphertext, tag);
}
var payloadLen = unchecked((uint)(ciphertext.Length + tag.Length));
var packet = new byte[4 + payloadLen];
BinaryPrimitives.WriteUInt32BigEndian(packet.AsSpan(0, 4), payloadLen);
if (ciphertext.Length > 0)
ciphertext.CopyTo(packet.AsSpan(4));
tag.CopyTo(packet.AsSpan(4 + ciphertext.Length));
await base.WriteAsync(packet, 0, packet.Length, cancellationToken).ConfigureAwait(false);
await base.FlushAsync(cancellationToken).ConfigureAwait(false);
Array.Clear(nonce, 0, nonce.Length);
Array.Clear(tag, 0, tag.Length);
Array.Clear(ciphertext, 0, ciphertext.Length);
Array.Clear(packet, 0, packet.Length);
}
protected override void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
lock (_aeadLock)
{
_aead.Dispose();
}
while (_leftoverQueue.TryDequeue(out var seg)) Array.Clear(seg, 0, seg.Length);
}
_disposed = true;
}
base.Dispose(disposing);
}
void IDisposable.Dispose() => Dispose();
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(AbyssStream));
}
public override void Write(ReadOnlySpan<byte> buffer)
{
var tmp = ArrayPool<byte>.Shared.Rent(buffer.Length);
try
{
buffer.CopyTo(tmp);
Write(tmp, 0, buffer.Length);
}
finally
{
ArrayPool<byte>.Shared.Return(tmp, clearArray: true);
}
}
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
if (MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> seg))
{
return new ValueTask(WriteAsync(seg.Array!, seg.Offset, seg.Count, cancellationToken));
}
else
{
return SlowWriteAsync(buffer, cancellationToken);
}
async ValueTask SlowWriteAsync(ReadOnlyMemory<byte> buf, CancellationToken ct)
{
var tmp = ArrayPool<byte>.Shared.Rent(buf.Length);
try
{
buf.Span.CopyTo(tmp);
await WriteAsync(tmp, 0, buf.Length, ct).ConfigureAwait(false);
}
finally
{
ArrayPool<byte>.Shared.Return(tmp, clearArray: true);
}
}
}
public override int Read(Span<byte> buffer)
{
var tmp = ArrayPool<byte>.Shared.Rent(buffer.Length);
try
{
int n = Read(tmp, 0, buffer.Length);
new ReadOnlySpan<byte>(tmp, 0, n).CopyTo(buffer);
return n;
}
finally
{
ArrayPool<byte>.Shared.Return(tmp, clearArray: true);
}
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> seg))
{
return new ValueTask<int>(ReadAsync(seg.Array!, seg.Offset, seg.Count, cancellationToken));
}
else
{
return SlowReadAsync(buffer, cancellationToken);
}
async ValueTask<int> SlowReadAsync(Memory<byte> buf, CancellationToken ct)
{
var tmp = ArrayPool<byte>.Shared.Rent(buf.Length);
try
{
int n = await ReadAsync(tmp, 0, buf.Length, ct).ConfigureAwait(false);
new ReadOnlySpan<byte>(tmp, 0, n).CopyTo(buf.Span);
return n;
}
finally
{
ArrayPool<byte>.Shared.Return(tmp, clearArray: true);
}
}
}
}
public static class TcpClientAbyssExtensions
{
public static Task<AbyssStream> GetAbyssStreamAsync(this TcpClient client, UserService us, byte[]? privateKeyRaw = null, CancellationToken ct = default)
=> AbyssStream.CreateAsync(client, us, privateKeyRaw, ct);
}
}

View File

@@ -0,0 +1,208 @@
using System.Text;
namespace Abyss.Components.Tools;
public class HttpHelper
{
private const int MaxHeaderCount = 100;
private const int MaxHeaderLineLength = 8192;
private const int MaxBodySize = 10 * 1024 * 1024; // 10 MB
public static string BuildHttpResponse(
int statusCode,
string statusDescription,
Dictionary<string, string>? headers = null,
string? body = null,
string httpVersion = "HTTP/1.1")
{
var responseBuilder = new StringBuilder();
// Sanitize status description (prevent CRLF injection)
statusDescription = SanitizeHeaderValue(statusDescription);
responseBuilder.Append($"{httpVersion} {statusCode} {statusDescription}\r\n");
headers ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Ensure correct Content-Length
if (!string.IsNullOrEmpty(body))
{
int contentLength = Encoding.UTF8.GetByteCount(body);
headers["Content-Length"] = contentLength.ToString();
if (!headers.ContainsKey("Content-Type"))
{
headers["Content-Type"] = "text/plain; charset=utf-8";
}
}
foreach (var header in headers)
{
string name = SanitizeHeaderName(header.Key);
string value = SanitizeHeaderValue(header.Value);
responseBuilder.AppendLine($"{name}: {value}");
}
responseBuilder.AppendLine();
if (!string.IsNullOrEmpty(body))
{
responseBuilder.Append(body);
}
return responseBuilder.ToString();
}
public static HttpRequest Parse(string requestText)
{
if (string.IsNullOrEmpty(requestText))
throw new ArgumentException("Request text cannot be empty");
using var reader = new StringReader(requestText);
var request = new HttpRequest();
string requestLine = reader.ReadLine() ?? "";
if (string.IsNullOrWhiteSpace(requestLine))
throw new FormatException("Invalid HTTP request: missing request line");
ParseRequestLine(requestLine, request);
ParseHeaders(reader, request);
ParseBody(reader, request);
return request;
}
private static void ParseRequestLine(string requestLine, HttpRequest request)
{
var parts = requestLine.Split(' ', 3);
if (parts.Length < 3)
throw new FormatException("Invalid request line format");
request.Method = parts[0].Trim();
if (!Uri.TryCreate(parts[1], UriKind.RelativeOrAbsolute, out var uri))
{
throw new FormatException("Invalid or unsupported URI");
}
request.RequestUri = uri;
request.HttpVersion = parts[2].Trim();
}
private static void ParseHeaders(StringReader reader, HttpRequest request)
{
string? line;
int headerCount = 0;
while (!string.IsNullOrEmpty(line = reader.ReadLine()))
{
if (++headerCount > MaxHeaderCount)
throw new InvalidOperationException("Too many headers");
if (line.Length > MaxHeaderLineLength)
throw new InvalidOperationException("Header line too long");
int colonIndex = line.IndexOf(':');
if (colonIndex <= 0)
throw new FormatException($"Invalid header format: {line}");
string headerName = SanitizeHeaderName(line.Substring(0, colonIndex).Trim());
string headerValue = SanitizeHeaderValue(line.Substring(colonIndex + 1).Trim());
if (request.Headers.ContainsKey(headerName))
throw new InvalidOperationException($"Duplicate header not allowed: {headerName}");
request.Headers[headerName] = headerValue;
}
}
private static void ParseBody(StringReader reader, HttpRequest request)
{
if (request.Headers.TryGetValue("Content-Length", out var contentLengthStr) &&
long.TryParse(contentLengthStr, out var contentLength) &&
contentLength > 0)
{
if (contentLength > MaxBodySize)
throw new InvalidOperationException("Request body too large");
var buffer = new char[contentLength];
int read = reader.ReadBlock(buffer, 0, (int)contentLength);
request.Body = new string(buffer, 0, read);
}
else if (request.Headers.TryGetValue("Transfer-Encoding", out var encoding) &&
encoding.Equals("chunked", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException("Chunked transfer encoding is not supported");
}
}
private static string SanitizeHeaderName(string name)
{
if (name.Contains("\r") || name.Contains("\n"))
throw new FormatException("Invalid header name");
return name;
}
private static string SanitizeHeaderValue(string value)
{
return value.Replace("\r", "").Replace("\n", "");
}
}
public class HttpRequest
{
public string Method { get; set; } = "";
public Uri? RequestUri { get; set; }
public string HttpVersion { get; set; } = "";
public Dictionary<string, string> Headers { get; set; }
public string Body { get; set; } = "";
public HttpRequest()
{
Headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Get header value by name (case-insensitive)
/// </summary>
public string? GetHeader(string headerName)
{
return Headers.TryGetValue(headerName, out var value) ? value : null;
}
/// <summary>
/// Check if header exists (case-insensitive)
/// </summary>
public bool HasHeader(string headerName)
{
return Headers.ContainsKey(headerName);
}
/// <summary>
/// Convert back to HTTP request string
/// </summary>
public override string ToString()
{
var builder = new StringBuilder();
// Request line
builder.AppendLine($"{Method} {RequestUri} {HttpVersion}");
// Headers
foreach (var header in Headers)
{
builder.AppendLine($"{header.Key}: {header.Value}");
}
// Empty line
builder.AppendLine();
// Body
if (!string.IsNullOrEmpty(Body))
{
builder.Append(Body);
}
return builder.ToString();
}
}

View File

@@ -0,0 +1,277 @@
using System;
using System.IO;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Globalization;
namespace Abyss.Components.Tools
{
public static class HttpReader
{
private const int DefaultBufferSize = 8192;
private const int MaxHeaderBytes = 64 * 1024; // 64 KB header max
private const long MaxBodyBytes = 10L * 1024 * 1024; // 10 MB body max
private const int MaxLineLength = 8 * 1024; // 8 KB per line max
/// <summary>
/// Read a full HTTP message (headers + body) from a NetworkStream and return as a string.
/// This method enforces size limits and parses chunked encoding correctly.
/// </summary>
public static async Task<string> ReadHttpMessageAsync(AbyssStream stream, CancellationToken cancellationToken)
{
if (stream == null) throw new ArgumentNullException(nameof(stream));
if (!stream.CanRead) throw new ArgumentException("Stream is not readable", nameof(stream));
// 1) Read header bytes until CRLFCRLF or header size limit is exceeded
var headerBuffer = new MemoryStream();
var tmp = new byte[DefaultBufferSize];
int headerEndIndex = -1;
while (true)
{
int n = await stream.ReadAsync(tmp.AsMemory(0, tmp.Length), cancellationToken).ConfigureAwait(false);
if (n == 0)
throw new IOException("Stream closed before HTTP header was fully read.");
headerBuffer.Write(tmp, 0, n);
if (headerBuffer.Length > MaxHeaderBytes)
throw new InvalidOperationException("HTTP header exceeds maximum allowed size.");
// search for CRLFCRLF in the accumulated bytes
var bytes = headerBuffer.ToArray();
headerEndIndex = IndexOfDoubleCrlf(bytes);
if (headerEndIndex >= 0)
{
// headerEndIndex is the index of the first '\r' of "\r\n\r\n"
// stop reading further here; remaining bytes (if any) are part of body initial chunk
break;
}
// continue reading
}
var allHeaderBytes = headerBuffer.ToArray();
int bodyStartIndex = headerEndIndex + 4;
string headerPart = Encoding.ASCII.GetString(allHeaderBytes, 0, headerEndIndex + 4);
// 2) parse headers to find Content-Length / Transfer-Encoding
int contentLength = 0;
bool isChunked = false;
foreach (var line in headerPart.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
{
var raw = line.Substring("Content-Length:".Length).Trim();
if (int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out int len))
{
if (len < 0) throw new FormatException("Negative Content-Length not allowed.");
contentLength = len;
}
else
{
throw new FormatException("Invalid Content-Length value.");
}
}
else if (line.StartsWith("Transfer-Encoding:", StringComparison.OrdinalIgnoreCase))
{
if (line.IndexOf("chunked", StringComparison.OrdinalIgnoreCase) >= 0)
{
isChunked = true;
}
}
}
// 3) Create a buffered reader that first consumes bytes already read after header
var initialTail = new ArraySegment<byte>(allHeaderBytes, bodyStartIndex, allHeaderBytes.Length - bodyStartIndex);
var reader = new BufferedNetworkReader(stream, initialTail, DefaultBufferSize, cancellationToken);
// 4) Read body according to encoding
byte[] bodyBytes;
if (isChunked)
{
using var bodyMs = new MemoryStream();
while (true)
{
string sizeLine = await reader.ReadLineAsync(MaxLineLength).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(sizeLine))
{
// skip empty lines (robustness)
continue;
}
// chunk-size [; extensions]
var semi = sizeLine.IndexOf(';');
var sizeToken = semi >= 0 ? sizeLine.Substring(0, semi) : sizeLine;
if (!long.TryParse(sizeToken.Trim(), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out long chunkSize))
throw new IOException("Invalid chunk size in chunked encoding.");
if (chunkSize < 0) throw new IOException("Negative chunk size.");
if (chunkSize == 0)
{
// read and discard any trailer headers until an empty line
while (true)
{
var trailerLine = await reader.ReadLineAsync(MaxLineLength).ConfigureAwait(false);
if (string.IsNullOrEmpty(trailerLine)) break;
}
break;
}
if (chunkSize > MaxBodyBytes || (bodyMs.Length + chunkSize) > MaxBodyBytes)
throw new InvalidOperationException("Chunked body exceeds maximum allowed size.");
await reader.ReadExactAsync(bodyMs, chunkSize).ConfigureAwait(false);
// after chunk data there must be CRLF; consume it
var crlf = await reader.ReadLineAsync(MaxLineLength).ConfigureAwait(false);
if (crlf != string.Empty)
throw new IOException("Missing CRLF after chunk data.");
}
bodyBytes = bodyMs.ToArray();
}
else if (contentLength > 0)
{
if (contentLength > MaxBodyBytes)
throw new InvalidOperationException("Content-Length exceeds maximum allowed size.");
using var bodyMs = new MemoryStream();
long remaining = contentLength;
// If there were initial tail bytes, BufferedNetworkReader will supply them first
await reader.ReadExactAsync(bodyMs, remaining).ConfigureAwait(false);
bodyBytes = bodyMs.ToArray();
}
else
{
// no body
bodyBytes = Array.Empty<byte>();
}
// 5) combine headerPart and body decoded as UTF-8 string
string bodyPart = Encoding.UTF8.GetString(bodyBytes);
return headerPart + bodyPart;
}
private static int IndexOfDoubleCrlf(byte[] data)
{
// find sequence \r\n\r\n
for (int i = 0; i + 3 < data.Length; i++)
{
if (data[i] == 13 && data[i + 1] == 10 && data[i + 2] == 13 && data[i + 3] == 10)
return i;
}
return -1;
}
/// <summary>
/// BufferedNetworkReader merges an initial buffer (already-read bytes) with later reads from NetworkStream.
/// It provides ReadLineAsync and ReadExactAsync semantics used by HTTP parsing.
/// </summary>
private sealed class BufferedNetworkReader
{
private readonly AbyssStream _stream;
private readonly CancellationToken _cancellation;
private readonly int _bufferSize;
private byte[] _buffer;
private int _offset;
private int _count; // valid data range [_offset, _offset + _count)
public BufferedNetworkReader(AbyssStream stream, ArraySegment<byte> initial, int bufferSize, CancellationToken cancellation)
{
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
_cancellation = cancellation;
_bufferSize = Math.Max(512, bufferSize);
// initialize buffer and copy initial tail bytes
_buffer = new byte[Math.Max(_bufferSize, initial.Count)];
Array.Copy(initial.Array ?? Array.Empty<byte>(), initial.Offset, _buffer, 0, initial.Count);
_offset = 0;
_count = initial.Count;
}
/// <summary>
/// Read a line terminated by CRLF. Returns the line without CRLF.
/// Throws if the line length exceeds maxLineLength.
/// </summary>
public async Task<string> ReadLineAsync(int maxLineLength)
{
var ms = new MemoryStream();
int seen = 0;
while (true)
{
if (_count == 0)
{
// refill buffer
int n = await _stream.ReadAsync(new Memory<byte>(_buffer, 0, _buffer.Length), _cancellation).ConfigureAwait(false);
if (n == 0)
throw new IOException("Unexpected end of stream while reading line.");
_offset = 0;
_count = n;
}
// scan for '\n'
int i;
for (i = 0; i < _count; i++)
{
byte b = _buffer[_offset + i];
seen++;
if (seen > maxLineLength) throw new InvalidOperationException("Line length exceeds maximum allowed.");
if (b == (byte)'\n')
{
// write bytes up to this position
ms.Write(_buffer, _offset, i + 1);
_offset += i + 1;
_count -= i + 1;
// convert to string and remove CRLF if present
var lineBytes = ms.ToArray();
if (lineBytes.Length >= 2 && lineBytes[lineBytes.Length - 2] == (byte)'\r')
return Encoding.ASCII.GetString(lineBytes, 0, lineBytes.Length - 2);
else if (lineBytes.Length >= 1 && lineBytes[lineBytes.Length - 1] == (byte)'\n')
return Encoding.ASCII.GetString(lineBytes, 0, lineBytes.Length - 1);
else
return Encoding.ASCII.GetString(lineBytes);
}
}
// no newline found in buffer; write all and continue
ms.Write(_buffer, _offset, _count);
_offset = 0;
_count = 0;
}
}
/// <summary>
/// Read exactly 'length' bytes and write them to destination stream.
/// Throws if stream ends before length bytes are read or size exceeds limits.
/// </summary>
public async Task ReadExactAsync(Stream destination, long length)
{
if (length < 0) throw new ArgumentOutOfRangeException(nameof(length));
long remaining = length;
var tmp = new byte[_bufferSize];
// first consume from internal buffer
if (_count > 0)
{
int take = (int)Math.Min(_count, remaining);
destination.Write(_buffer, _offset, take);
_offset += take;
_count -= take;
remaining -= take;
}
while (remaining > 0)
{
int toRead = (int)Math.Min(tmp.Length, remaining);
int n = await _stream.ReadAsync(tmp.AsMemory(0, toRead), _cancellation).ConfigureAwait(false);
if (n == 0) throw new IOException("Unexpected end of stream while reading body.");
destination.Write(tmp, 0, n);
remaining -= n;
}
}
}
}
}

View File

@@ -12,6 +12,8 @@ public class Comic
public List<Bookmark> Bookmarks { get; set; } = new();
[JsonProperty("author")]
public string Author { get; set; } = "";
[JsonProperty("tags")]
public List<string> Tags { get; set; } = new();
[JsonProperty("list")]
public List<string> List { get; set; } = new();
}

View File

@@ -1,9 +1,17 @@
using SQLite;
namespace Abyss.Model;
[Table("ResourceAttributes")]
public class ResourceAttribute
{
public string Uid { get; set; } = "@";
public string Name { get; set; } = "@";
public string Owner { get; set; } = "@";
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[Unique, NotNull]
public string Uid { get; init; } = "@";
[NotNull]
public int Owner { get; set; }
[NotNull]
public string Permission { get; set; } = "--,--,--";
}

View File

@@ -9,7 +9,7 @@ public enum TaskType
public class Task
{
public uint Id;
public string Owner = "";
public int Owner;
public string Class = "";
public string Name = "";
public TaskType Type;

View File

@@ -1,9 +1,18 @@
using SQLite;
namespace Abyss.Model;
[Table("Users")]
public class User
{
public string Name { get; set; } = "";
public string Parent { get; set; } = "";
[PrimaryKey, AutoIncrement]
public int Uuid { get; set; }
[Unique, NotNull]
public string Username { get; set; } = "";
[NotNull]
public int ParentId { get; set; }
[NotNull]
public string PublicKey { get; set; } = "";
[NotNull]
public int Privilege { get; set; }
}

View File

@@ -4,7 +4,6 @@ public class UserCreating
{
public string Response { get; set; } = "";
public string Name { get; set; } = "";
public string Parent { get; set; } = "";
public string PublicKey { get; set; } = "";
public int Privilege { get; set; }
}

View File

@@ -1,4 +1,5 @@
using System.Threading.RateLimiting;
using Abyss.Components.Controllers.Middleware;
using Abyss.Components.Controllers.Task;
using Abyss.Components.Services;
using Microsoft.AspNetCore.RateLimiting;
@@ -13,19 +14,18 @@ public class Program
builder.Services.AddAuthorization();
builder.Services.AddMemoryCache();
builder.Services.AddOpenApi();
builder.Services.AddControllers();
builder.Services.AddSingleton<ConfigureService>();
builder.Services.AddSingleton<UserService>();
builder.Services.AddSingleton<ResourceService>();
builder.Services.AddSingleton<TaskController>();
builder.Services.AddSingleton<TaskService>();
builder.Services.AddHostedService<AbyssService>();
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("Fixed", policyOptions =>
{
// 时间窗口长度
policyOptions.Window = TimeSpan.FromSeconds(30);
policyOptions.PermitLimit = 10;
policyOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
@@ -39,19 +39,11 @@ public class Program
};
});
builder.Services.BuildServiceProvider().GetRequiredService<UserService>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// app.UseHttpsRedirection();
app.UseMiddleware<BadRequestExceptionMiddleware>();
app.UseAuthorization();
app.MapStaticAssets();
app.MapControllers();
app.UseRateLimiter();

View File

@@ -5,20 +5,11 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://192.168.1.244:5198",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"MEDIA_ROOT" : "/storage"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7013;http://localhost:5198",
"applicationUrl": "http://localhost:3000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"MEDIA_ROOT" : "/storage",
"ALLOWED_PORTS" : "3000",
"DEBUG_MODE": "Debug"
}
}

321
Abyss/Toolkits/abyss-cli.py Normal file
View 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())

View File

@@ -5,6 +5,8 @@ import subprocess
import shutil
from pathlib import Path
ALLOWED_VIDEO_EXTS = [".mp4", ".mkv", ".webm", ".mov", ".ogg", ".ts", ".m2ts"]
def get_video_duration(video_path):
"""Get video duration in milliseconds using ffprobe"""
try:
@@ -27,14 +29,12 @@ def create_thumbnails(video_path, gallery_path, num_thumbnails=10):
Extracts thumbnails from a video and saves them to the gallery directory.
"""
try:
# Check if ffmpeg is installed
subprocess.run(['ffmpeg', '-version'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except (subprocess.CalledProcessError, FileNotFoundError):
print("Error: ffmpeg is not installed or not in your PATH. Skipping thumbnail creation.")
return
try:
# Get video duration using ffprobe
duration_cmd = [
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', str(video_path)
@@ -107,15 +107,42 @@ def create_cover(video_path, output_path, time_percent):
except subprocess.CalledProcessError as e:
print(f"Error creating cover image: {e}")
def find_video_in_dir(base_path):
"""
Find video file in base_path. Preference:
1) file named 'video' with allowed ext (video.mp4, video.mkv, ...)
2) first file with allowed ext
Returns Path or None.
"""
if not base_path.exists() or not base_path.is_dir():
return None
# prefer explicit video.* name
for ext in ALLOWED_VIDEO_EXTS:
candidate = base_path / ("video" + ext)
if candidate.exists() and candidate.is_file():
return candidate
# otherwise find first allowed extension file
for f in base_path.iterdir():
if f.is_file() and f.suffix.lower() in ALLOWED_VIDEO_EXTS:
return f
return None
def update_summary(base_path, name_input=None, author_input=None):
"""
Updates the summary.json file for a given path.
name_input and author_input are optional, used for the '-a' mode.
"""
summary_path = base_path / "summary.json"
video_path = base_path / "video.mp4"
gallery_path = base_path / "gallery"
# Find the video file dynamically
video_path = find_video_in_dir(base_path)
if video_path is None:
print(f"Warning: no video file found in {base_path}")
# Default template
default_summary = {
"name": name_input if name_input is not None else "null",
@@ -139,11 +166,11 @@ def update_summary(base_path, name_input=None, author_input=None):
except json.JSONDecodeError:
print("Warning: Invalid JSON in summary.json, using defaults")
# Update duration from video file
if video_path.exists():
# Update duration from video file if found
if video_path and video_path.exists():
default_summary["duration"] = get_video_duration(video_path)
else:
print(f"Warning: video.mp4 not found at {video_path}")
print("Warning: video file for duration not found; duration set to 0")
# Update gallery from directory
if gallery_path.exists() and gallery_path.is_dir():
@@ -179,12 +206,15 @@ def main():
print("Usage: python script.py <command> [arguments]")
print("Commands:")
print(" -u <path> Update the summary.json in the specified path.")
print(" -a <video_file> <path> Add a new video project in a new directory under the specified path.")
print(" -a <video_file> <path> Add a new video project in a new directory under the specified path. Optional -y to accept defaults.")
print(" -c <path> <time> Create a cover image from the video in the specified path at a given time percentage (0.0-1.0).")
sys.exit(1)
command = sys.argv[1]
# global -y flag (if present anywhere)
assume_yes = '-y' in sys.argv
if command == '-u':
if len(sys.argv) != 3:
print("Usage: python script.py -u <path>")
@@ -196,12 +226,14 @@ def main():
update_summary(base_path)
elif command == '-a':
if len(sys.argv) != 4:
print("Usage: python script.py -a <video_file> <path>")
# allow invocation with optional -y flag anywhere; expecting at least video and base path
params = [p for p in sys.argv[2:] if p != '-y']
if len(params) != 2:
print("Usage: python script.py -a <video_file> <path> (optional -y to accept defaults)")
sys.exit(1)
video_source_path = Path(sys.argv[2])
base_path = Path(sys.argv[3])
video_source_path = Path(params[0])
base_path = Path(params[1])
if not video_source_path.exists() or not video_source_path.is_file():
print(f"Error: Video file not found: {video_source_path}")
@@ -221,15 +253,25 @@ def main():
gallery_path.mkdir(exist_ok=True)
print(f"New project directory created at {new_project_path}")
# Copy video file to the new directory
shutil.copy(video_source_path, new_project_path / "video.mp4")
print(f"Video copied to {new_project_path / 'video.mp4'}")
# Copy video file to the new directory, preserving extension in the target name
dest_video_name = "video" + video_source_path.suffix.lower()
video_dest_path = new_project_path / dest_video_name
shutil.copy(video_source_path, video_dest_path)
print(f"Video copied to {video_dest_path}")
# Auto-generate thumbnails
video_dest_path = new_project_path / "video.mp4"
create_thumbnails(video_dest_path, gallery_path)
# Get user input for name and author, with a prompt for default values
# Auto-generate cover at 50%
cover_path = new_project_path / "cover.jpg"
create_cover(video_dest_path, cover_path, 0.5)
# Get user input for name and author, unless assume_yes is set
if assume_yes:
video_name = video_source_path.stem
video_author = "Anonymous"
print(f"Assume yes (-y): using defaults: name='{video_name}', author='{video_author}'")
else:
print("\nEnter the video name (press Enter to use the original filename):")
video_name = input(f"Video Name [{video_source_path.stem}]: ")
if not video_name:
@@ -249,7 +291,11 @@ def main():
sys.exit(1)
base_path = Path(sys.argv[2])
video_path = base_path / "video.mp4"
# find video dynamically
video_path = find_video_in_dir(base_path)
if video_path is None:
print(f"Error: no video file found in {base_path}")
sys.exit(1)
cover_path = base_path / "cover.jpg"
try:
@@ -261,7 +307,7 @@ def main():
sys.exit(1)
if not video_path.exists() or not video_path.is_file():
print(f"Error: video.mp4 not found at {video_path}")
print(f"Error: video file not found at {video_path}")
sys.exit(1)
create_cover(video_path, cover_path, time_percent)

View File

@@ -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>

View File

@@ -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()
};
}

View File

@@ -1,16 +1,59 @@
_<div align="center">
<div align="center">
# Abyss (Server for Aether)
[![Plugin Version](https://img.shields.io/badge/Alpha-v0.1-red.svg?style=for-the-badge&color=76bad9)](https://github.com/rootacite/Abyss)
_🚀This is the server of the multimedia application Aether, which can also be extended to other purposes🚀_
🚀This is the server of the multimedia application Aether, which can also be extended to other purposes🚀
<img src="abyss_clip.png" width="25%" alt="Logo">
</div>
<br/>
<br/>
<br/>
---
## Description
**Abyss** is a modern, self-hosted media server and secure proxy platform built with **.NET 9**. It is designed to provide a highly secure, extensible, and efficient solution for managing and streaming media content (images, videos, live streams) while enforcing fine-grained access control and cryptographic authentication.
### 🎯 Key Features
- **Media Management**: Organize and serve images, videos, and live streams with structured directory support.
- **User Authentication**: Challenge-response authentication using **Ed25519** signatures. No private keys are ever transmitted.
- **Role-Based Access Control (RBAC)**: Hierarchical user privileges with configurable permissions for resources.
- **Secure Proxy**: Built-in HTTP/S proxy with end-to-end encrypted tunneling using **X25519** key exchange and **ChaCha20-Poly1305** encryption.
- **Resource-Level Permissions**: Fine-grained control over files and directories using a SQLite-based attribute system.
- **Task System**: Support for background tasks such as media uploads and processing with chunk-based integrity validation.
- **RESTful API**: Fully documented API endpoints for media access, user management, and task control.
### 🛠️ Technology Stack
- **Backend**: ASP.NET Core 9, MVC, Dependency Injection
- **Database**: SQLite with async ORM support
- **Cryptography**: NSec.Cryptography (Ed25519, X25519), ChaCha20Poly1305
- **Media Handling**: Range requests, MIME type detection, chunked uploads
- **Security**: Rate limiting, IP binding, token expiration, secure headers
### 🔐 Security Highlights
- Zero-trust architecture: All requests require valid tokens bound to IP addresses.
- No plaintext private key transmission.
- All media and metadata access is validated against a permission database.
- Secure tunneling with forward secrecy via ephemeral key exchange.
### 📦 Use Cases
- Personal media library with access control
- Secure internal video streaming platform
- Proxy server with authenticated tunneling
- Task-driven media processing pipeline
### 🌱 Extensibility
Abyss is designed with modularity in mind. Its service-based architecture allows easy integration of new media types, authentication providers, or storage backends.
---
## Development environment
@@ -326,4 +369,4 @@ These endpoints provide access to static image resources. A valid token is requi
- [ ] Add P/D method to all controllers to achieve dynamic modification of media items
- [x] Implement identity management module
- [ ] Add a description of the media library directory structure in the READMD document
- [x] Add API interface instructions in the READMD document_
- [x] Add API interface instructions in the READMD document

BIN
abyss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
abyss_clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB