[add] function implementation

This commit is contained in:
acite
2025-08-24 00:56:08 +08:00
parent 240b0d98fd
commit 052a2da270
35 changed files with 5261 additions and 4 deletions

5
.gitignore vendored
View File

@@ -51,4 +51,7 @@ CodeCoverage/
# NUnit # NUnit
*.VisualState.xml *.VisualState.xml
TestResult.xml TestResult.xml
nunit-*.xml nunit-*.xml
# DB
*.db

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="dataSourceStorageLocal" created-in="RD-252.23892.524">
<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>
</database-info>
<case-sensitivity plain-identifiers="mixed" quoted-identifiers="mixed" />
<auth-provider>no-auth</auth-provider>
<schema-mapping>
<introspection-scope>
<node kind="schema" qname="@" />
</introspection-scope>
</schema-mapping>
</data-source>
<data-source name="ra" uuid="bf32ff15-97d4-4301-bb5e-c1d57c7be5c0">
<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>
</database-info>
<case-sensitivity plain-identifiers="mixed" quoted-identifiers="mixed" />
<auth-provider>no-auth</auth-provider>
<schema-mapping>
<introspection-scope>
<node kind="schema" qname="@" />
</introspection-scope>
</schema-mapping>
</data-source>
</component>
</project>

27
.idea/.idea.Abyss/.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="user" uuid="91acd9d8-5f8b-442f-9d50-17006d4e1ac7">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/Abyss/user.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="ra" uuid="bf32ff15-97d4-4301-bb5e-c1d57c7be5c0">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/Abyss/ra.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
<libraries>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar</url>
</library>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
</library>
</libraries>
</data-source>
</component>
</project>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
#n:main
!<md> [0, 0, null, null, -2147483648, -2147483648]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
#n:main
!<md> [0, 0, null, null, -2147483648, -2147483648]

4
.idea/.idea.Abyss/.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

8
.idea/.idea.Abyss/.idea/indexLayout.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RiderProjectSettingsUpdater">
<option name="singleClickDiffPreview" value="1" />
<option name="unhandledExceptionsIgnoreList" value="1" />
<option name="vcsConfiguration" value="3" />
</component>
</project>

6
.idea/.idea.Abyss/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

178
.idea/.idea.Abyss/.idea/workspace.xml generated Normal file
View File

@@ -0,0 +1,178 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoGeneratedRunConfigurationManager">
<projectFile profileName="http">Abyss/Abyss.csproj</projectFile>
<projectFile profileName="https">Abyss/Abyss.csproj</projectFile>
<projectFile>AbyssCli/AbyssCli.csproj</projectFile>
</component>
<component name="AutoImportSettings">
<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/AbyssController.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Security/README.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Components/Services/ConfigureService.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Components/Services/ResourceService.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Components/Services/UserService.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Components/Static/Helpers.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Model/ChallengeResponse.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Model/ResourceAttribute.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Model/User.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Model/UserCreating.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="DpaMonitoringSettings">
<option name="firstShow" value="false" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="HighlightingSettingsPerFile">
<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/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="file://$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/ConfigureService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/ResourceService.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/Model/ChallengeResponse.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/ResourceAttribute.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/User.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/UserCreating.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/AbyssCli/Program.cs" root0="FORCE_HIGHLIGHTING" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 3
}</component>
<component name="ProjectId" id="31eHu2auFr9esxCfyKyS2MQAaSS" />
<component name="ProjectViewState">
<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 Project.AbyssCli.executor": "Run",
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"XThreadsFramesViewSplitterKey": "0.30266345",
"git-widget-placeholder": "main",
"last_opened_file_path": "/home/acite/embd/WebProjects/Abyss/.gitignore",
"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": "preferences.pluginManager",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager" selected=".NET Launch Settings Profile.Abyss: http">
<configuration name="AbyssCli" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/AbyssCli/AbyssCli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="Abyss: http" 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="http" />
<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>
<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>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="bf317275-3039-49bb-a475-725a800a0cce" name="Changes" comment="" />
<created>1755877836092</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1755877836092</updated>
<workItem from="1755877837171" duration="446000" />
<workItem from="1755878548611" duration="22675000" />
<workItem from="1755924449835" duration="33591000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.OperationCanceledException" breakIfHandledByOtherCode="false" displayValue="System.OperationCanceledException" />
<option name="timeStamp" value="1" />
</breakpoint>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.Threading.Tasks.TaskCanceledException" breakIfHandledByOtherCode="false" displayValue="System.Threading.Tasks.TaskCanceledException" />
<option name="timeStamp" value="2" />
</breakpoint>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.Threading.ThreadAbortException" breakIfHandledByOtherCode="false" displayValue="System.Threading.ThreadAbortException" />
<option name="timeStamp" value="3" />
</breakpoint>
</breakpoints>
</breakpoint-manager>
</component>
</project>

22
Abyss.sln Normal file
View File

@@ -0,0 +1,22 @@

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
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.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

@@ -0,0 +1,5 @@
<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_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_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_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>

24
Abyss/Abyss.csproj Normal file
View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="K4os.Hash.xxHash" Version="1.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.8" />
<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="Standart.Hash.xxHash" Version="4.0.5" />
<PackageReference Include="System.IO.Hashing" Version="10.0.0-preview.7.25380.108" />
</ItemGroup>
<ItemGroup>
<Folder Include="Components\Controllers\Media\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using Abyss.Components.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
namespace Abyss.Components.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AbyssController(ILogger<AbyssController> logger, ConfigureService config) : Controller
{
private ILogger<AbyssController> _logger = logger;
private ConfigureService _config = config;
[HttpGet]
public IActionResult GetCollection()
{
return Ok($"Abyss {_config.Version}. \nMediaRoot: {_config.MediaRoot}");
}
}

View File

@@ -0,0 +1,59 @@
using Abyss.Components.Services;
using Abyss.Components.Static;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
namespace Abyss.Components.Controllers.Media;
using System.IO;
[ApiController]
[Route("api/[controller]")]
public class ImageController(ILogger<ImageController> logger, ResourceService rs, ConfigureService config) : Controller
{
public readonly string ImageFolder = Path.Combine(config.MediaRoot, "Images");
[HttpPost("init")]
public async Task<IActionResult> InitAsync(string token, string owner)
{
var r = await rs.Initialize(ImageFolder, token, owner, Ip);
if(r) return Ok(r);
return StatusCode(403, new { message = "403 Denied" });
}
[HttpGet]
public async Task<IActionResult> QueryCollections(string token)
{
var r = await rs.Query(ImageFolder, token, Ip);
if(r == null)
return StatusCode(401, new { message = "Unauthorized" });
return Ok(r);
}
[HttpGet("{id}")]
public async Task<IActionResult> Query(string id, string token)
{
var d = Helpers.SafePathCombine(ImageFolder, [id, "summary.json"]);
if (d == null) return StatusCode(403, new { message = "403 Denied" });
var r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
return Ok(await System.IO.File.ReadAllTextAsync(d));
}
[HttpGet("{id}/{file}")]
public async Task<IActionResult> Get(string id, string file, string token)
{
var d = Helpers.SafePathCombine(ImageFolder, [id, file]);
if (d == null) return StatusCode(403, new { message = "403 Denied" });
var r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
}

View File

@@ -0,0 +1,94 @@
using System.Diagnostics;
using Abyss.Components.Services;
using Abyss.Components.Static;
using Microsoft.AspNetCore.Mvc;
namespace Abyss.Components.Controllers.Media;
[ApiController]
[Route("api/[controller]")]
public class VideoController(ILogger<VideoController> logger, ResourceService rs, ConfigureService config) : Controller
{
private ILogger<VideoController> _logger = logger;
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
[HttpPost("init")]
public async Task<IActionResult> InitAsync(string token, string owner)
{
var r = await rs.Initialize(VideoFolder, token, owner, Ip);
if(r) return Ok(r);
return StatusCode(403, new { message = "403 Denied" });
}
[HttpGet]
public async Task<IActionResult> GetClass(string token)
{
var r = await rs.Query(VideoFolder, token, Ip);
if(r == null)
return StatusCode(401, new { message = "Unauthorized" });
return Ok(r);
}
[HttpGet("{klass}")]
public async Task<IActionResult> QueryClass(string klass, string token)
{
var d = Helpers.SafePathCombine(VideoFolder, klass);
if (d == null) return StatusCode(403, new { message = "403 Denied" });
var r = await rs.Query(d, token, Ip);
if (r == null) return StatusCode(401, new { message = "Unauthorized" });
return Ok(r);
}
[HttpGet("{klass}/{id}")]
public async Task<IActionResult> QueryVideo(string klass, string id, string token)
{
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "summary.json"]);
if (d == null) return StatusCode(403, new { message = "403 Denied" });
var r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
return Ok(await System.IO.File.ReadAllTextAsync(d));
}
[HttpGet("{klass}/{id}/cover")]
public async Task<IActionResult> Cover(string klass, string id, string token)
{
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "cover.jpg"]);
if (d == null) return StatusCode(403, new { message = "403 Denied" });
var r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
}
[HttpGet("{klass}/{id}/gallery/{pic}")]
public async Task<IActionResult> Gallery(string klass, string id, string pic, string token)
{
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "gallery", pic]);
if (d == null) return StatusCode(403, new { message = "403 Denied" });
var r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
}
[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 r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
return PhysicalFile(d, "video/mp4", enableRangeProcessing: true);
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
}

View File

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

View File

@@ -0,0 +1,107 @@
// UserController.cs
using System.Text.RegularExpressions;
using Abyss.Components.Services;
using Abyss.Model;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Abyss.Components.Controllers.Security;
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("Fixed")]
public class UserController(UserService user, ILogger<UserController> logger) : Controller
{
private readonly ILogger<UserController> _logger = logger;
private readonly UserService _user = user;
[HttpGet("{user}")]
public async Task<IActionResult> Challenge(string user)
{
var c = await _user.Challenge(user);
if(c == null)
return StatusCode(403, new { message = "Access forbidden" });
return Ok(c);
}
[HttpPost("{user}")]
public async Task<IActionResult> Challenge(string user, [FromBody] ChallengeResponse response)
{
var r = await _user.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)
{
return StatusCode(401, new { message = "Invalid" });
}
return Ok(u);
}
[HttpPost("destroy")]
public IActionResult Destroy(string token)
{
var u = _user.Validate(token, Ip);
if (u == null)
{
return StatusCode(401, new { message = "Invalid" });
}
_user.Destroy(token);
return Ok("Success");
}
[HttpPatch("{user}")]
public async Task<IActionResult> Create(string user, [FromBody] UserCreating creating)
{
// Valid token
var r = await _user.Verify(user, creating.Response, Ip);
if(r == null)
return StatusCode(403, new { message = "Denied" });
// User exists ?
var cu = await _user.QueryUser(creating.Name);
if(cu != null)
return StatusCode(403, new { message = "Denied" });
// Valid username string
if(!IsAlphanumeric(creating.Name))
return StatusCode(403, new { message = "Denied" });
// Valid parent && Privilege
var ou = await _user.QueryUser(_user.Validate(r, Ip) ?? "");
if(creating.Parent != (_user.Validate(r, Ip) ?? "") || creating.Privilege > ou?.Privilege)
return StatusCode(403, new { message = "Denied" });
await _user.CreateUser(new User()
{
Name = creating.Name,
Parent = _user.Validate(r, Ip) ?? "",
Privilege = creating.Privilege,
PublicKey = creating.PublicKey,
} );
_user.Destroy(r);
return Ok("Success");
}
public static bool IsAlphanumeric(string input)
{
if (string.IsNullOrEmpty(input))
return false;
return Regex.IsMatch(input, @"^[a-zA-Z0-9]+$");
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
}

View File

@@ -0,0 +1,9 @@
namespace Abyss.Components.Services;
public class ConfigureService
{
public string MediaRoot { get; set; } = Environment.GetEnvironmentVariable("MEDIA_ROOT") ?? "/opt";
public string Version { get; } = "Alpha v0.1";
public string UserDatabase { get; set; } = "user.db";
public string RaDatabase { get; set; } = "ra.db";
}

View File

@@ -0,0 +1,338 @@
// ResourceService.cs
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;
namespace Abyss.Components.Services;
public enum OperationType
{
Read, // Query, Read
Write, // Write, Delete
Security // Chown, Chmod
}
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 Regex(@"^([r-][w-]),([r-][w-]),([r-][w-])$", RegexOptions.Compiled);
public ResourceService(ILogger<ResourceService> logger, ConfigureService config, IMemoryCache cache,
UserService user)
{
_logger = logger;
_config = config;
_cache = cache;
_user = user;
_database = new SQLiteAsyncConnection(config.RaDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
_database.CreateTableAsync<ResourceAttribute>().Wait();
}
// Create UID only for resources, without considering advanced hash security such as adding salt
private string Uid(string path)
{
var b = Encoding.UTF8.GetBytes(path);
var r = XxHash128.Hash(b, 0x11451419);
return Convert.ToBase64String(r ?? []);
}
public async Task<bool> Valid(string path, string token, OperationType type, string ip)
{
// Path is abs path here, due to Helpers.SafePathCombine
if (!path.StartsWith(Path.GetFullPath(_config.MediaRoot), StringComparison.OrdinalIgnoreCase))
return false;
path = Path.GetRelativePath(_config.MediaRoot, path);
string? username = _user.Validate(token, ip);
if (username == null)
{
// No permission granted for invalid tokens
_logger.LogError($"Invalid token: {token}");
return false;
}
User? user = await _user.QueryUser(username);
if (user == null || user.Name != username)
{
_logger.LogError($"Verification failed: {token}");
return false; // Two-factor authentication
}
var parts = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Where(p => !string.IsNullOrEmpty(p))
.ToArray();
for (int i = 0; i < parts.Length - 1; i++)
{
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
var uidDir = Uid(subPath);
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}");
return false;
}
if (!await CheckPermission(user, raDir, OperationType.Read))
{
_logger.LogError($"Permission denied: {username} 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)
.FirstOrDefaultAsync();
if (ra == null)
{
_logger.LogError($"Permission check failed: User: {username}, 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()} ");
}
return l;
}
private async Task<bool> CheckPermission(User? user, ResourceAttribute? ra, OperationType type)
{
if (user == null || ra == null) return false;
if(!PermissionRegex.IsMatch(ra.Permission)) return false;
var perms = ra.Permission.Split(',');
if (perms.Length != 3) return false;
var owner = await _user.QueryUser(ra.Owner);
if (owner == null) return false;
bool isOwner = ra.Owner == user.Name;
bool isPeer = !isOwner && user.Privilege == owner.Privilege;
bool isOther = !isOwner && !isPeer;
string currentPerm;
if (isOwner) currentPerm = perms[0];
else if (isPeer) currentPerm = perms[1];
else if (isOther) currentPerm = perms[2];
else return false;
switch (type)
{
case OperationType.Read:
return currentPerm.Contains('r') || (user.Privilege > owner.Privilege);
case OperationType.Write:
return currentPerm.Contains('w') || (user.Privilege > owner.Privilege);
case OperationType.Security:
return (isOwner && currentPerm.Contains('w')) || user.Name == "root";
default:
return false;
}
}
public async Task<string[]?> Query(string path, string token, string ip)
{
if(!await Valid(path, token, OperationType.Read, ip))
return null;
if (Helpers.GetPathType(path) != PathType.Directory)
return null;
var files = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly);
return files.Select(x => Path.GetRelativePath(path, x)).ToArray();
}
public async Task<bool> Get(string path, string token, string ip)
{
return await Valid(path, token, OperationType.Read, ip);
}
public async Task<bool> Initialize(string path, string token, string username, string ip)
{
// 1. Authorization: Verify the operation is performed by 'root'
var requester = _user.Validate(token, ip);
if (requester != "root")
{
_logger.LogWarning($"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to initialize resources.");
return false;
}
// 2. Validation: Ensure the target path and owner are valid
if (!Directory.Exists(path))
{
_logger.LogError($"Initialization failed: Path '{path}' does not exist or is not a directory.");
return false;
}
var ownerUser = await _user.QueryUser(username);
if (ownerUser == null)
{
_logger.LogError($"Initialization failed: Owner user '{username}' does not exist.");
return false;
}
try
{
// 3. Traversal: Get the root directory and all its descendants (files and subdirectories)
var allPaths = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories).Prepend(path);
// 4. Filtering: Identify which paths are not yet in the database
var newResources = new List<ResourceAttribute>();
foreach (var p in allPaths)
{
var currentPath = Path.GetRelativePath(_config.MediaRoot, p);
var uid = Uid(currentPath);
var existing = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
// If it's not in the database, add it to our list for batch insertion
if (existing == null)
{
newResources.Add(new ResourceAttribute
{
Uid = uid,
Name = currentPath,
Owner = username,
Permission = "rw,--,--"
});
}
}
// 5. Database Insertion: Add all new resources in a single, efficient transaction
if (newResources.Any())
{
await _database.InsertAllAsync(newResources);
_logger.LogInformation($"Successfully initialized {newResources.Count} new resources under '{path}' for user '{username}'.");
}
else
{
_logger.LogInformation($"No new resources to initialize under '{path}'. All items already exist in the database.");
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"An error occurred during resource initialization for path '{path}'.");
return false;
}
}
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> Chmod(string path, string token, string permission, string ip)
{
if(!await Valid(path, token, OperationType.Security, ip))
return false;
// Validate the permission format using the existing regex
if (!PermissionRegex.IsMatch(permission))
{
_logger.LogError($"Invalid permission format: {permission}");
return false;
}
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)
{
_logger.LogError($"Resource not found: {path}");
return false;
}
resource.Permission = permission;
var rowsAffected = await _database.UpdateAsync(resource);
if (rowsAffected > 0)
{
_logger.LogInformation($"Successfully changed permissions for '{path}' to '{permission}'");
return true;
}
else
{
_logger.LogError($"Failed to update permissions for: {path}");
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error changing permissions for: {path}");
return false;
}
}
public async Task<bool> Chown(string path, string token, string owner, string ip)
{
if(!await Valid(path, token, OperationType.Security, ip))
return false;
// Validate that the new owner exists
var newOwner = await _user.QueryUser(owner);
if (newOwner == null)
{
_logger.LogError($"New owner '{owner}' does not exist");
return false;
}
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)
{
_logger.LogError($"Resource not found: {path}");
return false;
}
resource.Owner = owner;
var rowsAffected = await _database.UpdateAsync(resource);
if (rowsAffected > 0)
{
_logger.LogInformation($"Successfully changed ownership of '{path}' to '{owner}'");
return true;
}
else
{
_logger.LogError($"Failed to change ownership for: {path}");
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error changing ownership for: {path}");
return false;
}
}
}

View File

@@ -0,0 +1,177 @@
// UserService.cs
using System.Security.Cryptography;
using System.Text;
using Abyss.Model;
using Microsoft.Extensions.Caching.Memory;
using NSec.Cryptography;
using SQLite;
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;
if (rootUser == null)
{
var key = GenerateKeyPair();
string privateKeyBase64 = Convert.ToBase64String(key.Export(KeyBlobFormat.RawPrivateKey));
string publicKeyBase64 = Convert.ToBase64String(key.Export(KeyBlobFormat.RawPublicKey));
var s = GenerateRandomAsciiString(8);
Console.WriteLine($"Enter the following string to create a root user: '{s}'");
if (Console.ReadLine() != s)
{
throw (new Exception("Invalid Input"));
}
Console.WriteLine($"Created root user. Please keep the key safe.");
Console.WriteLine("key: '" + privateKeyBase64 + "'");
_database.InsertAsync(new User()
{
Name = "root",
Parent = "root",
PublicKey = publicKeyBase64,
Privilege = 1145141919,
}).Wait();
Console.ReadKey();
}
}
public async Task<string?> Challenge(string user)
{
var u = await _database.Table<User>().Where(x => x.Name == 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);
var c = Convert.ToBase64String(Encoding.UTF8.GetBytes(GenerateRandomAsciiString(32)));
_cache.Set(u.Name,c, DateTimeOffset.Now.AddMinutes(1));
return c;
}
// The challenge source and response source are not necessarily required to be the same,
// 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();
if (u == null) // Error: User not exists
{
return null;
}
if (_cache.TryGetValue(u.Name, out string? challenge))
{
bool isVerified = VerifySignature(
PublicKey.Import(
SignatureAlgorithm.Ed25519,
Convert.FromBase64String(u.PublicKey),
KeyBlobFormat.RawPublicKey),
Convert.FromBase64String(challenge ?? ""),
Convert.FromBase64String(response));
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));
return null;
}
else
{
// Remove the challenge string and create a session
_cache.Remove(u.Name);
var s = GenerateRandomAsciiString(64);
_cache.Set(s, $"{u.Name}@{ip}", DateTimeOffset.Now.AddDays(1));
_logger.LogInformation($"Verified {u.Name}@{ip}");
return s;
}
}
return null;
}
public string? Validate(string token, string ip)
{
if (_cache.TryGetValue(token, out string? userAndIp))
{
if (ip != userAndIp?.Split('@')[1])
{
_logger.LogError($"Token used from another Host: {token}");
Destroy(token);
return null;
}
_logger.LogInformation($"Validated {userAndIp}");
return userAndIp?.Split('@')[0];
}
_logger.LogWarning($"Validation failed {token}");
return null;
}
public void Destroy(string token)
{
_cache.Remove(token);
}
public async Task<User?> QueryUser(string user)
{
var u = await _database.Table<User>().Where(x => x.Name == user).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}");
}
static Key GenerateKeyPair()
{
var algorithm = SignatureAlgorithm.Ed25519;
var creationParameters = new KeyCreationParameters
{
ExportPolicy = KeyExportPolicies.AllowPlaintextExport
};
return Key.Create(algorithm, creationParameters);
}
public static string GenerateRandomAsciiString(int length)
{
const string asciiChars = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
using (var rng = RandomNumberGenerator.Create())
{
byte[] randomBytes = new byte[length];
rng.GetBytes(randomBytes);
char[] result = new char[length];
for (int i = 0; i < length; i++)
{
result[i] = asciiChars[randomBytes[i] % asciiChars.Length];
}
return new string(result);
}
}
static bool VerifySignature(PublicKey publicKey, byte[] data, byte[] signature)
{
var algorithm = SignatureAlgorithm.Ed25519;
return algorithm.Verify(publicKey, data, signature);
}
}

View File

@@ -0,0 +1,60 @@
namespace Abyss.Components.Static;
public static class Helpers
{
public static string? SafePathCombine(string basePath, params string[] pathParts)
{
if (string.IsNullOrWhiteSpace(basePath))
return null;
if (basePath.Contains("..") || pathParts.Any(p => p.Contains("..")))
return null;
string combinedPath = Path.Combine(basePath, Path.Combine(pathParts));
string fullPath = Path.GetFullPath(combinedPath);
if (!fullPath.StartsWith(Path.GetFullPath(basePath), StringComparison.OrdinalIgnoreCase))
return null;
return fullPath;
}
public static PathType GetPathType(string path)
{
try
{
var attributes = File.GetAttributes(path);
if ((attributes & FileAttributes.Directory) == FileAttributes.Directory)
{
return PathType.Directory;
}
else
{
return PathType.File;
}
}
catch (FileNotFoundException)
{
return PathType.NotFound;
}
catch (DirectoryNotFoundException)
{
return PathType.NotFound;
}
catch (UnauthorizedAccessException)
{
return PathType.AccessDenied;
}
return PathType.NotFound;
}
}
public enum PathType
{
File,
Directory,
NotFound,
AccessDenied
}

View File

@@ -0,0 +1,6 @@
namespace Abyss.Model;
public class ChallengeResponse
{
public string Response { get; set; } = "";
}

View File

@@ -0,0 +1,9 @@
namespace Abyss.Model;
public class ResourceAttribute
{
public string Uid { get; set; } = "@";
public string Name { get; set; } = "@";
public string Owner { get; set; } = "@";
public string Permission { get; set; } = "--,--,--";
}

9
Abyss/Model/User.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace Abyss.Model;
public class User
{
public string Name { get; set; } = "";
public string Parent { get; set; } = "";
public string PublicKey { get; set; } = "";
public int Privilege { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace Abyss.Model;
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; }
}

57
Abyss/Program.cs Normal file
View File

@@ -0,0 +1,57 @@
using System.Threading.RateLimiting;
using Abyss.Components.Services;
using Microsoft.AspNetCore.RateLimiting;
namespace Abyss;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
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.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("Fixed", policyOptions =>
{
// 时间窗口长度
policyOptions.Window = TimeSpan.FromSeconds(30);
policyOptions.PermitLimit = 10;
policyOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
policyOptions.QueueLimit = 0;
});
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync("Too many requests. Please try later.", token);
};
});
builder.Services.BuildServiceProvider().GetRequiredService<UserService>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// app.UseHttpsRedirection();
app.UseAuthorization();
app.MapStaticAssets();
app.MapControllers();
app.UseRateLimiter();
app.Run();
}
}

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"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",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

9
Abyss/appsettings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

16
AbyssCli/AbyssCli.csproj Normal file
View File

@@ -0,0 +1,16 @@
<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>

364
AbyssCli/Program.cs Normal file
View File

@@ -0,0 +1,364 @@
// 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()
};
}

289
README.md
View File

@@ -1,4 +1,4 @@
<div align="center"> _<div align="center">
# Abyss (Server for Aether) # Abyss (Server for Aether)
@@ -37,10 +37,293 @@ _🚀This is the server of the multimedia application Aether, which can also be
dotnet restore dotnet restore
dotnet run dotnet run
``` ```
4. Setup super user
5. Setup Media Library. **MEDIA_ROOT** environment variable specifies the root directory of the media library.But at this point, no files have been included in the management, so you cannot access any files through the API interface.
## API Quick Guide
This API provides a suite of user management and authentication services. All endpoints are rate-limited to prevent abuse. The authentication flow is based on a **challenge-response mechanism** using public-key cryptography.
---
**🔒 Authentication Flow**
The authentication process involves a three-step **challenge-response** flow:
1. **Request a Challenge:** The client requests a challenge string for a specific user.
2. **Sign the Challenge:** The client signs the challenge string using the user's private key.
3. **Verify the Response:** The client sends the signed response back to the API for verification, receiving a session token upon success.
---
### 1. Request a Challenge
- **Endpoint:** `GET /api/User/{user}`
- **Description:** Requests a random challenge string for the specified user. This string must be signed and returned to complete the authentication. The challenge is valid for 1 minute.
- **Parameters:**
- `user` (string, path): The username to get a challenge for.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** A Base64-encoded challenge string.
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "Access forbidden"}` (e.g., if the user doesn't exist)
### 2. Verify a Challenge
- **Endpoint:** `POST /api/User/{user}`
- **Description:** Verifies the signed response to a previously issued challenge. A successful verification returns a session token valid for 1 day.
- **Parameters:**
- `user` (string, path): The username.
- **Body:**
- **Type:** `application/json`
- **Schema:** `{"response": "string"}`
- `response` (string, body): The Base64-encoded signature of the challenge string, created with the user's private key.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** A Base64-encoded session token.
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "Access forbidden"}` (e.g., challenge expired or signature invalid)
### 3. Validate a Token
- **Endpoint:** `POST /api/User/validate`
- **Description:** Validates a session token. This endpoint verifies that the token is active and being used from the same IP address that obtained it.
- **Parameters:**
- `token` (string, query): The session token to validate.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** The username associated with the token.
- **Error Response:**
- **Code:** `401 Unauthorized`
- **Content:** `{"message": "Invalid"}` (e.g., token expired or from a different IP)
### 4. Destroy a Token
- **Endpoint:** `POST /api/User/destroy`
- **Description:** Invalidates a session token, immediately terminating the session.
- **Parameters:**
- `token` (string, query): The session token to destroy.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** `Success`
- **Error Response:**
- **Code:** `401 Unauthorized`
- **Content:** `{"message": "Invalid"}` (e.g., token is not valid)
### 5. Create a User
- **Endpoint:** `PATCH /api/User/{user}`
- **Description:** Creates a new user. This action requires a valid session token from the user's parent (or a user with higher privilege).
- **Parameters:**
- `user` (string, path): The username of the new user to be created.
- **Body:**
- **Type:** `application/json`
- **Schema:** `{"response": "string", "name": "string", "parent": "string", "privilege": "integer", "publicKey": "string"}`
- `response` (string, body): A signed response to a challenge, verifying the parent user's identity.
- `name` (string, body): The new user's unique username (alphanumeric only).
- `parent` (string, body): The username of the parent user creating this account.
- `privilege` (integer, body): The privilege level for the new user.
- `publicKey` (string, body): The new user's public key (Base64-encoded).
- **Success Response:**
- **Code:** `200 OK`
- **Content:** `Success`
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "Denied"}` (e.g., invalid token, user already exists, invalid username, or insufficient privilege)
**🎥 Video Endpoints**
These endpoints provide access to video resources. A valid token is required for all operations.
### 1. Initialize Resources
- **Endpoint:** `POST /api/Video/init`
- **Description:** Initializes the resource access control list for the video folder. This operation can only be performed by the **'root' user**.
- **Parameters:**
- `token` (string, query): A valid session token for the `root` user.
- `owner` (string, query): The username to be set as the owner of the video resources.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** `true`
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "403 Denied"}` (e.g., token is not from `root` user)
---
### 2. Get Video Classes
- **Endpoint:** `GET /api/Video`
- **Description:** Queries the top-level video directories (classes) available.
- **Parameters:**
- `token` (string, query): A valid session token.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** An array of strings representing the names of video classes.
- **Error Response:**
- **Code:** `401 Unauthorized`
- **Content:** `{"message": "Unauthorized"}` (e.g., invalid token or insufficient permissions)
---
### 3. Query a Specific Class
- **Endpoint:** `GET /api/Video/{klass}`
- **Description:** Queries the contents of a specific video class directory.
- **Parameters:**
- `klass` (string, path): The name of the video class.
- `token` (string, query): A valid session token.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** An array of strings representing the items within the class directory.
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "403 Denied"}` (e.g., path traversal attempt)
- **Code:** `401 Unauthorized`
- **Content:** `{"message": "Unauthorized"}` (e.g., invalid token or insufficient permissions)
---
### 4. Query a Video Summary
- **Endpoint:** `GET /api/Video/{klass}/{id}`
- **Description:** Retrieves the summary information (as a JSON file) for a specific video.
- **Parameters:**
- `klass` (string, path): The video class name.
- `id` (string, path): The video ID.
- `token` (string, query): A valid session token.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** A JSON object containing video summary data.
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "403 Denied"}` (e.g., path traversal attempt or insufficient permissions)
---
### 5. Get Video Cover Image
- **Endpoint:** `GET /api/Video/{klass}/{id}/cover`
- **Description:** Serves the cover image for a video. Supports range processing for efficient streaming.
- **Parameters:**
- `klass` (string, path): The video class name.
- `id` (string, path): The video ID.
- `token` (string, query): A valid session token.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** The JPEG image file.
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "403 Denied"}` (e.g., path traversal attempt or insufficient permissions)
---
### 6. Get Gallery Image
- **Endpoint:** `GET /api/Video/{klass}/{id}/gallery/{pic}`
- **Description:** Serves an image from a video's gallery. Supports range processing.
- **Parameters:**
- `klass` (string, path): The video class name.
- `id` (string, path): The video ID.
- `pic` (string, path): The name of the gallery image.
- `token` (string, query): A valid session token.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** The JPEG image file.
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "403 Denied"}` (e.g., path traversal attempt or insufficient permissions)
---
### 7. Stream Video
- **Endpoint:** `GET /api/Video/{klass}/{id}/av`
- **Description:** Streams the video file. Supports range processing for seeking.
- **Parameters:**
- `klass` (string, path): The video class name.
- `id` (string, path): The video ID.
- `token` (string, query): A valid session token.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** The MP4 video file.
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "403 Denied"}` (e.g., path traversal attempt or insufficient permissions)
**🖼️ Image Endpoints**
These endpoints provide access to static image resources. A valid token is required for all operations.
---
### 1. Initialize Image Resources
- **Endpoint:** `POST /api/Image/init`
- **Description:** Initializes the resource access control list for the image folder. This operation can only be performed by the **'root' user**.
- **Parameters:**
- `token` (string, query): A valid session token for the `root` user.
- `owner` (string, query): The username to be set as the owner of the image resources.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** `true`
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "403 Denied"}` (e.g., token is not from `root` user)
---
### 2. Query Image Collections
- **Endpoint:** `GET /api/Image`
- **Description:** Queries the top-level image directories (collections) available.
- **Parameters:**
- `token` (string, query): A valid session token.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** An array of strings representing the names of image collections.
- **Error Response:**
- **Code:** `401 Unauthorized`
- **Content:** `{"message": "Unauthorized"}` (e.g., invalid token or insufficient permissions)
---
### 3. Query a Specific Image's Summary
- **Endpoint:** `GET /api/Image/{id}`
- **Description:** Retrieves the summary information (as a JSON file) for a specific image.
- **Parameters:**
- `id` (string, path): The ID of the image collection.
- `token` (string, query): A valid session token.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** A JSON object containing the image summary data.
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "403 Denied"}` (e.g., path traversal attempt or insufficient permissions)
---
### 4. Get an Image File
- **Endpoint:** `GET /api/Image/{id}/{file}`
- **Description:** Serves a specific image file from a collection. Supports range processing for efficient streaming.
- **Parameters:**
- `id` (string, path): The ID of the image collection.
- `file` (string, path): The name of the image file within the collection.
- `token` (string, query): A valid session token.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** The JPEG image file.
- **Error Response:**
- **Code:** `403 Forbidden`
- **Content:** `{"message": "403 Denied"}` (e.g., path traversal attempt or insufficient permissions)
## TODO List ## TODO List
- [ ] Add P/D method to all controllers to achieve dynamic modification of media items - [ ] Add P/D method to all controllers to achieve dynamic modification of media items
- [ ] Implement identity management module - [x] Implement identity management module
- [ ] Add a description of the media library directory structure in the READMD document - [ ] Add a description of the media library directory structure in the READMD document
- [ ] Add API interface instructions in the READMD document - [x] Add API interface instructions in the READMD document_