From 052a2da270eba14d917a2de54b8ef49dd7ea69ed Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Sun, 24 Aug 2025 00:56:08 +0800 Subject: [PATCH] [add] function implementation --- .gitignore | 5 +- .idea/.idea.Abyss/.idea/dataSources.local.xml | 29 + .idea/.idea.Abyss/.idea/dataSources.xml | 27 + .../91acd9d8-5f8b-442f-9d50-17006d4e1ac7.xml | 1634 +++++++++++++++++ .../storage_v2/_src_/schema/main.uQUzAA.meta | 2 + .../bf32ff15-97d4-4301-bb5e-c1d57c7be5c0.xml | 1634 +++++++++++++++++ .../storage_v2/_src_/schema/main.uQUzAA.meta | 2 + .idea/.idea.Abyss/.idea/encodings.xml | 4 + .idea/.idea.Abyss/.idea/indexLayout.xml | 8 + .../.idea/projectSettingsUpdater.xml | 8 + .idea/.idea.Abyss/.idea/vcs.xml | 6 + .idea/.idea.Abyss/.idea/workspace.xml | 178 ++ Abyss.sln | 22 + Abyss.sln.DotSettings.user | 5 + Abyss/Abyss.csproj | 24 + .../Components/Controllers/AbyssController.cs | 19 + .../Controllers/Media/ImageController.cs | 59 + .../Controllers/Media/VideoController.cs | 94 + .../Components/Controllers/Security/README.md | 13 + .../Controllers/Security/UserController.cs | 107 ++ Abyss/Components/Services/ConfigureService.cs | 9 + Abyss/Components/Services/ResourceService.cs | 338 ++++ Abyss/Components/Services/UserService.cs | 177 ++ Abyss/Components/Static/Helpers.cs | 60 + Abyss/Model/ChallengeResponse.cs | 6 + Abyss/Model/ResourceAttribute.cs | 9 + Abyss/Model/User.cs | 9 + Abyss/Model/UserCreating.cs | 10 + Abyss/Program.cs | 57 + Abyss/Properties/launchSettings.json | 24 + Abyss/appsettings.Development.json | 8 + Abyss/appsettings.json | 9 + AbyssCli/AbyssCli.csproj | 16 + AbyssCli/Program.cs | 364 ++++ README.md | 289 ++- 35 files changed, 5261 insertions(+), 4 deletions(-) create mode 100644 .idea/.idea.Abyss/.idea/dataSources.local.xml create mode 100644 .idea/.idea.Abyss/.idea/dataSources.xml create mode 100644 .idea/.idea.Abyss/.idea/dataSources/91acd9d8-5f8b-442f-9d50-17006d4e1ac7.xml create mode 100644 .idea/.idea.Abyss/.idea/dataSources/91acd9d8-5f8b-442f-9d50-17006d4e1ac7/storage_v2/_src_/schema/main.uQUzAA.meta create mode 100644 .idea/.idea.Abyss/.idea/dataSources/bf32ff15-97d4-4301-bb5e-c1d57c7be5c0.xml create mode 100644 .idea/.idea.Abyss/.idea/dataSources/bf32ff15-97d4-4301-bb5e-c1d57c7be5c0/storage_v2/_src_/schema/main.uQUzAA.meta create mode 100644 .idea/.idea.Abyss/.idea/encodings.xml create mode 100644 .idea/.idea.Abyss/.idea/indexLayout.xml create mode 100644 .idea/.idea.Abyss/.idea/projectSettingsUpdater.xml create mode 100644 .idea/.idea.Abyss/.idea/vcs.xml create mode 100644 .idea/.idea.Abyss/.idea/workspace.xml create mode 100644 Abyss.sln create mode 100644 Abyss.sln.DotSettings.user create mode 100644 Abyss/Abyss.csproj create mode 100644 Abyss/Components/Controllers/AbyssController.cs create mode 100644 Abyss/Components/Controllers/Media/ImageController.cs create mode 100644 Abyss/Components/Controllers/Media/VideoController.cs create mode 100644 Abyss/Components/Controllers/Security/README.md create mode 100644 Abyss/Components/Controllers/Security/UserController.cs create mode 100644 Abyss/Components/Services/ConfigureService.cs create mode 100644 Abyss/Components/Services/ResourceService.cs create mode 100644 Abyss/Components/Services/UserService.cs create mode 100644 Abyss/Components/Static/Helpers.cs create mode 100644 Abyss/Model/ChallengeResponse.cs create mode 100644 Abyss/Model/ResourceAttribute.cs create mode 100644 Abyss/Model/User.cs create mode 100644 Abyss/Model/UserCreating.cs create mode 100644 Abyss/Program.cs create mode 100644 Abyss/Properties/launchSettings.json create mode 100644 Abyss/appsettings.Development.json create mode 100644 Abyss/appsettings.json create mode 100644 AbyssCli/AbyssCli.csproj create mode 100644 AbyssCli/Program.cs diff --git a/.gitignore b/.gitignore index 35063fc..8fdb9ae 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,7 @@ CodeCoverage/ # NUnit *.VisualState.xml TestResult.xml -nunit-*.xml \ No newline at end of file +nunit-*.xml + +# DB +*.db \ No newline at end of file diff --git a/.idea/.idea.Abyss/.idea/dataSources.local.xml b/.idea/.idea.Abyss/.idea/dataSources.local.xml new file mode 100644 index 0000000..df6e771 --- /dev/null +++ b/.idea/.idea.Abyss/.idea/dataSources.local.xml @@ -0,0 +1,29 @@ + + + + + + " + + + no-auth + + + + + + + + + " + + + no-auth + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.Abyss/.idea/dataSources.xml b/.idea/.idea.Abyss/.idea/dataSources.xml new file mode 100644 index 0000000..9b96c62 --- /dev/null +++ b/.idea/.idea.Abyss/.idea/dataSources.xml @@ -0,0 +1,27 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/Abyss/user.db + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/Abyss/ra.db + $ProjectFileDir$ + + + 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 + + + file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar + + + + + \ No newline at end of file diff --git a/.idea/.idea.Abyss/.idea/dataSources/91acd9d8-5f8b-442f-9d50-17006d4e1ac7.xml b/.idea/.idea.Abyss/.idea/dataSources/91acd9d8-5f8b-442f-9d50-17006d4e1ac7.xml new file mode 100644 index 0000000..c68b54d --- /dev/null +++ b/.idea/.idea.Abyss/.idea/dataSources/91acd9d8-5f8b-442f-9d50-17006d4e1ac7.xml @@ -0,0 +1,1634 @@ + + + + + 3.45.1 + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + window + + + 1 + + + 1 + + + 1 + + + + 1 + 1 + + + + + 1 + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + + + 1 + + + + + window + + + window + + + + + + 1 + 1 + + + 1 + 1 + + + 1 + + + window + + + + 1 + + + window + + + 1 + + + 1 + 1 + + + + + + 1 + + + 1 + + + window + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + window + + + 1 + window + + + 1 + 1 + + + 1 + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + window + + + 1 + window + + + 1 + 1 + + + 1 + 1 + + + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + 1 + + + window + + + window + + + + window + + + window + + + window + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + 1 + + + 1 + + + 1 + + + 1 + + + aggregate + + + 1 + + + + + + 1 + 1 + + + window + + + aggregate + + + 1 + 1 + + + window + + + 1 + + + aggregate + + + window + + + window + + + 1 + + + 1 + + + + + + + + window + + + 1 + + + 1 + + + 1 + + + 1 + 1 + + + + 1 + + + 1 + + + + + window + + + 1 + + + 1 + + + + + + 1 + + + window + + + 1 + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + 1 + + + + + 1 + + + + aggregate + + + + 1 + 1 + + + window + + + 1 + + + 1 + + + 1 + + + window + + + 1 + + + 1 + + + 1 + 1 + + + 1 + + + window + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + + + 1 + + + aggregate + + + aggregate + + + 1 + + + 1 + 2025-08-23.08:35:53 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + R + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + R + + + R + + + R + + + R + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + R + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + +
+ 1 +
+ + 1 + varchar|0s + + + 2 + varchar|0s + + + 3 + varchar|0s + + + 4 + integer|0s + + + 1 + TEXT|0s + + + 2 + TEXT|0s + + + 3 + TEXT|0s + + + 4 + INT|0s + + + 5 + TEXT|0s + +
+
\ No newline at end of file diff --git a/.idea/.idea.Abyss/.idea/dataSources/91acd9d8-5f8b-442f-9d50-17006d4e1ac7/storage_v2/_src_/schema/main.uQUzAA.meta b/.idea/.idea.Abyss/.idea/dataSources/91acd9d8-5f8b-442f-9d50-17006d4e1ac7/storage_v2/_src_/schema/main.uQUzAA.meta new file mode 100644 index 0000000..8dab49c --- /dev/null +++ b/.idea/.idea.Abyss/.idea/dataSources/91acd9d8-5f8b-442f-9d50-17006d4e1ac7/storage_v2/_src_/schema/main.uQUzAA.meta @@ -0,0 +1,2 @@ +#n:main +! [0, 0, null, null, -2147483648, -2147483648] diff --git a/.idea/.idea.Abyss/.idea/dataSources/bf32ff15-97d4-4301-bb5e-c1d57c7be5c0.xml b/.idea/.idea.Abyss/.idea/dataSources/bf32ff15-97d4-4301-bb5e-c1d57c7be5c0.xml new file mode 100644 index 0000000..c859b9f --- /dev/null +++ b/.idea/.idea.Abyss/.idea/dataSources/bf32ff15-97d4-4301-bb5e-c1d57c7be5c0.xml @@ -0,0 +1,1634 @@ + + + + + 3.45.1 + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + window + + + 1 + + + 1 + + + 1 + + + + 1 + 1 + + + + + 1 + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + + + 1 + + + + + window + + + window + + + + + + 1 + 1 + + + 1 + 1 + + + 1 + + + window + + + + 1 + + + window + + + 1 + + + 1 + 1 + + + + + + 1 + + + 1 + + + window + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + window + + + 1 + window + + + 1 + 1 + + + 1 + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + window + + + 1 + window + + + 1 + 1 + + + 1 + 1 + + + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + 1 + + + 1 + 1 + + + window + + + window + + + + window + + + window + + + window + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + 1 + + + 1 + + + 1 + + + 1 + + + aggregate + + + 1 + + + + + + 1 + 1 + + + window + + + aggregate + + + 1 + 1 + + + window + + + 1 + + + aggregate + + + window + + + window + + + 1 + + + 1 + + + + + + + + window + + + 1 + + + 1 + + + 1 + + + 1 + 1 + + + + 1 + + + 1 + + + + + window + + + 1 + + + 1 + + + + + + 1 + + + window + + + 1 + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + 1 + + + + + 1 + + + + aggregate + + + + 1 + 1 + + + window + + + 1 + + + 1 + + + 1 + + + window + + + 1 + + + 1 + + + 1 + 1 + + + 1 + + + window + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + 1 + + + 1 + + + 1 + + + aggregate + + + aggregate + + + 1 + + + 1 + 2025-08-23.10:03:56 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + R + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + R + + + R + + + R + + + R + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + R + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + 2 + + + 3 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + 2 + + + R + + + 1 + + + R + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + + R + + + 1 + + +
+ 1 +
+ + 1 + varchar|0s + + + 2 + varchar|0s + + + 3 + varchar|0s + + + 4 + varchar|0s + + + 1 + TEXT|0s + + + 2 + TEXT|0s + + + 3 + TEXT|0s + + + 4 + INT|0s + + + 5 + TEXT|0s + +
+
\ No newline at end of file diff --git a/.idea/.idea.Abyss/.idea/dataSources/bf32ff15-97d4-4301-bb5e-c1d57c7be5c0/storage_v2/_src_/schema/main.uQUzAA.meta b/.idea/.idea.Abyss/.idea/dataSources/bf32ff15-97d4-4301-bb5e-c1d57c7be5c0/storage_v2/_src_/schema/main.uQUzAA.meta new file mode 100644 index 0000000..8dab49c --- /dev/null +++ b/.idea/.idea.Abyss/.idea/dataSources/bf32ff15-97d4-4301-bb5e-c1d57c7be5c0/storage_v2/_src_/schema/main.uQUzAA.meta @@ -0,0 +1,2 @@ +#n:main +! [0, 0, null, null, -2147483648, -2147483648] diff --git a/.idea/.idea.Abyss/.idea/encodings.xml b/.idea/.idea.Abyss/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.Abyss/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.Abyss/.idea/indexLayout.xml b/.idea/.idea.Abyss/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.Abyss/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.Abyss/.idea/projectSettingsUpdater.xml b/.idea/.idea.Abyss/.idea/projectSettingsUpdater.xml new file mode 100644 index 0000000..ef20cb0 --- /dev/null +++ b/.idea/.idea.Abyss/.idea/projectSettingsUpdater.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.Abyss/.idea/vcs.xml b/.idea/.idea.Abyss/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/.idea.Abyss/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/.idea.Abyss/.idea/workspace.xml b/.idea/.idea.Abyss/.idea/workspace.xml new file mode 100644 index 0000000..76d8d2c --- /dev/null +++ b/.idea/.idea.Abyss/.idea/workspace.xml @@ -0,0 +1,178 @@ + + + + Abyss/Abyss.csproj + Abyss/Abyss.csproj + AbyssCli/AbyssCli.csproj + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 3 +} + + + + + + + + + + + + + + + + 1755877836092 + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Abyss.sln b/Abyss.sln new file mode 100644 index 0000000..a2abd49 --- /dev/null +++ b/Abyss.sln @@ -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 diff --git a/Abyss.sln.DotSettings.user b/Abyss.sln.DotSettings.user new file mode 100644 index 0000000..e567e74 --- /dev/null +++ b/Abyss.sln.DotSettings.user @@ -0,0 +1,5 @@ + + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded \ No newline at end of file diff --git a/Abyss/Abyss.csproj b/Abyss/Abyss.csproj new file mode 100644 index 0000000..b27990d --- /dev/null +++ b/Abyss/Abyss.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + diff --git a/Abyss/Components/Controllers/AbyssController.cs b/Abyss/Components/Controllers/AbyssController.cs new file mode 100644 index 0000000..f73cc5e --- /dev/null +++ b/Abyss/Components/Controllers/AbyssController.cs @@ -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 logger, ConfigureService config) : Controller +{ + private ILogger _logger = logger; + private ConfigureService _config = config; + + [HttpGet] + public IActionResult GetCollection() + { + return Ok($"Abyss {_config.Version}. \nMediaRoot: {_config.MediaRoot}"); + } +} \ No newline at end of file diff --git a/Abyss/Components/Controllers/Media/ImageController.cs b/Abyss/Components/Controllers/Media/ImageController.cs new file mode 100644 index 0000000..969c42d --- /dev/null +++ b/Abyss/Components/Controllers/Media/ImageController.cs @@ -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 logger, ResourceService rs, ConfigureService config) : Controller +{ + public readonly string ImageFolder = Path.Combine(config.MediaRoot, "Images"); + + [HttpPost("init")] + public async Task 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 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 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 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"; +} \ No newline at end of file diff --git a/Abyss/Components/Controllers/Media/VideoController.cs b/Abyss/Components/Controllers/Media/VideoController.cs new file mode 100644 index 0000000..fe44dff --- /dev/null +++ b/Abyss/Components/Controllers/Media/VideoController.cs @@ -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 logger, ResourceService rs, ConfigureService config) : Controller +{ + private ILogger _logger = logger; + + public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos"); + + [HttpPost("init")] + public async Task 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 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 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 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 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 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 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"; +} \ No newline at end of file diff --git a/Abyss/Components/Controllers/Security/README.md b/Abyss/Components/Controllers/Security/README.md new file mode 100644 index 0000000..49a29d7 --- /dev/null +++ b/Abyss/Components/Controllers/Security/README.md @@ -0,0 +1,13 @@ +明确几个此目录下的API的开发理念: +- 永远不传输私钥 + +root用户的私钥仅通过服务器shell配置 +私钥在客户端生成,仅将公钥传输到服务器 +token通过挑战-响应机制创建,加密传输 + + +- 用户管理 + +创建任何新用户都必须通过一个已有用户的token,且新用户权限等级不大于该用户 +root用户的权限等级为 **114514** + diff --git a/Abyss/Components/Controllers/Security/UserController.cs b/Abyss/Components/Controllers/Security/UserController.cs new file mode 100644 index 0000000..aedda20 --- /dev/null +++ b/Abyss/Components/Controllers/Security/UserController.cs @@ -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 logger) : Controller +{ + private readonly ILogger _logger = logger; + private readonly UserService _user = user; + + [HttpGet("{user}")] + public async Task 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 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 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"; +} \ No newline at end of file diff --git a/Abyss/Components/Services/ConfigureService.cs b/Abyss/Components/Services/ConfigureService.cs new file mode 100644 index 0000000..345ca83 --- /dev/null +++ b/Abyss/Components/Services/ConfigureService.cs @@ -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"; +} \ No newline at end of file diff --git a/Abyss/Components/Services/ResourceService.cs b/Abyss/Components/Services/ResourceService.cs new file mode 100644 index 0000000..2d578da --- /dev/null +++ b/Abyss/Components/Services/ResourceService.cs @@ -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 _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 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().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 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().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().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 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 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 Get(string path, string token, string ip) + { + return await Valid(path, token, OperationType.Read, ip); + } + + public async Task 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(); + foreach (var p in allPaths) + { + var currentPath = Path.GetRelativePath(_config.MediaRoot, p); + var uid = Uid(currentPath); + var existing = await _database.Table().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 Put(string path, string token, string ip) + { + throw new NotImplementedException(); + } + + public async Task Delete(string path, string token, string ip) + { + throw new NotImplementedException(); + } + + public async Task 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().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 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().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; + } + } +} \ No newline at end of file diff --git a/Abyss/Components/Services/UserService.cs b/Abyss/Components/Services/UserService.cs new file mode 100644 index 0000000..f53b30d --- /dev/null +++ b/Abyss/Components/Services/UserService.cs @@ -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 _logger; + private readonly ConfigureService _config; + private readonly IMemoryCache _cache; + private readonly SQLiteAsyncConnection _database; + + public UserService(ILogger logger, ConfigureService config, IMemoryCache cache) + { + _logger = logger; + _config = config; + _cache = cache; + + _database = new SQLiteAsyncConnection(config.UserDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create); + _database.CreateTableAsync().Wait(); + var rootUser = _database.Table().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 Challenge(string user) + { + var u = await _database.Table().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 Verify(string user, string response, string ip) + { + var u = await _database.Table().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 QueryUser(string user) + { + var u = await _database.Table().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); + } +} \ No newline at end of file diff --git a/Abyss/Components/Static/Helpers.cs b/Abyss/Components/Static/Helpers.cs new file mode 100644 index 0000000..00a24f2 --- /dev/null +++ b/Abyss/Components/Static/Helpers.cs @@ -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 +} \ No newline at end of file diff --git a/Abyss/Model/ChallengeResponse.cs b/Abyss/Model/ChallengeResponse.cs new file mode 100644 index 0000000..1d7ad33 --- /dev/null +++ b/Abyss/Model/ChallengeResponse.cs @@ -0,0 +1,6 @@ +namespace Abyss.Model; + +public class ChallengeResponse +{ + public string Response { get; set; } = ""; +} \ No newline at end of file diff --git a/Abyss/Model/ResourceAttribute.cs b/Abyss/Model/ResourceAttribute.cs new file mode 100644 index 0000000..bf72bf9 --- /dev/null +++ b/Abyss/Model/ResourceAttribute.cs @@ -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; } = "--,--,--"; +} \ No newline at end of file diff --git a/Abyss/Model/User.cs b/Abyss/Model/User.cs new file mode 100644 index 0000000..26cb393 --- /dev/null +++ b/Abyss/Model/User.cs @@ -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; } +} \ No newline at end of file diff --git a/Abyss/Model/UserCreating.cs b/Abyss/Model/UserCreating.cs new file mode 100644 index 0000000..6ede628 --- /dev/null +++ b/Abyss/Model/UserCreating.cs @@ -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; } +} \ No newline at end of file diff --git a/Abyss/Program.cs b/Abyss/Program.cs new file mode 100644 index 0000000..cb80f89 --- /dev/null +++ b/Abyss/Program.cs @@ -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(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + 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(); + + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + } + + // app.UseHttpsRedirection(); + + app.UseAuthorization(); + app.MapStaticAssets(); + app.MapControllers(); + + app.UseRateLimiter(); + app.Run(); + } +} \ No newline at end of file diff --git a/Abyss/Properties/launchSettings.json b/Abyss/Properties/launchSettings.json new file mode 100644 index 0000000..7d84003 --- /dev/null +++ b/Abyss/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/Abyss/appsettings.Development.json b/Abyss/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Abyss/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Abyss/appsettings.json b/Abyss/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/Abyss/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/AbyssCli/AbyssCli.csproj b/AbyssCli/AbyssCli.csproj new file mode 100644 index 0000000..05f4bb4 --- /dev/null +++ b/AbyssCli/AbyssCli.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0 + enable + enable + true + true + + + + + + + diff --git a/AbyssCli/Program.cs b/AbyssCli/Program.cs new file mode 100644 index 0000000..e1679a6 --- /dev/null +++ b/AbyssCli/Program.cs @@ -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 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 "); + Console.WriteLine(" AbyssCli destroy "); + Console.WriteLine(" AbyssCli valid "); + Console.WriteLine(" AbyssCli create "); + } + + static HttpClient CreateHttpClient(string baseUrl) + { + var client = new HttpClient(); + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + return client; + } + + static async Task CmdOpen(string[] args) + { + if (args.Length != 4) + { + Console.Error.WriteLine("open requires 3 arguments: "); + 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 CmdDestroy(string[] args) + { + if (args.Length != 3) + { + Console.Error.WriteLine("destroy requires 2 arguments: "); + 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 CmdValid(string[] args) + { + if (args.Length != 3) + { + Console.Error.WriteLine("valid requires 2 arguments: "); + 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(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 CmdCreate(string[] args) + { + if (args.Length != 6) + { + Console.Error.WriteLine("create requires 5 arguments: "); + 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 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(content, jsonOptions); + if (s != null) return s; + } + catch { /* ignore */ } + + // fallback: trim quotes + return content.Trim('"'); + } + + static async Task 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(content, jsonOptions); + if (!string.IsNullOrEmpty(token)) return token; + } + catch { /* ignore */ } + return content.Trim('"'); + } + + static async Task TryReadResponseText(HttpResponseMessage resp) + { + try + { + return await resp.Content.ReadAsStringAsync(); + } + catch + { + return ""; + } + } + + static readonly JsonSerializerOptions jsonOptions = new() + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver() + }; +} diff --git a/README.md b/README.md index 23dd3a8..a4e9c90 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
+_
# Abyss (Server for Aether) @@ -37,10 +37,293 @@ _🚀This is the server of the multimedia application Aether, which can also be dotnet restore 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 - [ ] 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 API interface instructions in the READMD document +- [x] Add API interface instructions in the READMD document_