39 Commits

Author SHA1 Message Date
rootacite
c23a3260aa [feat] User announce. 2025-10-27 12:25:04 +08:00
acite
2433175757 [feat] Abyssctl Basic functions 2 2025-10-05 21:43:20 +08:00
acite
a0273e3334 [feat] Abyssctl Basic functions 2025-10-05 16:48:54 +08:00
acite
50eae5e275 [feat] Abyssctl Automatic module discovery 2025-10-05 11:41:06 +08:00
acite
af6dfbac8c [feat] Ctl framework 2025-10-05 03:00:26 +08:00
acite
dcdd9d840e [optimize] Merge user logic to service 2025-10-02 18:47:15 +08:00
acite
db58091814 [feat] Add cover felid to Comic model 2025-10-01 18:49:55 +08:00
acite
eb72efb338 [doc] Doc Update 2025-09-29 20:49:29 +08:00
acite
46ffba7098 [update] Remove token from Query param, move to cookies 2025-09-29 01:18:37 +08:00
acite
52c18212a4 [optimize] Phase 1 Architecture Review 2025-09-28 22:03:15 +08:00
acite
c8d9e4f9ee [feat] Add Merge Command to Script 2025-09-27 01:17:05 +08:00
acite
b2f91175f7 [feat] Add support for grouping in management scripts 2025-09-27 00:51:23 +08:00
acite
79add11c08 [feat] Video Group 2025-09-25 23:54:09 +08:00
acite
76bdba1755 [feat] Index Service 2025-09-25 15:14:02 +08:00
acite
46ff611706 [feat] Added Init endpoint to RootController. 2025-09-20 15:18:32 +08:00
acite
f1e636a79d [optimize] Database optimizition 2025-09-20 15:03:05 +08:00
acite
87536a1508 [optimize] Refactoring AbyssCli project to Python script 2025-09-20 14:41:47 +08:00
acite
a7c522a61f [feat] Optional recursive 2025-09-20 14:22:08 +08:00
acite
3723ea32a7 [feat] Video system optimization2 2025-09-20 02:41:53 +08:00
acite
e7d24aa20b [feat] Video system optimization 2025-09-20 02:16:43 +08:00
acite
a2f6eb1fba [feat] Further improvement of the permission system 2025-09-20 00:42:23 +08:00
acite
57c37bee51 [doc] Removed doc 2025-09-17 23:01:19 +08:00
acite
a228d523a2 [fix] Exception capture mechanism 2025-09-17 19:45:11 +08:00
acite
40c041444a [update] Refactoring database logic and optimizing queries 2025-09-17 18:47:12 +08:00
acite
8465ec5b2a [doc] Icon& Doc 2025-09-14 23:19:16 +08:00
acite
ec7306ade2 [doc] Description Updated 2025-09-14 14:34:16 +08:00
acite
9aa987d52a [optimize] merge network write 2025-09-14 00:52:08 +08:00
acite
e174238d3c [feat] Abyss Protocol authentication 2025-09-13 17:01:12 +08:00
acite
cad92f8fa5 [fix] No tags in Comic Class 2025-09-13 14:46:45 +08:00
acite
9b6a4a9982 [fix] Bulk query permission 2025-09-13 14:31:59 +08:00
acite
197cf525fb [feat] Bulk query 2025-09-13 13:06:55 +08:00
acite
ae93b75e41 [merge] Merge branch 'dev-abyss' 2025-09-13 00:37:21 +08:00
acite
3f90ea9476 [fix] ReadAsync Magic Number 2025-09-13 00:36:59 +08:00
acite
23b43f15b5 [feat] Abyss protocol 2025-09-12 22:51:27 +08:00
acite
d2e11817db [feat] Safer client authentication 2025-09-11 13:55:24 +08:00
acite
f9e4510553 [merge] Merge branch 'dev-live' 2025-09-09 22:17:02 +08:00
acite
4ef20f7dc7 [feat] Live 2025-09-09 22:16:34 +08:00
acite
5c4f1a87d2 [feat] update-video.py 2025-09-09 14:09:08 +08:00
acite
c14823f971 [merge] Merge branch 'dev-task' 2025-09-09 12:11:46 +08:00
88 changed files with 5993 additions and 1453 deletions

4
.gitignore vendored
View File

@@ -57,4 +57,6 @@ nunit-*.xml
*.db
appsettings.json
appsettings.Development.json
appsettings.Development.json
build/
publish/

View File

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

View File

@@ -499,7 +499,7 @@
</routine>
<schema id="191" parent="1" name="main">
<Current>1</Current>
<LastIntrospectionLocalTimestamp>2025-08-23.08:35:53</LastIntrospectionLocalTimestamp>
<LastIntrospectionLocalTimestamp>2025-09-25.10:18:41</LastIntrospectionLocalTimestamp>
</schema>
<argument id="192" parent="16">
<ArgumentDirection>R</ArgumentDirection>
@@ -1590,45 +1590,72 @@
<argument id="554" parent="190">
<Position>1</Position>
</argument>
<table id="555" parent="191" name="User"/>
<table id="555" parent="191" name="Users"/>
<table id="556" parent="191" name="sqlite_master">
<System>1</System>
</table>
<column id="557" parent="555" name="Name">
<table id="557" parent="191" name="sqlite_sequence">
<System>1</System>
</table>
<column id="558" parent="555" name="Uuid">
<AutoIncrement>1</AutoIncrement>
<NotNull>1</NotNull>
<Position>1</Position>
<StoredType>varchar|0s</StoredType>
</column>
<column id="558" parent="555" name="Parent">
<Position>2</Position>
<StoredType>varchar|0s</StoredType>
</column>
<column id="559" parent="555" name="PublicKey">
<Position>3</Position>
<StoredType>varchar|0s</StoredType>
</column>
<column id="560" parent="555" name="Privilege">
<Position>4</Position>
<StoredType>integer|0s</StoredType>
</column>
<column id="561" parent="556" name="type">
<column id="559" parent="555" name="Username">
<NotNull>1</NotNull>
<Position>2</Position>
<StoredType>varchar|0s</StoredType>
</column>
<column id="560" parent="555" name="ParentId">
<NotNull>1</NotNull>
<Position>3</Position>
<StoredType>integer|0s</StoredType>
</column>
<column id="561" parent="555" name="PublicKey">
<NotNull>1</NotNull>
<Position>4</Position>
<StoredType>varchar|0s</StoredType>
</column>
<column id="562" parent="555" name="Privilege">
<NotNull>1</NotNull>
<Position>5</Position>
<StoredType>integer|0s</StoredType>
</column>
<index id="563" parent="555" name="Users_Username">
<ColNames>Username</ColNames>
<Unique>1</Unique>
</index>
<key id="564" parent="555">
<ColNames>Uuid</ColNames>
<Primary>1</Primary>
</key>
<column id="565" parent="556" name="type">
<Position>1</Position>
<StoredType>TEXT|0s</StoredType>
</column>
<column id="562" parent="556" name="name">
<column id="566" parent="556" name="name">
<Position>2</Position>
<StoredType>TEXT|0s</StoredType>
</column>
<column id="563" parent="556" name="tbl_name">
<column id="567" parent="556" name="tbl_name">
<Position>3</Position>
<StoredType>TEXT|0s</StoredType>
</column>
<column id="564" parent="556" name="rootpage">
<column id="568" parent="556" name="rootpage">
<Position>4</Position>
<StoredType>INT|0s</StoredType>
</column>
<column id="565" parent="556" name="sql">
<column id="569" parent="556" name="sql">
<Position>5</Position>
<StoredType>TEXT|0s</StoredType>
</column>
<column id="570" parent="557" name="name">
<Position>1</Position>
</column>
<column id="571" parent="557" name="seq">
<Position>2</Position>
</column>
</database-model>
</dataSource>

View File

@@ -499,7 +499,7 @@
</routine>
<schema id="191" parent="1" name="main">
<Current>1</Current>
<LastIntrospectionLocalTimestamp>2025-08-23.10:03:56</LastIntrospectionLocalTimestamp>
<LastIntrospectionLocalTimestamp>2025-10-05.08:15:22</LastIntrospectionLocalTimestamp>
</schema>
<argument id="192" parent="16">
<ArgumentDirection>R</ArgumentDirection>
@@ -1590,45 +1590,67 @@
<argument id="554" parent="190">
<Position>1</Position>
</argument>
<table id="555" parent="191" name="ResourceAttribute"/>
<table id="555" parent="191" name="ResourceAttributes"/>
<table id="556" parent="191" name="sqlite_master">
<System>1</System>
</table>
<column id="557" parent="555" name="Uid">
<table id="557" parent="191" name="sqlite_sequence">
<System>1</System>
</table>
<column id="558" parent="555" name="Id">
<AutoIncrement>1</AutoIncrement>
<NotNull>1</NotNull>
<Position>1</Position>
<StoredType>varchar|0s</StoredType>
<StoredType>integer|0s</StoredType>
</column>
<column id="558" parent="555" name="Name">
<column id="559" parent="555" name="Uid">
<NotNull>1</NotNull>
<Position>2</Position>
<StoredType>varchar|0s</StoredType>
</column>
<column id="559" parent="555" name="Owner">
<column id="560" parent="555" name="Owner">
<NotNull>1</NotNull>
<Position>3</Position>
<StoredType>varchar|0s</StoredType>
<StoredType>integer|0s</StoredType>
</column>
<column id="560" parent="555" name="Permission">
<column id="561" parent="555" name="Permission">
<NotNull>1</NotNull>
<Position>4</Position>
<StoredType>varchar|0s</StoredType>
</column>
<column id="561" parent="556" name="type">
<index id="562" parent="555" name="ResourceAttributes_Uid">
<ColNames>Uid</ColNames>
<Unique>1</Unique>
</index>
<key id="563" parent="555">
<ColNames>Id</ColNames>
<Primary>1</Primary>
</key>
<column id="564" parent="556" name="type">
<Position>1</Position>
<StoredType>TEXT|0s</StoredType>
</column>
<column id="562" parent="556" name="name">
<column id="565" parent="556" name="name">
<Position>2</Position>
<StoredType>TEXT|0s</StoredType>
</column>
<column id="563" parent="556" name="tbl_name">
<column id="566" parent="556" name="tbl_name">
<Position>3</Position>
<StoredType>TEXT|0s</StoredType>
</column>
<column id="564" parent="556" name="rootpage">
<column id="567" parent="556" name="rootpage">
<Position>4</Position>
<StoredType>INT|0s</StoredType>
</column>
<column id="565" parent="556" name="sql">
<column id="568" parent="556" name="sql">
<Position>5</Position>
<StoredType>TEXT|0s</StoredType>
</column>
<column id="569" parent="557" name="name">
<Position>1</Position>
</column>
<column id="570" parent="557" name="seq">
<Position>2</Position>
</column>
</database-model>
</dataSource>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/bf32ff15-97d4-4301-bb5e-c1d57c7be5c0/console.sql" value="bf32ff15-97d4-4301-bb5e-c1d57c7be5c0" />
</component>
</project>

View File

@@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>abyssctl</w>
</words>
</dictionary>
</component>

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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/Abyss/Components/Services/Media/ResourceDatabaseService.cs" dialect="GenericSQL" />
</component>
</project>

View File

@@ -4,27 +4,16 @@
<projectFile profileName="http">Abyss/Abyss.csproj</projectFile>
<projectFile profileName="https">Abyss/Abyss.csproj</projectFile>
<projectFile>AbyssCli/AbyssCli.csproj</projectFile>
<projectFile>abyssctl/abyssctl.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/Toolkits/image-creator.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Toolkits/image-sum.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Toolkits/update-tags.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Abyss/Toolkits/update-video.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/.idea.Abyss/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.Abyss/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss.sln.DotSettings.user" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss.sln.DotSettings.user" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/ImageController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/ImageController.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/VideoController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Media/VideoController.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Services/ConfigureService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Services/ConfigureService.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Services/ResourceService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Services/ResourceService.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Services/UserService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Services/UserService.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Tools/NaturalStringComparer.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Tools/NaturalStringComparer.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Model/Bookmark.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Model/Bookmark.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Model/Comic.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Model/Comic.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Properties/launchSettings.json" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Properties/launchSettings.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Services/Security/UserService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Services/Security/UserService.cs" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -39,37 +28,79 @@
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/011a191356a243438f987de3ec3d6c6230800/04/8419ff35/ServiceProvider.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/18f2eb258dcf45748fa1903c530f5f07d1a000/f2/f5e8fb60/Array.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/457530be4752476295767457c3639889d1a000/25/817def70/ConfiguredValueTaskAwaitable`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/457530be4752476295767457c3639889d1a000/4c/4b962087/Monitor.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/457530be4752476295767457c3639889d1a000/af/aac0eaa5/ExceptionDispatchInfo.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/457530be4752476295767457c3639889d1a000/b5/9de8e4ee/Index.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/457530be4752476295767457c3639889d1a000/d0/3b166e9e/String.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/457530be4752476295767457c3639889d1a000/f3/fbf95091/SafeFileHandle.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/61241bc83d094fe6ac4acdfe094b2b7f1e000/d9/09284666/ServiceProviderServiceExtensions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/61fe11e9d86b4d2a9bd2b806929b7d381a400/a1/62750ee4/AsyncTableQuery`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/61fe11e9d86b4d2a9bd2b806929b7d381a400/e9/67f4a40e/SQLiteAsyncConnection.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/c453ace1e4574cfe83f15ca8c8f735bf37000/04/dce32804/ParserResultExtensions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c453ace1e4574cfe83f15ca8c8f735bf37000/b6/fcf31dfe/ParserExtensions.cs" root0="SKIP_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/Controllers/Media/IndexController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Media/LiveController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Middleware/BadRequestExceptionMiddleware.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Task/TaskController.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/TaskService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/UserService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Admin/Attributes/ModuleAttribute.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Admin/CtlService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Admin/Interfaces/IModule.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Admin/Modules/ChmodModule.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Admin/Modules/HelloModule.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Admin/Modules/IncludeModule.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Admin/Modules/InitModule.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Admin/Modules/ListModule.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Admin/Modules/UserAddModule.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Admin/Modules/VersionModule.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Media/ComicService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Media/IndexService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Media/ResourceDatabaseService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Media/ResourceService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Media/TaskService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Media/VideoService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Misc/ConfigureService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Security/AbyssService.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/Security/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/Bookmark.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/ChallengeResponse.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Chip.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Comic.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Comment.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/ResourceAttribute.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Task.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/TaskCreation.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$/Abyss/Model/Video.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/AbyssCli/Program.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file:///storage/Images/31/summary.json" root0="FORCE_HIGHLIGHTING" />
<setting file="file:///usr/lib/dotnet/sdk/9.0.109/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.targets" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Static/SocketExtensions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Tools/AbyssStream.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Tools/HttpHelper.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Tools/HttpReader.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Misc/StringClusterer.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Admin/Ctl.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Bookmark.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Chip.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Comic.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Comment.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Index.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Media/ResourceAttribute.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Task.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Media/TaskCreation.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Video.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Security/ChallengeResponse.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Security/User.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Security/UserCreating.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/abyssctl/App/App.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/abyssctl/App/Interfaces/IOptions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/abyssctl/App/Modules/ChmodOptions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/abyssctl/App/Modules/HelloOptions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/abyssctl/App/Modules/IncludeOptions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/abyssctl/App/Modules/InitOptions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/abyssctl/App/Modules/ListOptions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/abyssctl/App/Modules/UserAddOptions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/abyssctl/App/Modules/VersionOptions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/abyssctl/Program.cs" root0="FORCE_HIGHLIGHTING" />
</component>
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
<component name="ProblemsViewState">
<option name="selectedTabId" value="CurrentFile" />
<option name="selectedTabId" value="Toolset" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 3
@@ -79,51 +110,54 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
".NET Launch Settings Profile.Abyss: http.executor": "Run",
".NET Launch Settings Profile.Abyss: https.executor": "Debug",
".NET Project.AbyssCli.executor": "Run",
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
"ModuleVcsDetector.initialDetectionPerformed": "true",
"Publish to folder.Publish Abyss to folder x86.executor": "Run",
"Publish to folder.Publish Abyss to folder.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"XThreadsFramesViewSplitterKey": "0.30266345",
"git-widget-placeholder": "dev-task",
"last_opened_file_path": "/storage/Images/31/summary.json",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable",
"vue.rearranger.settings.migration": "true"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;.NET Launch Settings Profile.Abyss: http.executor&quot;: &quot;Run&quot;,
&quot;.NET Launch Settings Profile.Abyss: https.executor&quot;: &quot;Debug&quot;,
&quot;.NET Project.AbyssCli.executor&quot;: &quot;Run&quot;,
&quot;.NET Project.abyssctl.executor&quot;: &quot;Debug&quot;,
&quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;Publish to folder.Publish Abyss to folder x86.executor&quot;: &quot;Run&quot;,
&quot;Publish to folder.Publish Abyss to folder.executor&quot;: &quot;Run&quot;,
&quot;Publish to folder.p1.executor&quot;: &quot;Run&quot;,
&quot;Publish to folder.p2.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;XThreadsFramesViewSplitterKey&quot;: &quot;0.55813956&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/acite/AciteProjects/Abyss/README.md&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.lookFeel&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}]]></component>
<component name="RunManager" selected="Publish to folder.Publish Abyss to folder">
<configuration name="Publish Abyss to folder x86" type="DotNetFolderPublish" factoryName="Publish to folder">
<riderPublish configuration="Release" platform="Any CPU" produce_single_file="true" ready_to_run="true" self_contained="true" target_folder="/opt/security/https/server" target_framework="net9.0" uuid_high="3690631506471504162" uuid_low="-4858628519588143325">
<runtimes>
<item value="linux-x64" />
</runtimes>
</riderPublish>
<method v="2" />
</configuration>
<configuration name="Publish Abyss to folder" type="DotNetFolderPublish" factoryName="Publish to folder">
<riderPublish configuration="Release" platform="Any CPU" produce_single_file="true" self_contained="true" target_folder="$PROJECT_DIR$/Abyss/bin/Release/net9.0/publish" target_framework="net9.0" uuid_high="3690631506471504162" uuid_low="-4858628519588143325">
}</component>
<component name="RunManager" selected="Publish to folder.p2">
<configuration name="p1" type="DotNetFolderPublish" factoryName="Publish to folder" singleton="false">
<riderPublish configuration="Release" platform="Any CPU" produce_single_file="true" self_contained="true" target_folder="$PROJECT_DIR$/publish" target_framework="net9.0" uuid_high="3690631506471504162" uuid_low="-4858628519588143325">
<runtimes>
<item value="linux-arm64" />
</runtimes>
</riderPublish>
<method v="2" />
</configuration>
<configuration name="AbyssCli" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="" />
<configuration name="p2" type="DotNetFolderPublish" factoryName="Publish to folder">
<riderPublish configuration="Release" platform="Any CPU" produce_single_file="true" self_contained="true" target_folder="$PROJECT_DIR$/publish" target_framework="net9.0" uuid_high="-657823440020091444" uuid_low="-8550226025966742844">
<runtimes>
<item value="linux-arm64" />
</runtimes>
</riderPublish>
<method v="2" />
</configuration>
<configuration name="abyssctl" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/build/net9.0/abyssctl" />
<option name="PROGRAM_PARAMETERS" value="hello" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/build/net9.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" />
@@ -133,12 +167,12 @@
<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_PATH" value="$PROJECT_DIR$/abyssctl/abyssctl.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="" />
<option name="PROJECT_TFM" value="net9.0" />
<method v="2">
<option name="Build" />
</method>
@@ -159,28 +193,11 @@
<option name="Build" />
</method>
</configuration>
<configuration name="Abyss: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Abyss/Abyss.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<list>
<item itemvalue=".NET Launch Settings Profile.Abyss: http" />
<item itemvalue=".NET Launch Settings Profile.Abyss: https" />
<item itemvalue=".NET Project.AbyssCli" />
<item itemvalue="Publish to folder.Publish Abyss to folder" />
<item itemvalue="Publish to folder.Publish Abyss to folder x86" />
<item itemvalue=".NET Project.abyssctl" />
<item itemvalue="Publish to folder.p1" />
<item itemvalue="Publish to folder.p2" />
</list>
</component>
<component name="TaskManager">
@@ -211,6 +228,89 @@
<workItem from="1757076719875" duration="601000" />
<workItem from="1757219779961" duration="112000" />
<workItem from="1757386288260" duration="3634000" />
<workItem from="1757428682321" duration="171000" />
<workItem from="1757429030386" duration="20000" />
<workItem from="1757508119360" duration="1704000" />
<workItem from="1757519520290" duration="14000" />
<workItem from="1757567561745" duration="2452000" />
<workItem from="1757597908282" duration="9750000" />
<workItem from="1757648650473" duration="9000" />
<workItem from="1757649246468" duration="4023000" />
<workItem from="1757653914660" duration="1923000" />
<workItem from="1757680205207" duration="3000" />
<workItem from="1757684000965" duration="2511000" />
<workItem from="1757687641035" duration="2969000" />
<workItem from="1757693751836" duration="667000" />
<workItem from="1757694833696" duration="11000" />
<workItem from="1757695721386" duration="749000" />
<workItem from="1757702942841" duration="32000" />
<workItem from="1757735249561" duration="5523000" />
<workItem from="1757742881713" duration="2285000" />
<workItem from="1757745929389" duration="93000" />
<workItem from="1757751423586" duration="2687000" />
<workItem from="1757782027930" duration="308000" />
<workItem from="1757830765557" duration="1218000" />
<workItem from="1757862781213" duration="341000" />
<workItem from="1757918235256" duration="1000" />
<workItem from="1758040123892" duration="21000" />
<workItem from="1758040188148" duration="1000" />
<workItem from="1758049713959" duration="86000" />
<workItem from="1758084310862" duration="17701000" />
<workItem from="1758121232981" duration="69000" />
<workItem from="1758279286341" duration="6796000" />
<workItem from="1758303096075" duration="2560000" />
<workItem from="1758307172642" duration="157000" />
<workItem from="1758307433345" duration="34000" />
<workItem from="1758344749532" duration="238000" />
<workItem from="1758345893755" duration="2662000" />
<workItem from="1758349313244" duration="24000" />
<workItem from="1758349710909" duration="16000" />
<workItem from="1758350096355" duration="452000" />
<workItem from="1758350848039" duration="946000" />
<workItem from="1758352441563" duration="281000" />
<workItem from="1758599755722" duration="14000" />
<workItem from="1758767744733" duration="12501000" />
<workItem from="1758794950242" duration="9381000" />
<workItem from="1758814543368" duration="642000" />
<workItem from="1758815224532" duration="430000" />
<workItem from="1758905391249" duration="128000" />
<workItem from="1758906781361" duration="252000" />
<workItem from="1759036019712" duration="20642000" />
<workItem from="1759072866075" duration="5798000" />
<workItem from="1759137056827" duration="1026000" />
<workItem from="1759150007653" duration="169000" />
<workItem from="1759314718830" duration="55000" />
<workItem from="1759315721112" duration="82000" />
<workItem from="1759398581423" duration="2195000" />
<workItem from="1759401971386" duration="69000" />
<workItem from="1759434890177" duration="183000" />
<workItem from="1759508787637" duration="115000" />
<workItem from="1759509008651" duration="2869000" />
<workItem from="1759515879741" duration="297000" />
<workItem from="1759516905127" duration="1451000" />
<workItem from="1759519618552" duration="9000" />
<workItem from="1759520741934" duration="642000" />
<workItem from="1759551752441" duration="5836000" />
<workItem from="1759561043616" duration="201000" />
<workItem from="1759591584659" duration="8123000" />
<workItem from="1759634209525" duration="1767000" />
<workItem from="1759639928617" duration="19620000" />
<workItem from="1759687378138" duration="189000" />
<workItem from="1759761027330" duration="124000" />
<workItem from="1759762428412" duration="103000" />
<workItem from="1759896589590" duration="691000" />
<workItem from="1760263567232" duration="4000" />
<workItem from="1760270818442" duration="477000" />
<workItem from="1760271408229" duration="10000" />
<workItem from="1760271525423" duration="10000" />
<workItem from="1760271788470" duration="14000" />
<workItem from="1760272429219" duration="621000" />
<workItem from="1760274170016" duration="1601000" />
<workItem from="1760275944851" duration="11000" />
<workItem from="1760304622217" duration="25000" />
<workItem from="1760629359988" duration="6000" />
<workItem from="1760630875664" duration="1000" />
<workItem from="1761538960257" duration="66000" />
</task>
<servers />
</component>
@@ -230,7 +330,7 @@
<entry key="branch">
<value>
<list>
<option value="dev-task" />
<option value="main" />
</list>
</value>
</entry>

View File

@@ -2,7 +2,7 @@
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}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "abyssctl", "abyssctl\abyssctl.csproj", "{F6DEF111-0CAD-4DCC-8957-7EBAFCF3D2C4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -14,9 +14,9 @@ Global
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.Build.0 = Release|Any CPU
{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D7D668D4-61E7-4AA4-B615-A162FABAD333}.Release|Any CPU.Build.0 = Release|Any CPU
{F6DEF111-0CAD-4DCC-8957-7EBAFCF3D2C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F6DEF111-0CAD-4DCC-8957-7EBAFCF3D2C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F6DEF111-0CAD-4DCC-8957-7EBAFCF3D2C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F6DEF111-0CAD-4DCC-8957-7EBAFCF3D2C4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -1,7 +1,17 @@
<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_003AArray_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F18f2eb258dcf45748fa1903c530f5f07d1a000_003Ff2_003Ff5e8fb60_003FArray_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAsyncTableQuery_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F61fe11e9d86b4d2a9bd2b806929b7d381a400_003Fa1_003F62750ee4_003FAsyncTableQuery_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConfiguredValueTaskAwaitable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003F25_003F817def70_003FConfiguredValueTaskAwaitable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_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_003AControllerBase_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5df2accb46d040ccbbbe8331bf4d24b61daa00_003Fdf_003F93debd37_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003Faf_003Faac0eaa5_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKey_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff09ccaeb94c34c2299acd3efee0facee1a400_003F81_003F137b58b4_003FKey_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003F4c_003F4b962087_003FMonitor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AParserResultExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc453ace1e4574cfe83f15ca8c8f735bf37000_003F04_003Fdce32804_003FParserResultExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASafeFileHandle_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003Ff3_003Ffbf95091_003FSafeFileHandle_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProviderServiceExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F61241bc83d094fe6ac4acdfe094b2b7f1e000_003Fd9_003F09284666_003FServiceProviderServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F011a191356a243438f987de3ec3d6c6230800_003F04_003F8419ff35_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASQLiteAsyncConnection_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F61fe11e9d86b4d2a9bd2b806929b7d381a400_003Fe9_003F67f4a40e_003FSQLiteAsyncConnection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AString_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003Fd0_003F3b166e9e_003FString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F457530be4752476295767457c3639889d1a000_003F6b_003F2e4babaf_003FTask_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AxxHash128_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7598e47d5cdf4107ba88f8220720fdc89000_003Fa6_003F79d67871_003FxxHash128_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

View File

@@ -6,6 +6,10 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<OutputPath>../build/</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="K4os.Hash.xxHash" Version="1.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/>
@@ -13,12 +17,12 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.11" />
<PackageReference Include="SQLitePCLRaw.core" Version="3.0.2" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.11" />
<PackageReference Include="SQLitePCLRaw.provider.e_sqlite3" Version="3.0.2" />
<PackageReference Include="Standart.Hash.xxHash" Version="4.0.5" />
<PackageReference Include="System.IO.Hashing" Version="10.0.0-preview.7.25380.108" />
</ItemGroup>
<ItemGroup>
<Folder Include="Components\Controllers\Media\" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,5 @@
using Abyss.Components.Services;
using Abyss.Components.Services.Misc;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;

View File

@@ -1,83 +1,56 @@
using Abyss.Components.Services;
using Abyss.Components.Services.Media;
using Abyss.Components.Static;
using Abyss.Components.Tools;
using Abyss.Model;
using Abyss.Model.Media;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
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 class ImageController(ComicService comicService) : BaseController
{
public readonly string ImageFolder = Path.Combine(config.MediaRoot, "Images");
[HttpPost("init")]
public async Task<IActionResult> InitAsync(string token, string owner)
public async Task<IActionResult> InitAsync(string owner)
{
var r = await rs.Initialize(ImageFolder, token, owner, Ip);
if(r) return Ok(r);
return StatusCode(403, new { message = "403 Denied" });
var r = await comicService.InitAsync(Token, owner, Ip);
return r ? Ok("Initialize Success") : _403;
}
[HttpGet]
public async Task<IActionResult> QueryCollections(string token)
public async Task<IActionResult> QueryCollections()
{
var r = await rs.Query(ImageFolder, token, Ip);
if(r == null)
return StatusCode(401, new { message = "Unauthorized" });
return Ok(r.NaturalSort(x => x));
var r = await comicService.QueryCollections(Token, Ip);
return r != null ? Ok(r.NaturalSort(x => x)) : _403;
}
[HttpGet("{id}")]
public async Task<IActionResult> Query(string id, string token)
public async Task<IActionResult> Query(string id)
{
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));
var r = await comicService.Query(id, Token, Ip);
return r != null ? Ok(r) : _403;
}
[HttpPost("bulkquery")]
public async Task<IActionResult> QueryBulk([FromBody] string[] id)
{
var r = await comicService.QueryBulk(Token, id, Ip);
return Ok(JsonConvert.SerializeObject(r));
}
[HttpPost("{id}/bookmark")]
public async Task<IActionResult> Bookmark(string id, string token, [FromBody] Bookmark bookmark)
public async Task<IActionResult> Bookmark(string id, [FromBody] Bookmark bookmark)
{
var d = Helpers.SafePathCombine(ImageFolder, [id, "summary.json"]);
if (d == null) return StatusCode(403, new { message = "403 Denied" });
var r = await rs.Update(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
Comic c = JsonConvert.DeserializeObject<Comic>(await System.IO.File.ReadAllTextAsync(d))!;
var bookmarkPage = Helpers.SafePathCombine(ImageFolder, [id, bookmark.Page]);
if(!System.IO.File.Exists(bookmarkPage))
return BadRequest();
c.Bookmarks.Add(bookmark);
var o = JsonConvert.SerializeObject(c);
await System.IO.File.WriteAllTextAsync(d, o);
return Ok();
var r = await comicService.Bookmark(id, Token, bookmark, Ip);
return r ? Ok("Success") : _403;
}
[HttpGet("{id}/{file}")]
public async Task<IActionResult> Get(string id, string file, string token)
public async Task<IActionResult> Get(string id, string file)
{
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);
var r = await comicService.Page(id, file, Token, Ip);
return r ?? _403;
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
}

View File

@@ -0,0 +1,11 @@
using Abyss.Components.Static;
using Microsoft.AspNetCore.Mvc;
namespace Abyss.Components.Controllers.Media;
[ApiController]
[Route("api/[controller]")]
public class IndexController: BaseController
{
}

View File

@@ -0,0 +1,52 @@
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Misc;
using Abyss.Components.Static;
using Microsoft.AspNetCore.Mvc;
namespace Abyss.Components.Controllers.Media;
[ApiController]
[Route("api/[controller]")]
public class LiveController(ResourceService rs, ConfigureService config): BaseController
{
public readonly string LiveFolder = Path.Combine(config.MediaRoot, "Live");
[HttpPost("{id}")]
public async Task<IActionResult> AddLive(string id, int owner)
{
var d = Helpers.SafePathCombine(LiveFolder, [id]);
if (d == null) return _403;
bool r = await rs.Include(d, Token, Ip, owner, "rw,--,--");
return r ? Ok("Success") : _400;
}
[HttpDelete("{id}")]
public async Task<IActionResult> RemoveLive(string id)
{
var d = Helpers.SafePathCombine(LiveFolder, [id]);
if (d == null)
return _403;
bool r = await rs.Exclude(d, Token, Ip);
return r ? Ok("Success") : _400;
}
[HttpGet("{id}/{item}")]
public async Task<IActionResult> GetLive(string id, string item)
{
var d = Helpers.SafePathCombine(LiveFolder, [id, item]);
if (d == null) return _400;
// TODO: (History)ffplay does not add the m3u8 query parameter in ts requests, so special treatment is given to ts here
// TODO: (History)It should be pointed out that this implementation is not secure and should be modified in subsequent updates
// TODO: It's still not very elegant, but it's a bit better to some extent
var r = await rs.Get(d, Token, Ip, Helpers.GetContentType(d));
return r ?? _404;
}
}

View File

@@ -1,8 +1,6 @@
using System.Diagnostics;
using Abyss.Components.Services;
using Abyss.Components.Services.Media;
using Abyss.Components.Static;
using Abyss.Components.Tools;
using Abyss.Model;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
@@ -10,105 +8,72 @@ namespace Abyss.Components.Controllers.Media;
[ApiController]
[Route("api/[controller]")]
public class VideoController(ILogger<VideoController> logger, ResourceService rs, ConfigureService config) : Controller
public class VideoController(VideoService videoService)
: BaseController
{
private ILogger<VideoController> _logger = logger;
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
[HttpPost("init")]
public async Task<IActionResult> InitAsync(string token, string owner)
public async Task<IActionResult> InitAsync(string owner)
{
var r = await rs.Initialize(VideoFolder, token, owner, Ip);
if(r) return Ok(r);
return StatusCode(403, new { message = "403 Denied" });
if (await videoService.Init(Token, owner, Ip))
return Ok("Initialized Successfully");
return _403;
}
[HttpGet]
public async Task<IActionResult> GetClass(string token)
public async Task<IActionResult> GetClass()
{
var r = (await rs.Query(VideoFolder, token, Ip))?.SortLikeWindows();
if(r == null)
return StatusCode(401, new { message = "Unauthorized" });
return Ok(r);
var r = await videoService.GetClasses(Token, Ip);
return r != null ? Ok(r) : _403;
}
[HttpGet("{klass}")]
public async Task<IActionResult> QueryClass(string klass, string token)
public async Task<IActionResult> QueryClass(string klass)
{
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" });
var rv = r.Select(x =>
{
return Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"]);
}).ToArray();
for (int i = 0; i < rv.Length; i++)
{
if(rv[i] == null) continue;
rv[i] = await System.IO.File.ReadAllTextAsync(rv[i] ?? "");
}
var sv = rv.Where(x => x!=null).Select(x => x ?? "")
.Select(x => JsonConvert.DeserializeObject<Video>(x)).ToArray();
return Ok(sv.Zip(r, (x, y) => (x, y)).NaturalSort(x => x.x.name).Select(x => x.y).ToArray());
var r = await videoService.QueryClass(klass, Token, Ip);
return r != null ? Ok(r) : _403;
}
[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}")]
public async Task<IActionResult> QueryVideo(string klass, string id)
{
var r = await videoService.QueryVideo(klass, id, Token, Ip);
return r != null ? Ok(r) : _403;
}
[HttpPost("{klass}/bulkquery")]
public async Task<IActionResult> QueryBulk([FromBody] string[] id,
[FromRoute] string klass)
{
var r = await videoService.QueryBulk(klass, id, Token, Ip);
return Ok(JsonConvert.SerializeObject(r));
}
[HttpGet("{klass}/{id}/cover")]
public async Task<IActionResult> Cover(string klass, string id, string token)
public async Task<IActionResult> Cover(string klass, string id)
{
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" });
_logger.LogInformation($"Cover found for {id}");
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
var r = await videoService.Cover(klass, id, Token, Ip);
return r ?? _403;
}
[HttpGet("{klass}/{id}/gallery/{pic}")]
public async Task<IActionResult> Gallery(string klass, string id, string pic, string token)
public async Task<IActionResult> Gallery(string klass, string id, string pic)
{
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);
var r = await videoService.Gallery(klass, id, pic, Token, Ip);
return r ?? _403;
}
[HttpGet("{klass}/{id}/subtitle")]
public async Task<IActionResult> Subtitle(string klass, string id)
{
var r = await videoService.Subtitle(klass, id, Token, Ip);
return r ?? _404;
}
[HttpGet("{klass}/{id}/av")]
public async Task<IActionResult> Av(string klass, string id, string token)
public async Task<IActionResult> Av(string klass, string id)
{
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);
var r = await videoService.Av(klass, id, Token, Ip);
return r ?? _403;
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
}

View File

@@ -0,0 +1,18 @@
namespace Abyss.Components.Controllers.Middleware;
public class BadRequestExceptionMiddleware(RequestDelegate next, ILogger<BadRequestExceptionMiddleware> logger)
{
public async System.Threading.Tasks.Task Invoke(HttpContext context)
{
try
{
await next(context);
}
catch (Exception ex)
{
logger.LogError(ex.Message);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync(ex.Message);
}
}
}

View File

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

View File

@@ -0,0 +1,155 @@
using System.Text;
using Abyss.Components.Services;
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Security;
using Abyss.Components.Static;
using Microsoft.AspNetCore.Mvc;
namespace Abyss.Components.Controllers.Security;
[ApiController]
[Route("api/[controller]")]
public class RootController(ILogger<RootController> logger, UserService userService, ResourceService resourceService)
: BaseController
{
[HttpPost("chmod")]
public async Task<IActionResult> Chmod(string path, string permission, string? recursive)
{
logger.LogInformation("Chmod method called with path: {Path}, permission: {Permission}", path, permission);
if (userService.Validate(Token, Ip) != 1)
{
logger.LogInformation("Chmod authorization failed for token: {Token}", Token);
return _401;
}
bool r = await resourceService.Chmod(path, Token, permission, Ip, recursive == "true");
logger.LogInformation("Chmod operation completed with result: {Result}", r);
return r ? Ok() : StatusCode(500);
}
[HttpPost("chown")]
public async Task<IActionResult> Chown(string path, int owner, string? recursive)
{
logger.LogInformation("Chown method called with path: {Path}, owner: {Owner}", path, owner);
if (userService.Validate(Token, Ip) != 1)
{
logger.LogInformation("Chown authorization failed for token: {Token}", Token);
return _401;
}
bool r = await resourceService.Chown(path, Token, owner, Ip, recursive == "true");
logger.LogInformation("Chown operation completed with result: {Result}", r);
return r ? Ok() : StatusCode(502);
}
[HttpGet("ls")]
public async Task<IActionResult> Ls(string path)
{
logger.LogInformation("Ls method called with path: {Path}", path);
if (userService.Validate(Token, Ip) != 1)
{
logger.LogInformation("Ls authorization failed for token: {Token}", Token);
return _401;
}
if (string.IsNullOrWhiteSpace(path))
{
logger.LogInformation("Ls method received empty path parameter");
return _400;
}
try
{
var fullPath = Path.GetFullPath(path);
logger.LogInformation("Resolved full path: {FullPath}", fullPath);
if (!Directory.Exists(fullPath))
{
logger.LogInformation("Directory does not exist: {FullPath}", fullPath);
return _404;
}
var entries = Directory.EnumerateFileSystemEntries(fullPath, "*", SearchOption.TopDirectoryOnly).ToArray();
logger.LogInformation("Found {Count} entries in directory", entries.Count());
var sb = new StringBuilder();
foreach (var entry in entries)
{
try
{
var filename = Path.GetFileName(entry);
var isDir = Directory.Exists(entry);
var ra = await resourceService.GetAttribute(entry);
var ownerId = ra?.Owner ?? -1;
var uid = ra?.Uid ?? string.Empty;
var permRaw = ra?.Permission ?? "--,--,--";
var permStr = ConvertToLsPerms(permRaw, isDir);
sb.AppendLine($"{permStr} {ownerId,5} {uid} {filename}");
}
catch (Exception ex)
{
logger.LogInformation("Error processing entry {Entry}: {ErrorMessage}", entry, ex.Message);
// ignored
}
}
logger.LogInformation("Ls operation completed successfully");
return Content(sb.ToString(), "text/plain; charset=utf-8");
}
catch (Exception ex)
{
logger.LogInformation("Ls operation failed with error: {ErrorMessage}", ex.Message);
return StatusCode(500, "Internal Server Error");
}
}
[HttpPost("init")]
public async Task<IActionResult> Init(string path, int owner)
{
if (userService.Validate(Token, Ip) != 1)
{
logger.LogInformation("Init authorization failed for token: {Token}", Token);
return _401;
}
var r = await resourceService.Initialize(path, Token, owner, Ip);
if (r) return Ok(r);
return _403;
}
public static string ConvertToLsPerms(string permRaw, bool isDirectory)
{
// expects format like "rw,r-,r-"
if (string.IsNullOrEmpty(permRaw))
permRaw = "--,--,--";
var parts = permRaw.Split(',', StringSplitOptions.None);
if (parts.Length != 3)
{
return (isDirectory ? 'd' : '-') + "---------";
}
string MakeTriplet(string token)
{
if (token.Length < 2) token = "--";
var r = token.Length > 0 && token[0] == 'r' ? 'r' : '-';
var w = token.Length > 1 && token[1] == 'w' ? 'w' : '-';
var x = '-'; // we don't manage execute bits in current model
return $"{r}{w}{x}";
}
var owner = MakeTriplet(parts[0]);
var group = MakeTriplet(parts[1]);
var other = MakeTriplet(parts[2]);
return (isDirectory ? 'd' : '-') + owner + group + other;
}
}

View File

@@ -1,9 +1,11 @@
// UserController.cs
using System.Text.RegularExpressions;
using Abyss.Components.Services;
using Abyss.Components.Services.Security;
using Abyss.Components.Static;
using Abyss.Model;
using Abyss.Model.Security;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
@@ -12,96 +14,79 @@ namespace Abyss.Components.Controllers.Security;
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("Fixed")]
public class UserController(UserService user, ILogger<UserController> logger) : Controller
public class UserController(UserService userService) : BaseController
{
private readonly ILogger<UserController> _logger = logger;
private readonly UserService _user = user;
[HttpGet("{user}/announce")]
public async Task<IActionResult> GetAnnounce(int user)
{
var r = userService.GetAnnounce(user);
if (r is not null)
return Ok(r);
return _404;
}
[HttpPost("{user}/announce")]
public async Task<IActionResult> SetAnnounce(int user, [FromBody] string data)
{
var r = userService.SetAnnounce(user, data, Token, Ip);
if (r)
{
return Ok(r);
}
return _403;
}
[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);
var c = await userService.Challenge(user);
return c != null ? Ok(c): _403;
}
[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" });
var r = await userService.Verify(user, response.Response, Ip);
if (r != null)
{
Response.Cookies.Append("token", r);
return Ok(r);
}
return Ok(r);
return _403;
}
[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);
var u = userService.Validate(token, Ip);
return u == -1 ? _401 : Ok(u);
}
[HttpPost("destroy")]
public IActionResult Destroy(string token)
{
var u = _user.Validate(token, Ip);
if (u == null)
var u = userService.Validate(token, Ip);
if (u != -1)
{
return StatusCode(401, new { message = "Invalid" });
userService.Destroy(token);
return Ok("Success");
}
_user.Destroy(token);
return Ok("Success");
return _401;
}
[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]+$");
bool r = await userService.CreateUserAsync(user, creating, Ip);
return r ? Ok("Success") : _403;
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
[HttpGet("{user}/open")]
public async Task<IActionResult> Open(string user, [FromQuery] string token, [FromQuery] string? bindIp = null)
{
string? r = await userService.OpenUserAsync(user, token, bindIp, Ip);
return r != null ? Ok(r) : _403;
}
}

View File

@@ -1,6 +1,9 @@
using Abyss.Components.Services;
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Misc;
using Abyss.Components.Static;
using Abyss.Model;
using Abyss.Model.Media;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -10,21 +13,21 @@ namespace Abyss.Components.Controllers.Task;
[ApiController]
[Route("api/[controller]")]
public class TaskController(ILogger<TaskController> logger, ConfigureService config, TaskService taskService) : Controller
public class TaskController(ConfigureService config, TaskService taskService) : BaseController
{
public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks");
[HttpGet]
public async Task<IActionResult> Query(string token)
public async Task<IActionResult> Query()
{
// If the token is invalid, an empty list will be returned, which is part of the design
return Json(await taskService.Query(token, Ip));
return Json(await taskService.Query(Token, Ip));
}
[HttpPost]
public async Task<IActionResult> Create(string token, [FromBody] TaskCreation creation)
public async Task<IActionResult> Create([FromBody] TaskCreation creation)
{
var r = await taskService.Create(token, Ip, creation);
var r = await taskService.Create(Token, Ip, creation);
if(r == null)
{
return BadRequest();
@@ -32,29 +35,26 @@ public class TaskController(ILogger<TaskController> logger, ConfigureService con
return Ok(JsonConvert.SerializeObject(r, Formatting.Indented));
}
[HttpGet("{id}")]
public async Task<IActionResult> GetTask(string id)
{
throw new NotImplementedException();
}
[HttpPatch("{id}")]
public async Task<IActionResult> PutChip(string id)
{
throw new NotImplementedException();
}
[HttpPost("{id}")]
public async Task<IActionResult> VerifyChip(string id)
{
throw new NotImplementedException();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteTask(string id)
{
throw new NotImplementedException();
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
// [HttpGet("{id}")]
// public async Task<IActionResult> GetTask(string id)
// {
// throw new NotImplementedException();
// }
//
// [HttpPatch("{id}")]
// public async Task<IActionResult> PutChip(string id)
// {
// throw new NotImplementedException();
// }
//
// [HttpPost("{id}")]
// public async Task<IActionResult> VerifyChip(string id)
// {
// throw new NotImplementedException();
// }
// [HttpDelete("{id}")]
// public async Task<IActionResult> DeleteTask(string id)
// {
// throw new NotImplementedException();
// }
}

View File

@@ -0,0 +1,29 @@
using System.Reflection;
using Abyss.Components.Services.Admin.Interfaces;
namespace Abyss.Components.Services.Admin.Attributes;
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public class ModuleAttribute(int head) : Attribute
{
public int Head { get; } = head;
public static Type[] Modules
{
get
{
Assembly assembly = Assembly.GetExecutingAssembly();
Type attributeType = typeof(ModuleAttribute);
const string targetNamespace = "Abyss.Components.Services.Admin.Modules";
var moduleTypes = assembly.GetTypes()
.Where(t => t is { IsClass: true, IsAbstract: false, IsInterface: false })
.Where(t => t.Namespace == targetNamespace)
.Where(t => typeof(IModule).IsAssignableFrom(t))
.Where(t => t.IsDefined(attributeType, inherit: false))
.ToArray();
return moduleTypes;
}
}
}

View File

@@ -0,0 +1,112 @@
using System.Net.Sockets;
using System.Text;
using Abyss.Components.Static;
using Abyss.Model.Admin;
using Newtonsoft.Json;
using System.Reflection;
using Abyss.Components.Services.Admin.Attributes;
using Abyss.Components.Services.Admin.Interfaces;
namespace Abyss.Components.Services.Admin;
public class CtlService(ILogger<CtlService> logger, IServiceProvider serviceProvider) : IHostedService
{
private static readonly string SocketPath = Path.Combine(Path.GetTempPath(), "abyss-ctl.sock");
private Task? _executingTask;
private CancellationTokenSource? _cts;
private Dictionary<int, Type> _handlers = new();
public Task StartAsync(CancellationToken cancellationToken)
{
var t = ModuleAttribute.Modules;
foreach (var module in t)
{
var attr = module.GetCustomAttribute<ModuleAttribute>();
if (attr != null)
{
_handlers[attr.Head] = module;
}
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_executingTask = ExecuteAsync(_cts.Token);
return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_executingTask == null)
return;
try
{
_cts?.CancelAsync();
}
finally
{
await Task.WhenAny(_executingTask,
Task.Delay(Timeout.Infinite, cancellationToken));
}
}
private async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (File.Exists(SocketPath))
{
File.Delete(SocketPath);
}
var endPoint = new UnixDomainSocketEndPoint(SocketPath);
using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
socket.Bind(endPoint);
socket.Listen(5);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var clientSocket = await socket.AcceptAsync(stoppingToken);
_ = HandleClientAsync(clientSocket, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
File.Delete(SocketPath);
}
private async Task HandleClientAsync(Socket clientSocket, CancellationToken stoppingToken)
{
async Task _400()
{
await clientSocket.WriteBase64Async(Ctl.MakeBase64(400, ["Bad Request"]), stoppingToken);
}
try
{
var s = Encoding.UTF8.GetString(
Convert.FromBase64String(await clientSocket.ReadBase64Async(stoppingToken)));
var json = JsonConvert.DeserializeObject<Ctl>(s);
if (json == null || !_handlers.TryGetValue(json.Head, out var handler))
{
await _400();
return;
}
var module = (serviceProvider.GetRequiredService(handler) as IModule)!;
var r = await module.ExecuteAsync(json, stoppingToken);
await clientSocket.WriteBase64Async(Ctl.MakeBase64(r.Head, r.Params), stoppingToken);
}
catch (Exception e)
{
logger.LogError(e, "Error while handling client connection");
}
}
}

View File

@@ -0,0 +1,8 @@
using Abyss.Model.Admin;
namespace Abyss.Components.Services.Admin.Interfaces;
public interface IModule
{
public Task<Ctl> ExecuteAsync(Ctl request, CancellationToken ct);
}

View File

@@ -0,0 +1,108 @@
using Abyss.Components.Services.Admin.Attributes;
using Abyss.Components.Services.Admin.Interfaces;
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Misc;
using Abyss.Components.Static;
using Abyss.Model.Admin;
namespace Abyss.Components.Services.Admin.Modules;
[Module(106)]
public class ChmodModule(
ILogger<ChmodModule> logger,
ConfigureService configureService,
ResourceDatabaseService resourceDatabaseService
) : IModule
{
public async Task<Ctl> ExecuteAsync(Ctl request, CancellationToken ct)
{
// request.Params[0] -> Relative Path
// request.Params[1] -> Permission
// request.Params[2] -> recursive?
var path = Helpers.SafePathCombine(configureService.MediaRoot, [request.Params[0]]);
if (request.Params.Length != 3 || !ResourceDatabaseService.PermissionRegex.IsMatch(request.Params[1]))
return new Ctl
{
Head = 400,
Params = ["Bad Request"]
};
if (!Directory.Exists(path))
return new Ctl
{
Head = 404,
Params = ["Directory not found"]
};
var recursive = request.Params[2] == "True";
List<string> targets = new List<string>();
try
{
if (recursive)
{
logger.LogInformation($"Recursive directory '{path}'.");
targets.Add(path);
foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
{
targets.Add(entry);
}
}
else
{
targets.Add(path);
}
// Build distinct UIDs
var relUids = targets
.Select(t => Path.GetRelativePath(configureService.MediaRoot, t))
.Select(rel => ResourceDatabaseService.Uid(rel))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (relUids.Count == 0)
{
logger.LogWarning($"No targets resolved for chmod on '{path}'");
return new Ctl
{
Head = 304,
Params = ["No targets, Not Modified."]
};
}
// Use DatabaseService to perform chunked updates
var updatedCount = await resourceDatabaseService.UpdatePermissionsByUidsAsync(relUids, request.Params[1] );
if (updatedCount > 0)
{
logger.LogInformation(
$"Chmod: updated permissions for {updatedCount} resource(s) (root='{path}', recursive={recursive})");
return new Ctl
{
Head = 200,
Params = ["Ok", updatedCount.ToString()]
};
}
else
{
logger.LogWarning($"Chmod: no resources updated for '{path}' (recursive={recursive})");
return new Ctl
{
Head = 304,
Params = ["Not Modified."]
};
}
}
catch (Exception ex)
{
logger.LogError(ex, $"Error changing permissions for: {path}");
return new Ctl
{
Head = 500,
Params = ["Error", ex.Message]
};
}
}
}

View File

@@ -0,0 +1,18 @@
using Abyss.Components.Services.Admin.Attributes;
using Abyss.Components.Services.Admin.Interfaces;
using Abyss.Model.Admin;
namespace Abyss.Components.Services.Admin.Modules;
[Module(100)]
public class HelloModule: IModule
{
public async Task<Ctl> ExecuteAsync(Ctl request, CancellationToken ct)
{
return await Task.FromResult(new Ctl
{
Head = 200,
Params = ["Hi"],
});
}
}

View File

@@ -0,0 +1,88 @@
using Abyss.Components.Services.Admin.Attributes;
using Abyss.Components.Services.Admin.Interfaces;
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Misc;
using Abyss.Components.Services.Security;
using Abyss.Components.Static;
using Abyss.Model.Admin;
using Abyss.Model.Media;
namespace Abyss.Components.Services.Admin.Modules;
[Module(105)]
public class IncludeModule(
ILogger<IncludeModule> logger,
UserService userService,
ConfigureService configureService,
ResourceDatabaseService resourceDatabaseService) : IModule
{
public async Task<Ctl> ExecuteAsync(Ctl request, CancellationToken ct)
{
// request.Params[0] -> Relative Path
// request.Params[1] -> Owner Id
// request.Params[2] -> recursive?
var path = Helpers.SafePathCombine(configureService.MediaRoot, [request.Params[0]]);
if (request.Params.Length != 3 || !int.TryParse(request.Params[1], out var id))
return new Ctl
{
Head = 400,
Params = ["Bad Request"]
};
if (await userService.QueryUser(id) == null)
return new Ctl
{
Head = 404,
Params = ["User not found"]
};
if (!Directory.Exists(path))
return new Ctl
{
Head = 404,
Params = ["Directory not found"]
};
var allPaths = request.Params[2] == "True" ?
Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories).Prepend(path)
: [path];
var newResources = new List<ResourceAttribute>();
int c = 0;
foreach (var p in allPaths)
{
var currentPath = Path.GetRelativePath(configureService.MediaRoot, p);
var uid = ResourceDatabaseService.Uid(currentPath);
var existing = await resourceDatabaseService.GetResourceAttributeByUidAsync(uid);
if (existing == null)
{
newResources.Add(new ResourceAttribute
{
Uid = uid,
Owner = id,
Permission = "rw,--,--"
});
}
}
if (newResources.Any())
{
c = await resourceDatabaseService.InsertResourceAttributesAsync(newResources);
logger.LogInformation(
$"Successfully initialized {c} new resources under '{path}' for user '{id}'.");
}
else
{
logger.LogInformation(
$"No new resources to initialize under '{path}'. All items already exist in the database.");
}
return new Ctl
{
Head = 200,
Params = [c.ToString(), "resource(s) add to system"]
};
}
}

View File

@@ -0,0 +1,58 @@
using Abyss.Components.Services.Admin.Attributes;
using Abyss.Components.Services.Admin.Interfaces;
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Misc;
using Abyss.Components.Services.Security;
using Abyss.Model.Admin;
using Abyss.Model.Security;
using NSec.Cryptography;
namespace Abyss.Components.Services.Admin.Modules;
[Module(103)]
public class InitModule(ILogger<InitModule> logger, UserService userService, ConfigureService configureService, ResourceDatabaseService resourceDatabaseService): IModule
{
public async Task<Ctl> ExecuteAsync(Ctl request, CancellationToken ct)
{
bool empty = await userService.IsEmptyUser();
if (!empty)
return new Ctl
{
Head = 403,
Params = ["Access Denied: User list is not empty."]
};
var key = UserService.GenerateKeyPair();
string privateKeyBase64 = Convert.ToBase64String(key.Export(KeyBlobFormat.RawPrivateKey));
string publicKeyBase64 = Convert.ToBase64String(key.Export(KeyBlobFormat.RawPublicKey));
await userService.AddUserAsync(new User
{
Uuid = 1,
Username = "root",
ParentId = 1,
PublicKey = publicKeyBase64,
Privilege = 1145141919,
});
var paths = new string[] { "Tasks", "Live", "Videos", "Images" }
.Select(x => Path.Combine(configureService.MediaRoot, x));
foreach (var path in paths)
{
if(!Directory.Exists(path))
Directory.CreateDirectory(path);
var i = await resourceDatabaseService.InsertRaRow(path, 1, "rw,r-,r-", true);
if (!i)
{
logger.LogError("Could not create resource database");
}
}
return new Ctl
{
Head = 200,
Params = [privateKeyBase64]
};
}
}

View File

@@ -0,0 +1,80 @@
using System.Text;
using Abyss.Components.Controllers.Security;
using Abyss.Components.Services.Admin.Attributes;
using Abyss.Components.Services.Admin.Interfaces;
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Misc;
using Abyss.Components.Static;
using Abyss.Model.Admin;
namespace Abyss.Components.Services.Admin.Modules;
[Module(107)]
public class ListModule(
ILogger<ListModule> logger,
ConfigureService configureService,
ResourceService resourceService
) : IModule
{
public async Task<Ctl> ExecuteAsync(Ctl request, CancellationToken ct)
{
// request.Params[0] -> Relative Path
try
{
var path = Helpers.SafePathCombine(configureService.MediaRoot, [request.Params[0]]);
if (!Directory.Exists(path))
{
logger.LogInformation("Directory does not exist: {FullPath}", path);
return new Ctl
{
Head = 404,
Params = ["Not found"]
};
}
var entries = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly).ToArray();
var sb = new StringBuilder();
foreach (var entry in entries)
{
try
{
var filename = Path.GetFileName(entry);
var isDir = Directory.Exists(entry);
var ra = await resourceService.GetAttribute(entry);
var ownerId = ra?.Owner ?? -1;
var uid = ra?.Uid ?? string.Empty;
var permRaw = ra?.Permission ?? "--,--,--";
var permStr = RootController.ConvertToLsPerms(permRaw, isDir);
sb.AppendLine($"{permStr} {ownerId,5} {uid} {filename}");
}
catch (Exception ex)
{
logger.LogInformation("Error processing entry {Entry}: {ErrorMessage}", entry, ex.Message);
// ignored
}
}
logger.LogInformation("Ls operation completed successfully");
return new Ctl
{
Head = 200,
Params = [sb.ToString()]
};
}
catch (Exception ex)
{
logger.LogInformation("Ls operation failed with error: {ErrorMessage}", ex.Message);
return new Ctl
{
Head = 500,
Params = ["Server Exception", ex.Message]
};
}
}
}

View File

@@ -0,0 +1,50 @@
using Abyss.Components.Services.Admin.Attributes;
using Abyss.Components.Services.Admin.Interfaces;
using Abyss.Components.Services.Security;
using Abyss.Model.Admin;
using Abyss.Model.Security;
using NSec.Cryptography;
namespace Abyss.Components.Services.Admin.Modules;
[Module(104)]
public class UserAddModule(UserService userService): IModule
{
public async Task<Ctl> ExecuteAsync(Ctl request, CancellationToken ct)
{
// request.Params[0] -> Username
// request.Params[1] -> Privilege
if (request.Params.Length != 2 || !UserService.IsAlphanumeric(request.Params[0]) || !int.TryParse(request.Params[1], out var privilege))
return new Ctl
{
Head = 400,
Params = ["Bad Request"]
};
if (await userService.QueryUser(request.Params[0]) != null)
return new Ctl
{
Head = 403,
Params = ["User exists"]
};
var key = UserService.GenerateKeyPair();
string privateKeyBase64 = Convert.ToBase64String(key.Export(KeyBlobFormat.RawPrivateKey));
string publicKeyBase64 = Convert.ToBase64String(key.Export(KeyBlobFormat.RawPublicKey));
await userService.AddUserAsync(new User
{
Username = request.Params[0],
ParentId = 1,
PublicKey = publicKeyBase64,
Privilege = privilege,
});
return new Ctl
{
Head = 200,
Params = [privateKeyBase64]
};
}
}

View File

@@ -0,0 +1,14 @@
using Abyss.Components.Services.Admin.Attributes;
using Abyss.Components.Services.Admin.Interfaces;
using Abyss.Model.Admin;
namespace Abyss.Components.Services.Admin.Modules;
[Module(101)]
public class VersionModule: IModule
{
public async Task<Ctl> ExecuteAsync(Ctl request, CancellationToken ct)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,66 @@
using Abyss.Components.Services.Misc;
using Abyss.Components.Static;
using Abyss.Model.Media;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace Abyss.Components.Services.Media;
public class ComicService(ResourceService rs, ConfigureService config)
{
public readonly string ImageFolder = Path.Combine(config.MediaRoot, "Images");
public async Task<bool> InitAsync(string token, string owner, string ip)
=> await rs.Initialize(ImageFolder, token, owner, ip);
public async Task<string[]?> QueryCollections(string token, string ip)
=> await rs.Query(ImageFolder, token, ip);
public async Task<string?> Query(string id, string token, string ip)
{
var d = Helpers.SafePathCombine(ImageFolder, [id, "summary.json"]);
if(d != null)
return await rs.GetString(d, token, ip);
return null;
}
public async Task<Comic?[]> QueryBulk(string token, string[] id, string ip)
{
var db = id.Select(x => Helpers.SafePathCombine(ImageFolder, [x, "summary.json"])).ToArray();
if (db.Any(x => x == null))
return [];
var sm = await rs.GetAllString(db!, token, ip);
return sm.Select(x => x.Value == null ? null : JsonConvert.DeserializeObject<Comic>(x.Value)).ToArray();
}
public async Task<bool> Bookmark(string id, string token, Bookmark bookmark, string ip)
{
var d = Helpers.SafePathCombine(ImageFolder, [id, "summary.json"]);
if (d == null)
return false;
Comic c = JsonConvert.DeserializeObject<Comic>(await File.ReadAllTextAsync(d))!;
var bookmarkPage = Helpers.SafePathCombine(ImageFolder, [id, bookmark.Page]);
if (File.Exists(bookmarkPage))
{
c.Bookmarks.Add(bookmark);
var o = JsonConvert.SerializeObject(c);
return await rs.UpdateString(d, token, ip, o);
}
return false;
}
public async Task<PhysicalFileResult?> Page(string id, string file, string token, string ip)
{
var d = Helpers.SafePathCombine(ImageFolder, [id, file]);
if (d != null)
{
return await rs.Get(d, token, ip, "image/jpeg");
}
return null;
}
}

View File

@@ -0,0 +1,231 @@
using Abyss.Components.Services.Misc;
using SQLite;
using Index = Abyss.Model.Media.Index;
namespace Abyss.Components.Services.Media;
public class IndexService: IAsyncDisposable
{
private readonly SQLiteAsyncConnection _db;
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
private bool _disposed;
public IndexService(ConfigureService cs)
{
if (string.IsNullOrWhiteSpace(cs.IndexDatabase)) throw new ArgumentNullException(nameof(cs.IndexDatabase));
_db = new SQLiteAsyncConnection(cs.IndexDatabase);
_db.CreateTableAsync<Index>().Wait();
EnsureRootExistsAsync().Wait();
}
private async Task EnsureRootExistsAsync()
{
await _lock.WaitAsync().ConfigureAwait(false);
try
{
// Ensure there is a root node with Id = 1. If it already exists, this does nothing.
// Using INSERT OR IGNORE so that explicit Id insertion will be ignored if existing.
await _db.ExecuteAsync("INSERT OR IGNORE INTO \"Index\" (Id, Type, Reference, Children) VALUES (?, ?, ?, ?)",
1, 0, string.Empty, string.Empty).ConfigureAwait(false);
}
finally
{
_lock.Release();
}
}
public async Task<Index?> GetByIdAsync(int id)
{
await _lock.WaitAsync().ConfigureAwait(false);
try
{
return await _db.FindAsync<Index>(id).ConfigureAwait(false);
}
finally
{
_lock.Release();
}
}
public async Task<Index> InsertNodeAsChildAsync(int parentId, int type, string reference = "")
{
await _lock.WaitAsync().ConfigureAwait(false);
try
{
var parent = await _db.FindAsync<Index>(parentId).ConfigureAwait(false);
if (parent == null) throw new InvalidOperationException($"Parent node {parentId} not found");
var node = new Index
{
Type = type,
Reference = reference,
Children = string.Empty
};
await _db.InsertAsync(node).ConfigureAwait(false);
// Update parent's children
var children = ParseChildren(parent.Children);
if (!children.Contains(node.Id))
{
children.Add(node.Id);
parent.Children = SerializeChildren(children);
await _db.UpdateAsync(parent).ConfigureAwait(false);
}
return node;
}
finally
{
_lock.Release();
}
}
public async Task<bool> DeleteNodeAsync(int id)
{
await _lock.WaitAsync().ConfigureAwait(false);
try
{
var node = await _db.FindAsync<Index>(id).ConfigureAwait(false);
if (node == null) return false;
await _db.DeleteAsync(node).ConfigureAwait(false);
// Remove references from all parents
var all = await _db.Table<Index>().ToListAsync().ConfigureAwait(false);
foreach (var parent in all)
{
var children = ParseChildren(parent.Children);
if (children.Remove(id))
{
parent.Children = SerializeChildren(children);
await _db.UpdateAsync(parent).ConfigureAwait(false);
}
}
return true;
}
finally
{
_lock.Release();
}
}
public async Task UpdateTypeOrReferenceAsync(int id, int? type = null, string? reference = null)
{
await _lock.WaitAsync().ConfigureAwait(false);
try
{
var node = await _db.FindAsync<Index>(id).ConfigureAwait(false) ?? throw new InvalidOperationException($"Node {id} not found");
var changed = false;
if (type.HasValue && node.Type != type.Value)
{
node.Type = type.Value;
changed = true;
}
if (reference != null && node.Reference != reference)
{
node.Reference = reference;
changed = true;
}
if (changed) await _db.UpdateAsync(node).ConfigureAwait(false);
}
finally
{
_lock.Release();
}
}
public async Task AddEdgeAsync(int fromId, int toId)
{
if (fromId == toId) throw new InvalidOperationException("Self-loop not allowed");
await _lock.WaitAsync().ConfigureAwait(false);
try
{
var from = await _db.FindAsync<Index>(fromId).ConfigureAwait(false) ?? throw new InvalidOperationException($"From node {fromId} not found");
_ = await _db.FindAsync<Index>(toId).ConfigureAwait(false) ?? throw new InvalidOperationException($"To node {toId} not found");
var children = ParseChildren(from.Children);
if (!children.Contains(toId))
{
children.Add(toId);
from.Children = SerializeChildren(children);
await _db.UpdateAsync(from).ConfigureAwait(false);
}
}
finally
{
_lock.Release();
}
}
public async Task<bool> RemoveEdgeAsync(int fromId, int toId)
{
await _lock.WaitAsync().ConfigureAwait(false);
try
{
var from = await _db.FindAsync<Index>(fromId).ConfigureAwait(false);
if (from == null) return false;
var children = ParseChildren(from.Children);
var removed = children.Remove(toId);
if (removed)
{
from.Children = SerializeChildren(children);
await _db.UpdateAsync(from).ConfigureAwait(false);
}
return removed;
}
finally
{
_lock.Release();
}
}
public async Task<List<int>> GetChildrenIdsAsync(int id)
{
await _lock.WaitAsync().ConfigureAwait(false);
try
{
var node = await _db.FindAsync<Index>(id).ConfigureAwait(false);
if (node == null) return new List<int>();
return ParseChildren(node.Children);
}
finally
{
_lock.Release();
}
}
private static List<int> ParseChildren(string children)
{
if (string.IsNullOrWhiteSpace(children)) return new List<int>();
return children.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => int.TryParse(s, out _))
.Select(int.Parse)
.ToList();
}
private static string SerializeChildren(List<int> children)
{
if (children.Count == 0) return string.Empty;
var seen = new HashSet<int>();
var ordered = new List<int>();
foreach (var c in children)
{
if (seen.Add(c)) ordered.Add(c);
}
return string.Join(",", ordered);
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_lock.Dispose();
// SQLiteAsyncConnection does not expose a Close method; rely on finalizer if any.
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,230 @@
using System.IO.Hashing;
using System.Text;
using System.Text.RegularExpressions;
using Abyss.Components.Services.Misc;
using Abyss.Components.Static;
using Abyss.Model.Media;
using SQLite;
namespace Abyss.Components.Services.Media;
public class ResourceDatabaseService
{
private readonly ILogger<ResourceDatabaseService> _logger;
private readonly ConfigureService _config;
public readonly SQLiteAsyncConnection ResourceDatabase;
public static readonly Regex PermissionRegex = new("^([r-][w-]),([r-][w-]),([r-][w-])$", RegexOptions.Compiled);
public ResourceDatabaseService(ConfigureService config, ILogger<ResourceDatabaseService> logger)
{
_config = config;
_logger = logger;
ResourceDatabase = new SQLiteAsyncConnection(config.RaDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
ResourceDatabase.CreateTableAsync<ResourceAttribute>().Wait();
}
public async Task<bool> InsertRaRow(string fullPath, int owner, string permission, bool update = false)
{
if (!PermissionRegex.IsMatch(permission))
{
_logger.LogError($"Invalid permission format: {permission}");
return false;
}
var path = Path.GetRelativePath(_config.MediaRoot, fullPath);
var uid = Uid(path);
if (update)
return await ResourceDatabase.InsertOrReplaceAsync(new ResourceAttribute()
{
Uid = uid,
Owner = owner,
Permission = permission,
}) == 1;
else
{
return await ResourceDatabase.InsertAsync(new ResourceAttribute()
{
Uid = uid,
Owner = owner,
Permission = permission,
}) == 1;
}
}
public static string Uid(string path)
{
var b = Encoding.UTF8.GetBytes(path);
var r = XxHash128.Hash(b, 0x11451419);
return Convert.ToBase64String(r);
}
public Task<int> ExecuteAsync(string sql, params object[] args)
=> ResourceDatabase.ExecuteAsync(sql, args);
public Task<List<T>> QueryAsync<T>(string sql, params object[] args) where T : new()
=> ResourceDatabase.QueryAsync<T>(sql, args);
public async Task<ResourceAttribute?> GetResourceAttributeByUidAsync(string uid)
{
return await ResourceDatabase.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
}
public async Task<List<ResourceAttribute>> GetResourceAttributesByUidsAsync(IEnumerable<string> uidsEnumerable)
{
var uids = uidsEnumerable.Where(s => !string.IsNullOrEmpty(s)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var result = new List<ResourceAttribute>();
if (uids.Count == 0) return result;
const int sqliteMaxVariableNumber = 900;
for (int i = 0; i < uids.Count; i += sqliteMaxVariableNumber)
{
var chunk = uids.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})";
try
{
var chunkResult = await ResourceDatabase.QueryAsync<ResourceAttribute>(sql, chunk.Cast<object>().ToArray());
if (chunkResult != null && chunkResult.Any())
result.AddRange(chunkResult);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error querying ResourceAttributes chunk (size {chunk.Count}).");
}
}
return result;
}
public async Task<int> InsertResourceAttributeAsync(ResourceAttribute ra)
{
if (ra == null) throw new ArgumentNullException(nameof(ra));
try
{
return await ResourceDatabase.InsertAsync(ra);
}
catch (Exception ex)
{
_logger.LogError(ex, "InsertResourceAttributeAsync failed.");
return -1;
}
}
public async Task<int> InsertResourceAttributesAsync(IEnumerable<ResourceAttribute> ras)
{
var list = ras.ToList();
if (!list.Any()) return 0;
try
{
return await ResourceDatabase.InsertAllAsync(list);
}
catch (Exception ex)
{
_logger.LogError(ex, "InsertResourceAttributesAsync failed.");
return -1;
}
}
public async Task<int> InsertOrReplaceResourceAttributeAsync(ResourceAttribute ra)
{
if (ra == null) throw new ArgumentNullException(nameof(ra));
try
{
return await ResourceDatabase.InsertOrReplaceAsync(ra);
}
catch (Exception ex)
{
_logger.LogError(ex, "InsertOrReplaceResourceAttributeAsync failed.");
return -1;
}
}
public async Task<int> DeleteByUidAsync(string uid)
{
if (string.IsNullOrEmpty(uid)) return 0;
try
{
var sql = "DELETE FROM ResourceAttributes WHERE Uid = ?";
return await ResourceDatabase.ExecuteAsync(sql, uid);
}
catch (Exception ex)
{
_logger.LogError(ex, $"DeleteByUidAsync failed for uid={uid}");
return -1;
}
}
public async Task<int> UpdatePermissionsByUidsAsync(IEnumerable<string> uids, string permission)
{
var list = uids.Where(s => !string.IsNullOrEmpty(s)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (!list.Any()) return 0;
int updatedCount = 0;
const int sqliteMaxVariableNumber = 900;
for (int i = 0; i < list.Count; i += sqliteMaxVariableNumber)
{
var chunk = list.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
var args = new List<object> { permission };
args.AddRange(chunk);
var sql = $"UPDATE ResourceAttributes SET Permission = ? WHERE Uid IN ({placeholders})";
try
{
var rows = await ResourceDatabase.ExecuteAsync(sql, args.ToArray());
updatedCount += rows;
}
catch (Exception ex)
{
_logger.LogError(ex, $"UpdatePermissionsByUidsAsync chunk failed (size {chunk.Count}).");
}
}
return updatedCount;
}
public async Task<int> UpdateOwnerByUidsAsync(IEnumerable<string> uids, int owner)
{
var list = uids.Where(s => !string.IsNullOrEmpty(s)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (!list.Any()) return 0;
int updatedCount = 0;
const int sqliteMaxVariableNumber = 900;
for (int i = 0; i < list.Count; i += sqliteMaxVariableNumber)
{
var chunk = list.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
var args = new List<object> { owner };
args.AddRange(chunk);
var sql = $"UPDATE ResourceAttributes SET Owner = ? WHERE Uid IN ({placeholders})";
try
{
var rows = await ResourceDatabase.ExecuteAsync(sql, args.ToArray());
updatedCount += rows;
}
catch (Exception ex)
{
_logger.LogError(ex, $"UpdateOwnerByUidsAsync chunk failed (size {chunk.Count}).");
}
}
return updatedCount;
}
public async Task<bool> ExistsUidAsync(string uid)
{
if (string.IsNullOrEmpty(uid)) return false;
try
{
var ra = await GetResourceAttributeByUidAsync(uid);
return ra != null;
}
catch (Exception ex)
{
_logger.LogError(ex, $"ExistsUidAsync failed for uid={uid}");
return false;
}
}
}

View File

@@ -0,0 +1,915 @@
// ResourceService.cs
using Abyss.Components.Services.Misc;
using Abyss.Components.Services.Security;
using Abyss.Components.Static;
using Abyss.Model.Media;
using Abyss.Model.Security;
using Microsoft.AspNetCore.Mvc;
namespace Abyss.Components.Services.Media;
public enum OperationType
{
Read, // Query, Read
Write, // Write, Delete
Security // Chown, Chmod
}
public class ResourceService(
ILogger<ResourceService> logger,
ConfigureService config,
UserService user,
ResourceDatabaseService db)
{
// Create UID only for resources, without considering advanced hash security such as adding salt
private async Task<Dictionary<string, bool>> ValidAny(string[] paths, string token, OperationType type, string ip)
{
var result = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
if (paths.Length == 0)
return result; // empty input -> empty result
// Normalize media root
var mediaRootFull = Path.GetFullPath(config.MediaRoot);
// Prepare normalized full paths and early-check outside-media-root
var fullPaths = new List<string>(paths.Length);
foreach (var p in paths)
{
try
{
var full = Path.GetFullPath(p);
// record normalized path as key
if (!full.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
{
logger.LogError($"Path outside media root or null: {p}");
result[full] = false;
}
else
{
fullPaths.Add(full);
// initialize to false; will set true when all checks pass
result[full] = false;
}
}
catch (Exception ex)
{
// malformed path -> mark false and continue
logger.LogError(ex, $"Invalid path encountered in ValidAny: {p}");
try
{
result[Path.GetFullPath(p)] = false;
}
catch
{
/* ignore */
}
}
}
if (fullPaths.Count == 0)
return result;
// Validate token and user once
int uuid = user.Validate(token, ip);
if (uuid == -1)
{
logger.LogError($"Invalid token: {token}");
// all previously-initialized keys remain false
return result;
}
User? user1 = await user.QueryUser(uuid);
if (user1 == null || user1.Uuid != uuid)
{
logger.LogError($"Verification failed: {token}");
return result;
}
// Build mapping: for each input path -> list of required (uid, op)
// Also build uid -> set of ops needed overall for batching
var pathToReqs = new Dictionary<string, List<(string uid, OperationType op)>>(StringComparer.OrdinalIgnoreCase);
var uidToOps = new Dictionary<string, HashSet<OperationType>>(StringComparer.OrdinalIgnoreCase);
var uidToExampleRelPath = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var full in fullPaths)
{
try
{
// rel path relative to media root for Uid calculation
var rel = Path.GetRelativePath(config.MediaRoot, full);
var parts = rel
.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
StringSplitOptions.RemoveEmptyEntries)
.Where(s => !string.IsNullOrEmpty(s))
.ToArray();
var reqs = new List<(string uid, OperationType op)>();
// parents: each prefix requires Read
for (int i = 0; i < parts.Length - 1; i++)
{
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
var uidDir = ResourceDatabaseService.Uid(subPath);
reqs.Add((uidDir, OperationType.Read));
if (!uidToOps.TryGetValue(uidDir, out var ops))
{
ops = new HashSet<OperationType>();
uidToOps[uidDir] = ops;
uidToExampleRelPath[uidDir] = subPath;
}
ops.Add(OperationType.Read);
}
// resource itself requires requested 'type'
var resourcePath = (parts.Length == 0) ? string.Empty : Path.Combine(parts);
var uidRes = ResourceDatabaseService.Uid(resourcePath);
reqs.Add((uidRes, type));
if (!uidToOps.TryGetValue(uidRes, out var resOps))
{
resOps = new HashSet<OperationType>();
uidToOps[uidRes] = resOps;
uidToExampleRelPath[uidRes] = resourcePath;
}
resOps.Add(type);
pathToReqs[full] = reqs;
}
catch (Exception ex)
{
logger.LogError(ex, $"Error building requirements for path '{full}' in ValidAny.");
// leave result[full] as false
}
}
// Batch query DB for all UIDs (via DatabaseService)
var uidsNeeded = uidToOps.Keys.ToList();
var rasList = new List<ResourceAttribute>();
if (uidsNeeded.Count > 0)
{
rasList = await db.GetResourceAttributesByUidsAsync(uidsNeeded);
}
var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
// Check each uid+op once and cache results
var permCache = new Dictionary<(string uid, OperationType op), bool>();
foreach (var kv in uidToOps)
{
var uid = kv.Key;
var ops = kv.Value;
if (!raDict.TryGetValue(uid, out var ra))
{
// missing resource attribute -> all ops for this uid are false
foreach (var op in ops)
{
permCache[(uid, op)] = false;
var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid);
logger.LogDebug($"ValidAny: missing ResourceAttribute for Uid={uid}, example='{examplePath}'");
}
continue;
}
foreach (var op in ops)
{
var key = (uid, op);
if (!permCache.TryGetValue(key, out var ok))
{
ok = await CheckPermission(user1, ra, op);
permCache[key] = ok;
}
}
}
// Compose results per original path
foreach (var kv in pathToReqs)
{
var full = kv.Key;
var reqs = kv.Value;
bool allOk = true;
foreach (var (uid, op) in reqs)
{
if (!permCache.TryGetValue((uid, op), out var ok) || !ok)
{
allOk = false;
break;
}
}
result[full] = allOk;
}
return result;
}
private async Task<bool> ValidAll(string[] paths, string token, OperationType type, string ip)
{
if (paths.Length == 0)
{
logger.LogError("ValidAll called with empty path set");
return false;
}
var mediaRootFull = Path.GetFullPath(config.MediaRoot);
// 1. basic path checks & normalize to relative
var relPaths = new List<string>(paths.Length);
foreach (var p in paths)
{
if (!p.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
{
logger.LogError($"Path outside media root or null: {p}");
return false;
}
relPaths.Add(Path.GetRelativePath(config.MediaRoot, Path.GetFullPath(p)));
}
// 2. validate token and user once
int uuid = user.Validate(token, ip);
if (uuid == -1)
{
logger.LogError($"Invalid token: {token}");
return false;
}
User? user1 = await user.QueryUser(uuid);
if (user1 == null || user1.Uuid != uuid)
{
logger.LogError($"Verification failed: {token}");
return false;
}
// 3. build uid -> required ops map (avoid duplicate Uid calculations)
var uidToOps = new Dictionary<string, HashSet<OperationType>>(StringComparer.OrdinalIgnoreCase);
var uidToExampleRelPath =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // for better logging
foreach (var rel in relPaths)
{
var parts = rel
.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
StringSplitOptions.RemoveEmptyEntries)
.Where(s => !string.IsNullOrEmpty(s))
.ToArray();
// parents (each prefix) require Read
for (int i = 0; i < parts.Length - 1; i++)
{
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
var uidDir = ResourceDatabaseService.Uid(subPath);
if (!uidToOps.TryGetValue(uidDir, out var ops))
{
ops = new HashSet<OperationType>();
uidToOps[uidDir] = ops;
uidToExampleRelPath[uidDir] = subPath;
}
ops.Add(OperationType.Read);
}
// resource itself requires requested 'type'
var resourcePath = (parts.Length == 0) ? string.Empty : Path.Combine(parts);
var uidRes = ResourceDatabaseService.Uid(resourcePath);
if (!uidToOps.TryGetValue(uidRes, out var resOps))
{
resOps = new HashSet<OperationType>();
uidToOps[uidRes] = resOps;
uidToExampleRelPath[uidRes] = resourcePath;
}
resOps.Add(type);
}
// 4. batch query DB for all UIDs using DatabaseService
var uidsNeeded = uidToOps.Keys.ToList();
var rasList = new List<ResourceAttribute>();
if (uidsNeeded.Count > 0)
{
rasList = await db.GetResourceAttributesByUidsAsync(uidsNeeded);
}
var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
// 5. check each uid once per required operation (cache results per uid+op)
var permCache = new Dictionary<(string uid, OperationType op), bool>(); // avoid repeated CheckPermission
foreach (var kv in uidToOps)
{
var uid = kv.Key;
if (!raDict.TryGetValue(uid, out var ra))
{
var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid);
logger.LogError(
$"Permission check failed (missing resource attribute): User: {uuid}, Resource: {examplePath}, Uid: {uid}");
return false;
}
foreach (var op in kv.Value)
{
var key = (uid, op);
if (!permCache.TryGetValue(key, out var ok))
{
ok = await CheckPermission(user1, ra, op);
permCache[key] = ok;
}
if (!ok)
{
var examplePath = uidToExampleRelPath.TryGetValue(uid, out var p) ? p : uid;
logger.LogError(
$"Permission check failed: User: {uuid}, Resource: {examplePath}, Uid: {uid}, Type: {op}");
return false;
}
}
}
return true;
}
private 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);
int uuid = user.Validate(token, ip);
if (uuid == -1)
{
// No permission granted for invalid tokens
logger.LogError($"Invalid token: {token}");
return false;
}
User? user1 = await user.QueryUser(uuid);
if (user1 == null || user1.Uuid != uuid)
{
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 = ResourceDatabaseService.Uid(subPath);
var raDir = await db.GetResourceAttributeByUidAsync(uidDir);
if (raDir == null)
{
logger.LogError($"Permission denied: {uuid} has no read access to parent directory {subPath}");
return false;
}
if (!await CheckPermission(user1, raDir, OperationType.Read))
{
logger.LogError($"Permission denied: {uuid} has no read access to parent directory {subPath}");
return false;
}
}
var uid = ResourceDatabaseService.Uid(path);
ResourceAttribute? ra = await db.GetResourceAttributeByUidAsync(uid);
if (ra == null)
{
logger.LogError($"Permission check failed: User: {uuid}, Resource: {path}, Type: {type.ToString()} ");
return false;
}
var l = await CheckPermission(user1, ra, type);
if (!l)
{
logger.LogError($"Permission check failed: User: {uuid}, Resource: {path}, Type: {type.ToString()} ");
}
return l;
}
private async Task<bool> CheckPermission(User? user1, ResourceAttribute? ra, OperationType type)
{
if (user1 == null || ra == null) return false;
if (!ResourceDatabaseService.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 == user1.Uuid;
bool isPeer = !isOwner && user1.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') || (user1.Privilege > owner.Privilege);
case OperationType.Write:
return currentPerm.Contains('w') || (user1.Privilege > owner.Privilege);
case OperationType.Security:
return (isOwner && currentPerm.Contains('w')) || user1.Uuid == 1;
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;
try
{
var entries = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly).ToArray();
if (entries.Length == 0)
return Array.Empty<string>();
var validMap = await ValidAny(entries, token, OperationType.Read, ip);
var allowed = new List<string>(entries.Length);
foreach (var entry in entries)
{
try
{
var full = Path.GetFullPath(entry);
if (validMap.TryGetValue(full, out var ok) && ok)
{
allowed.Add(Path.GetRelativePath(path, entry));
}
else
{
logger.LogDebug(
$"Query: access denied or not managed for '{entry}' (user token: {token}) - item skipped.");
}
}
catch (Exception exEntry)
{
logger.LogError(exEntry, $"Error processing entry '{entry}' in Query.");
}
}
return allowed.ToArray();
}
catch (Exception ex)
{
logger.LogError(ex, $"Error while listing directory '{path}' in Query.");
return null;
}
}
public async Task<PhysicalFileResult?> Get(string path, string token, string ip, string contentType)
{
var b = await Valid(path, token, OperationType.Read, ip);
if (b) return new PhysicalFileResult(path, contentType)
{
EnableRangeProcessing = true
};
return null;
}
public async Task<string?> GetString(string path, string token, string ip)
{
var b = await Valid(path, token, OperationType.Read, ip);
if (b)
{
return await File.ReadAllTextAsync(path);
}
return null;
}
public async Task<Dictionary<string, string?>> GetAllString(string[] paths, string token, string ip)
{
Dictionary<string, string?> result = new();
var validMap = await ValidAny(paths, token, OperationType.Read, ip);
foreach (var entry in validMap)
{
if (entry.Value)
{
result[entry.Key] = await File.ReadAllTextAsync(entry.Key);
}
}
return result;
}
public async Task<bool> UpdateString(string path, string token, string ip, string content)
{
var b = await Valid(path, token, OperationType.Write, ip);
if (b)
{
await File.WriteAllTextAsync(path, content);
return true;
}
return false;
}
public async Task<bool> Initialize(string path, string token, string owner, string ip)
{
var u = await user.QueryUser(owner);
if (u == null || u.Uuid == -1) return false;
return await Initialize(path, token, u.Uuid!.Value, ip);
}
public async Task<bool> Initialize(string path, string token, int owner, string ip)
{
// TODO: Use a more elegant Debug mode
if (config.DebugMode == "Debug")
goto debug;
// 1. Authorization: Verify the operation is performed by 'root'
var requester = user.Validate(token, ip);
if (requester != 1)
{
logger.LogWarning(
$"Permission denied: Non-root user '{requester}' attempted to initialize resources.");
return false;
}
debug:
// 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(owner);
if (ownerUser == null)
{
logger.LogError($"Initialization failed: Owner user '{owner}' 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 = ResourceDatabaseService.Uid(currentPath);
var existing = await db.GetResourceAttributeByUidAsync(uid);
// If it's not in the database, add it to our list for batch insertion
if (existing == null)
{
newResources.Add(new ResourceAttribute
{
Uid = uid,
Owner = owner,
Permission = "rw,--,--"
});
}
}
// 5. Database Insertion: Add all new resources in a single, efficient transaction
if (newResources.Any())
{
await db.InsertResourceAttributesAsync(newResources);
logger.LogInformation(
$"Successfully initialized {newResources.Count} new resources under '{path}' for user '{owner}'.");
}
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> Exclude(string path, string token, string ip)
{
var requester = user.Validate(token, ip);
if (requester != 1)
{
logger.LogWarning(
$"Permission denied: Non-root user '{requester}' attempted to exclude resource '{path}'.");
return false;
}
try
{
var relPath = Path.GetRelativePath(config.MediaRoot, path);
var uid = ResourceDatabaseService.Uid(relPath);
var resource = await db.GetResourceAttributeByUidAsync(uid);
if (resource == null)
{
logger.LogError($"Exclude failed: Resource '{relPath}' not found in database.");
return false;
}
var deleted = await db.DeleteByUidAsync(uid);
if (deleted > 0)
{
logger.LogInformation($"Successfully excluded resource '{relPath}' from management.");
return true;
}
else
{
logger.LogError($"Failed to exclude resource '{relPath}' from database.");
return false;
}
}
catch (Exception ex)
{
logger.LogError(ex, $"Error excluding resource '{path}'.");
return false;
}
}
public async Task<bool> Include(string path, string token, string ip, int owner, string permission)
{
var requester = user.Validate(token, ip);
if (requester != 1)
{
logger.LogWarning(
$"Permission denied: Non-root user '{requester}' attempted to include resource '{path}'.");
return false;
}
if (!ResourceDatabaseService.PermissionRegex.IsMatch(permission))
{
logger.LogError($"Invalid permission format: {permission}");
return false;
}
var ownerUser = await user.QueryUser(owner);
if (ownerUser == null)
{
logger.LogError($"Include failed: Owner user '{owner}' does not exist.");
return false;
}
try
{
var relPath = Path.GetRelativePath(config.MediaRoot, path);
var uid = ResourceDatabaseService.Uid(relPath);
var existing = await db.GetResourceAttributeByUidAsync(uid);
if (existing != null)
{
logger.LogError($"Include failed: Resource '{relPath}' already exists in database.");
return false;
}
var newResource = new ResourceAttribute
{
Uid = uid,
Owner = owner,
Permission = permission
};
var inserted = await db.InsertResourceAttributeAsync(newResource);
if (inserted > 0)
{
logger.LogInformation(
$"Successfully included '{relPath}' into resource management (Owner={owner}, Permission={permission}).");
return true;
}
else
{
logger.LogError($"Failed to include resource '{relPath}' into database.");
return false;
}
}
catch (Exception ex)
{
logger.LogError(ex, $"Error including resource '{path}'.");
return false;
}
}
public async Task<bool> Exists(string path)
{
try
{
var relPath = Path.GetRelativePath(config.MediaRoot, path);
var uid = ResourceDatabaseService.Uid(relPath);
return await db.ExistsUidAsync(uid);
}
catch (Exception ex)
{
logger.LogError(ex, $"Error checking existence of resource '{path}'.");
return false;
}
}
public async Task<bool> Chmod(string path, string token, string permission, string ip, bool recursive = false)
{
// Validate permission format first
if (!ResourceDatabaseService.PermissionRegex.IsMatch(permission))
{
logger.LogError($"Invalid permission format: {permission}");
return false;
}
// Normalize path to full path
path = Path.GetFullPath(path);
// Collect targets and permission checks
List<string> targets = new List<string>();
try
{
if (recursive && Directory.Exists(path))
{
logger.LogInformation($"Recursive directory '{path}'.");
targets.Add(path);
foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
{
targets.Add(entry);
}
if (!await ValidAll(targets.ToArray(), token, OperationType.Security, ip))
{
logger.LogWarning($"Permission denied for recursive chmod on '{path}'");
return false;
}
logger.LogInformation($"Successfully validated chmod on '{path}'.");
}
else
{
if (!await Valid(path, token, OperationType.Security, ip))
{
logger.LogWarning($"Permission denied for chmod on '{path}'");
return false;
}
targets.Add(path);
}
// Build distinct UIDs
var relUids = targets
.Select(t => Path.GetRelativePath(config.MediaRoot, t))
.Select(rel => ResourceDatabaseService.Uid(rel))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (relUids.Count == 0)
{
logger.LogWarning($"No targets resolved for chmod on '{path}'");
return false;
}
// Use DatabaseService to perform chunked updates
var updatedCount = await db.UpdatePermissionsByUidsAsync(relUids, permission);
if (updatedCount > 0)
{
logger.LogInformation(
$"Chmod: updated permissions for {updatedCount} resource(s) (root='{path}', recursive={recursive})");
return true;
}
else
{
logger.LogWarning($"Chmod: no resources updated for '{path}' (recursive={recursive})");
return false;
}
}
catch (Exception ex)
{
logger.LogError(ex, $"Error changing permissions for: {path}");
return false;
}
}
public async Task<bool> Chown(string path, string token, int owner, string ip, bool recursive = false)
{
// Validate new owner exists
var newOwner = await user.QueryUser(owner);
if (newOwner == null)
{
logger.LogError($"New owner '{owner}' does not exist");
return false;
}
// Normalize
path = Path.GetFullPath(path);
// Permission checks and target collection
List<string> targets = new List<string>();
try
{
if (recursive && Directory.Exists(path))
{
targets.Add(path);
foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
{
targets.Add(entry);
}
if (!await ValidAll(targets.ToArray(), token, OperationType.Security, ip))
{
logger.LogWarning($"Permission denied for recursive chown on '{path}'");
return false;
}
}
else
{
if (!await Valid(path, token, OperationType.Security, ip))
{
logger.LogWarning($"Permission denied for chown on '{path}'");
return false;
}
targets.Add(path);
}
// Build distinct UIDs
var relUids = targets
.Select(t => Path.GetRelativePath(config.MediaRoot, t))
.Select(rel => ResourceDatabaseService.Uid(rel))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (relUids.Count == 0)
{
logger.LogWarning($"No targets resolved for chown on '{path}'");
return false;
}
// Use DatabaseService to perform chunked owner updates
var updatedCount = await db.UpdateOwnerByUidsAsync(relUids, owner);
if (updatedCount > 0)
{
logger.LogInformation(
$"Chown: changed owner for {updatedCount} resource(s) (root='{path}', recursive={recursive})");
return true;
}
else
{
logger.LogWarning($"Chown: no resources updated for '{path}' (recursive={recursive})");
return false;
}
}
catch (Exception ex)
{
logger.LogError(ex, $"Error changing ownership for: {path}");
return false;
}
}
public async Task<ResourceAttribute?> GetAttribute(string path)
{
try
{
// normalize to full path
var full = Path.GetFullPath(path);
// ensure it's under media root
var mediaRootFull = Path.GetFullPath(config.MediaRoot);
if (!full.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
return null;
var rel = Path.GetRelativePath(config.MediaRoot, full);
var uid = ResourceDatabaseService.Uid(rel);
var ra = await db.GetResourceAttributeByUidAsync(uid);
return ra;
}
catch (Exception ex)
{
logger.LogError(ex, $"GetAttribute failed for path '{path}'");
return null;
}
}
}

View File

@@ -1,14 +1,16 @@
using Abyss.Components.Services.Misc;
using Abyss.Components.Services.Security;
using Abyss.Components.Static;
using Abyss.Model;
using Abyss.Model.Media;
using Newtonsoft.Json;
using SQLite;
using Task = Abyss.Model.Task;
using Task = Abyss.Model.Media.Task;
namespace Abyss.Components.Services;
namespace Abyss.Components.Services.Media;
public class TaskService(ILogger<TaskService> logger, ConfigureService config, ResourceService rs, UserService user)
public class TaskService(ConfigureService config, ResourceService rs, UserService user)
{
public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks");
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
@@ -24,7 +26,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
foreach (var i in r ?? [])
{
var p = Helpers.SafePathCombine(TaskFolder, [i, "task.json"]);
var c = JsonConvert.DeserializeObject<Model.Task>(await System.IO.File.ReadAllTextAsync(p ?? ""));
var c = JsonConvert.DeserializeObject<Task>(await File.ReadAllTextAsync(p ?? ""));
if(c?.Owner == u) s.Add(i);
}
@@ -48,7 +50,8 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
switch ((TaskType)creation.Type)
{
case TaskType.Image:
return await CreateImageTask(token, ip, creation);
throw new NotImplementedException();
// return await CreateImageTask(token, ip, creation);
case TaskType.Video:
return await CreateVideoTask(token, ip, creation);
default:
@@ -58,10 +61,8 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
private async Task<TaskCreationResponse?> CreateVideoTask(string token, string ip, TaskCreation creation)
{
if(!await rs.Valid(VideoFolder, token, OperationType.Write, ip))
return null;
var u = user.Validate(token, ip);
if(u == null)
if(u == -1)
return null;
var r = new TaskCreationResponse()
@@ -74,7 +75,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
Directory.CreateDirectory(Path.Combine(TaskFolder, r.Id.ToString(), "gallery"));
// It shouldn't be a problem to spell it directly like this, as all the parameters are generated by myself
Task v = new Task()
Task v = new Task
{
Name = creation.Name,
Owner = u,
@@ -83,7 +84,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
Type = TaskType.Video
};
await System.IO.File.WriteAllTextAsync(
await File.WriteAllTextAsync(
Path.Combine(TaskFolder, r.Id.ToString(), "task.json"),
JsonConvert.SerializeObject(v, Formatting.Indented));
@@ -105,10 +106,10 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
return r;
}
private async Task<TaskCreationResponse?> CreateImageTask(string token, string ip, TaskCreation creation)
{
throw new NotImplementedException();
}
// private async Task<TaskCreationResponse?> CreateImageTask(string token, string ip, TaskCreation creation)
// {
// throw new NotImplementedException();
// }
public static uint GenerateUniqueId(string parentDirectory)
{
@@ -239,7 +240,7 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
return -1;
}
}
catch (Exception ex)
catch (Exception)
{
return -1;
}

View File

@@ -0,0 +1,163 @@
using Abyss.Components.Services.Misc;
using Abyss.Components.Static;
using Abyss.Model.Media;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Newtonsoft.Json;
namespace Abyss.Components.Services.Media;
public class VideoService(ResourceService rs, ConfigureService config)
{
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
public async Task<bool> Init(string token, string owner, string ip)
=> await rs.Initialize(VideoFolder, token, owner, ip);
public async Task<string[]?> GetClasses(string token, string ip)
=> (await rs.Query(VideoFolder, token, ip))?.SortLikeWindows();
public async Task<string[]?> QueryClass(string klass, string token, string ip)
{
var d = Helpers.SafePathCombine(VideoFolder, klass);
if (d != null)
{
return await rs.Query(d, token, ip);
}
return null;
}
public async Task<string?> QueryVideo(string klass, string id, string token, string ip)
{
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "summary.json"]);
if(d != null)
return await rs.GetString(d, token, ip);
return null;
}
public async Task<Video?[]> QueryBulk(string klass, string[] id, string token, string ip)
{
var db = id.Select(x => Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"])).ToArray();
if (db.Any(x => x == null))
return [];
var sm = await rs.GetAllString(db!, token, ip);
return sm.Select(x => x.Value == null ? null : JsonConvert.DeserializeObject<Video>(x.Value)).ToArray();
}
public async Task<PhysicalFileResult?> Cover(string klass, string id, string token, string ip)
{
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "cover.jpg"]);
if (d != null)
{
return await rs.Get(d, token, ip, "image/jpeg");
}
return null;
}
public async Task<PhysicalFileResult?> Gallery(string klass, string id, string pic, string token, string ip)
{
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "gallery", pic]);
if (d != null)
{
return await rs.Get(d, token, ip, "image/jpeg");
}
return null;
}
public async Task<PhysicalFileResult?> Subtitle(string klass, string id, string token, string ip)
{
var folder = Helpers.SafePathCombine(VideoFolder, new[] { klass, id });
if (folder == null)
return null;
string? subtitlePath;
try
{
var preferredVtt = Path.Combine(folder, "subtitle.vtt");
if (File.Exists(preferredVtt))
{
subtitlePath = preferredVtt;
}
else
{
subtitlePath = Directory.EnumerateFiles(folder, "*.vtt").FirstOrDefault();
if (subtitlePath == null)
{
var preferredAss = Path.Combine(folder, "subtitle.ass");
if (File.Exists(preferredAss))
{
subtitlePath = preferredAss;
}
else
{
subtitlePath = Directory.EnumerateFiles(folder, "*.ass").FirstOrDefault();
}
}
}
}
catch (DirectoryNotFoundException)
{
return null;
}
if (subtitlePath == null)
return null;
var ext = Path.GetExtension(subtitlePath).ToLowerInvariant();
var contentType = ext switch
{
".vtt" => "text/vtt",
".ass" => "text/x-ssa",
_ => "text/plain"
};
return await rs.Get(subtitlePath, token, ip, contentType);
}
public async Task<PhysicalFileResult?> Av(string klass, string id, string token, string ip)
{
var folder = Helpers.SafePathCombine(VideoFolder, new[] { klass, id });
if (folder == null)
return null;
var allowedExt = new[] { ".mp4", ".mkv", ".webm", ".mov", ".ogg" };
string? videoPath = null;
foreach (var ext in allowedExt)
{
var p = Path.Combine(folder, "video" + ext);
if (File.Exists(p))
{
videoPath = p;
break;
}
}
if (videoPath == null)
return null;
var provider = new FileExtensionContentTypeProvider();
if (!provider.TryGetContentType(videoPath, out var contentType))
{
var ext = Path.GetExtension(videoPath).ToLowerInvariant();
contentType = ext switch
{
".mkv" => "video/x-matroska",
".mp4" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
".ogg" => "video/ogg",
_ => "application/octet-stream",
};
}
return await rs.Get(videoPath, token, ip, contentType);
}
}

View File

@@ -1,10 +1,12 @@
namespace Abyss.Components.Services;
namespace Abyss.Components.Services.Misc;
public class ConfigureService
{
public string MediaRoot { get; set; } = Environment.GetEnvironmentVariable("MEDIA_ROOT") ?? "/opt";
public string DebugMode { get; set; } = Environment.GetEnvironmentVariable("DEBUG_MODE") ?? "Production";
public string AllowedPorts { get; set; } = Environment.GetEnvironmentVariable("ALLOWED_PORTS") ?? "443"; // Split with ' '
public string Version { get; } = "Alpha v0.1";
public string UserDatabase { get; set; } = "user.db";
public string RaDatabase { get; set; } = "ra.db";
public string IndexDatabase { get; set; } = "index.db";
}

View File

@@ -1,381 +0,0 @@
// 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(@"^([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();
var tasksPath = Helpers.SafePathCombine(_config.MediaRoot, "Tasks");
if(tasksPath != null)
InsertRaRow(tasksPath, "root", "rw,r-,r-", true).Wait();
}
// Create UID only for resources, without considering advanced hash security such as adding salt
private static 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> Update(string path, string token, string ip)
{
return await Valid(path, token, OperationType.Write, ip);
}
public async Task<bool> Initialize(string path, string token, string username, string ip)
{
// TODO: Use a more elegant Debug mode
if (_config.DebugMode == "Debug")
goto debug;
// 1. Authorization: Verify the operation is performed by 'root'
var requester = _user.Validate(token, ip);
if (requester != "root")
{
_logger.LogWarning($"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to initialize resources.");
return false;
}
debug:
// 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;
}
}
private async Task<bool> InsertRaRow(string fullPath, string owner, string permission, bool update = false)
{
if (!PermissionRegex.IsMatch(permission))
{
_logger.LogError($"Invalid permission format: {permission}");
return false;
}
var path = Path.GetRelativePath(_config.MediaRoot, fullPath);
if (update)
return await _database.InsertOrReplaceAsync(new ResourceAttribute()
{
Uid = Uid(path),
Name = path,
Owner = owner,
Permission = permission,
}) == 1;
else
{
return await _database.InsertAsync(new ResourceAttribute()
{
Uid = Uid(path),
Name = path,
Owner = owner,
Permission = permission,
}) == 1;
}
}
}

View File

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

View File

@@ -0,0 +1,293 @@
// UserService.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Abyss.Components.Services.Misc;
using Abyss.Model.Security;
using Microsoft.Extensions.Caching.Memory;
using NSec.Cryptography;
using SQLite;
using Task = System.Threading.Tasks.Task;
namespace Abyss.Components.Services.Security;
public class UserService
{
private readonly ILogger<UserService> _logger;
private readonly IMemoryCache _cache;
private readonly SQLiteAsyncConnection _database;
private readonly Dictionary<int, string> _userAnnounces = new();
public UserService(ILogger<UserService> logger, ConfigureService config, IMemoryCache cache)
{
_logger = logger;
_cache = cache;
_database = new SQLiteAsyncConnection(config.UserDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
_database.CreateTableAsync<User>().Wait();
if (config.DebugMode == "Debug")
_cache.Set("abyss", $"1@127.0.0.1", DateTimeOffset.Now.AddHours(1));
// Test token, can only be used locally. Will be destroyed in one hour.
}
public string? GetAnnounce(int id)
{
return _userAnnounces.GetValueOrDefault(id);
}
public bool SetAnnounce(int id, string? value, string token, string ip)
{
if (Validate(token, ip) == -1)
{
return false;
}
if (value == null)
{
_userAnnounces.Remove(id);
return true;
}
_userAnnounces[id] = value;
return true;
}
public async Task<bool> IsEmptyUser()
{
return await _database.Table<User>().CountAsync() == 0;
}
public async Task<string?> OpenUserAsync(string user, string token, string? bindIp, string ip)
{
var caller = Validate(token, ip);
if (caller != 1)
{
return null;
}
var target = await QueryUser(user);
if (target == null)
{
return null;
}
var ipToBind = string.IsNullOrWhiteSpace(bindIp) ? ip : bindIp;
var t = CreateToken(target.Uuid!.Value, ipToBind, TimeSpan.FromHours(1));
_logger.LogInformation("Root created 1h token for {User}, bound to {BindIp}, request from {ReqIp}", user,
ipToBind, ip);
return t;
}
public async Task<bool> CreateUserAsync(string user, UserCreating creating, string ip)
{
// Valid token
var r = await Verify(user, creating.Response, ip);
if (r == null)
return false;
// User exists ?
var cu = await QueryUser(creating.Name);
if (cu != null)
return false;
// Valid username string
if (!IsAlphanumeric(creating.Name))
return false;
// Valid parent && Privilege
var ou = await QueryUser(Validate(r, ip));
if (creating.Privilege > ou?.Privilege || ou == null)
return false;
await AddUserAsync(new User
{
Username = creating.Name,
ParentId = ou.Uuid!.Value,
Privilege = creating.Privilege,
PublicKey = creating.PublicKey,
});
Destroy(r);
return true;
}
public async Task<string?> Challenge(string user)
{
var u = await _database.Table<User>().Where(x => x.Username == user).FirstOrDefaultAsync();
if (u == null) // Error: User not exists
return null;
if (_cache.TryGetValue(u.Uuid!.Value, out _)) // The previous challenge has not yet expired
_cache.Remove(u.Uuid);
var c = Convert.ToBase64String(Encoding.UTF8.GetBytes(GenerateRandomAsciiString(32)));
_cache.Set(u.Uuid, 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.Username == user).FirstOrDefaultAsync();
if (u == null) // Error: User not exists
{
return null;
}
if (_cache.TryGetValue(u.Uuid!.Value, 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.Uuid, $"failed : {GenerateRandomAsciiString(32)}", DateTimeOffset.Now.AddMinutes(1));
return null;
}
else
{
// Remove the challenge string and create a session
_cache.Remove(u.Uuid);
var s = GenerateRandomAsciiString(64);
_cache.Set(s, $"{u.Uuid}@{ip}", DateTimeOffset.Now.AddDays(1));
_logger.LogInformation($"Verified {u.Uuid}@{ip}, Name: {u.Username}");
return s;
}
}
return null;
}
// Id >= 1 : Success, Uid
// Id == -1: Failed
public int Validate(string token, string ip)
{
if (_cache.TryGetValue(token, out string? userAndIp))
{
if (ip != userAndIp?.Split('@')[1] && ip != "127.0.0.1" && token != "abyss")
{
_logger.LogError($"Token used from another Host: {token}");
Destroy(token);
return -1;
}
// _logger.LogInformation($"Validated {userAndIp}");
return Convert.ToInt32(userAndIp?.Split('@')[0]);
}
_logger.LogWarning($"Validation failed {token}");
return -1;
}
public void Destroy(string token)
{
_cache.Remove(token);
}
public async Task<User?> QueryUser(int uid)
{
if (uid == -1)
return null;
var u = await _database.Table<User>().Where(x => x.Uuid == uid).FirstOrDefaultAsync();
return u;
}
public async Task<User?> QueryUser(string username)
{
var u = await _database.Table<User>().Where(x => x.Username == username).FirstOrDefaultAsync();
return u;
}
public async Task AddUserAsync(User user)
{
await _database.InsertAsync(user);
_logger.LogInformation($"Created user: {user.Username}, Uid: {user.Uuid}, Parent: {user.ParentId}, Privilege: {user.Privilege}");
}
public 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);
}
}
public static bool VerifySignature(PublicKey publicKey, byte[] data, byte[] signature)
{
var algorithm = SignatureAlgorithm.Ed25519;
return algorithm.Verify(publicKey, data, signature);
}
public async Task<bool> VerifyAny(byte[] data, byte[] signature)
{
var users = await _database.Table<User>().ToListAsync();
foreach (var u in users)
{
try
{
var pubKeyBytes = Convert.FromBase64String(u.PublicKey);
var pubKey = PublicKey.Import(
SignatureAlgorithm.Ed25519,
pubKeyBytes,
KeyBlobFormat.RawPublicKey);
if (VerifySignature(pubKey, data, signature))
{
_logger.LogInformation($"Signature verified using user {u.Username}");
return true;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, $"Failed to import public key for {u.Username}");
}
}
return false;
}
public string CreateToken(int uid, string ip, TimeSpan lifetime)
{
var token = GenerateRandomAsciiString(64);
_cache.Set(token, $"{uid}@{ip}", DateTimeOffset.Now.Add(lifetime));
_logger.LogInformation($"Created token for {uid}@{ip}, valid {lifetime.TotalMinutes} minutes");
return token;
}
public static bool IsAlphanumeric(string input)
{
if (string.IsNullOrEmpty(input))
return false;
return Regex.IsMatch(input, @"^[a-zA-Z0-9]+$");
}
}

View File

@@ -1,182 +0,0 @@
// UserService.cs
using System.Security.Cryptography;
using System.Text;
using Abyss.Model;
using Microsoft.Extensions.Caching.Memory;
using NSec.Cryptography;
using SQLite;
using Task = System.Threading.Tasks.Task;
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 (_config.DebugMode == "Debug")
_cache.Set("root", $"root@127.0.0.1", DateTimeOffset.Now.AddHours(1));
// Test token, can only be used locally. Will be destroyed in one hour.
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,75 @@
using System.Net;
using System.Security.Authentication;
using Microsoft.AspNetCore.Mvc;
namespace Abyss.Components.Static;
public abstract class BaseController : Controller
{
protected IActionResult _403 => StatusCode(403, new { message = "Access Denied" });
protected IActionResult _400 => StatusCode(400, new { message = "Bad Request" });
protected IActionResult _401 => StatusCode(404, new { message = "Unauthorized" });
protected IActionResult _404 => StatusCode(404, new { message = "Not Found" });
protected string Token
{
get
{
var t = Request.Cookies["token"];
if (string.IsNullOrEmpty(t))
throw new AuthenticationException("Token is missing");
return t;
}
}
private string? _ip;
protected string Ip
{
get
{
if (_ip != null)
return _ip;
_ip = GetClientIpAddress();
if (string.IsNullOrEmpty(_ip))
throw new InvalidOperationException("invalid IP");
return _ip;
}
}
private string? GetClientIpAddress()
{
var remoteIp = HttpContext.Connection.RemoteIpAddress;
if (remoteIp != null && (IPAddress.IsLoopback(remoteIp) || remoteIp.ToString() == "::1"))
{
return remoteIp.ToString();
}
string? ip = remoteIp?.ToString();
if (HttpContext.Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
{
var forwardedIps = forwardedFor.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrEmpty(x))
.ToArray();
if (forwardedIps.Length > 0)
{
ip = forwardedIps[0];
}
}
if (string.IsNullOrEmpty(ip) && HttpContext.Request.Headers.TryGetValue("X-Real-IP", out var realIp))
{
ip = realIp.ToString();
}
return ip;
}
}

View File

@@ -1,11 +1,35 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.StaticFiles;
namespace Abyss.Components.Static;
public static class Helpers
{
private static readonly FileExtensionContentTypeProvider _provider = InitProvider();
private static FileExtensionContentTypeProvider InitProvider()
{
var provider = new FileExtensionContentTypeProvider();
provider.Mappings[".m3u8"] = "application/vnd.apple.mpegurl";
provider.Mappings[".ts"] = "video/mp2t";
provider.Mappings[".mpd"] = "application/dash+xml";
return provider;
}
public static string GetContentType(string path)
{
if (_provider.TryGetContentType(path, out var contentType))
{
return contentType;
}
return "application/octet-stream";
}
public static string? SafePathCombine(string basePath, params string[] pathParts)
{
if (string.IsNullOrWhiteSpace(basePath))
@@ -49,8 +73,6 @@ public static class Helpers
{
return PathType.AccessDenied;
}
return PathType.NotFound;
}
}

View File

@@ -0,0 +1,51 @@
using System.Net.Sockets;
using System.Text;
namespace Abyss.Components.Static;
public static class SocketExtensions
{
public static async Task<string> ReadBase64Async(this Socket socket, CancellationToken cancellationToken = default)
{
var buffer = new byte[4096];
var sb = new StringBuilder();
while (true)
{
int bytesRead = await socket.ReceiveAsync(buffer, SocketFlags.None, cancellationToken).ConfigureAwait(false);
if (bytesRead == 0)
throw new SocketException((int)SocketError.ConnectionReset);
string chunk = Encoding.UTF8.GetString(buffer, 0, bytesRead);
sb.Append(chunk);
int newlineIndex = sb.ToString().IndexOf('\n');
if (newlineIndex >= 0)
{
string base64 = sb.ToString(0, newlineIndex).Trim();
sb.Remove(0, newlineIndex + 1);
return base64;
}
}
}
public static async Task WriteBase64Async(this Socket socket, string base64, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(base64))
throw new ArgumentException("Base64 string cannot be null or empty.", nameof(base64));
string message = base64 + "\n";
byte[] data = Encoding.UTF8.GetBytes(message);
int totalSent = 0;
while (totalSent < data.Length)
{
int sent = await socket.SendAsync(data.AsMemory(totalSent), SocketFlags.None, cancellationToken).ConfigureAwait(false);
if (sent == 0)
throw new SocketException((int)SocketError.ConnectionReset);
totalSent += sent;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,244 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace Abyss.Misc;
public static class StringClusterer
{
public static Dictionary<string, List<string>> Cluster(
string[] inputs,
double mergeThreshold = 0.20
)
{
return Cluster(inputs, s => s, mergeThreshold);
}
public static Dictionary<string, List<T>> Cluster<T>(
IEnumerable<T> inputs,
Func<T, string> selector,
double mergeThreshold = 0.20
)
{
if (inputs == null) throw new ArgumentNullException(nameof(inputs));
if (selector == null) throw new ArgumentNullException(nameof(selector));
var items = inputs.Select(x => new Item(selector(x), x)).ToList();
var groups = new Dictionary<string, Group>(StringComparer.OrdinalIgnoreCase);
foreach (var it in items)
{
if (!groups.TryGetValue(it.KeyNorm, out var g))
{
g = new Group(it.KeyNorm);
groups[it.KeyNorm] = g;
}
g.Items.Add(it);
}
var keys = groups.Keys.ToList();
var uf = new UnionFind(keys.Count);
for (int i = 0; i < keys.Count; i++)
{
for (int j = i + 1; j < keys.Count; j++)
{
string k1 = keys[i], k2 = keys[j];
int maxLen = Math.Max(k1.Length, k2.Length);
if (maxLen == 0) continue;
int lenDiff = Math.Abs(k1.Length - k2.Length);
if (lenDiff > Math.Max(2, (int)Math.Ceiling(maxLen * 0.5))) continue;
double distNorm = (double)Levenshtein(k1, k2) / maxLen;
if (distNorm <= mergeThreshold && CompatibleForMerge(groups[k1], groups[k2]))
{
uf.Union(i, j);
}
}
}
var merged = new Dictionary<int, List<Group>>();
for (int i = 0; i < keys.Count; i++)
{
int root = uf.Find(i);
if (!merged.TryGetValue(root, out var list))
{
list = new List<Group>();
merged[root] = list;
}
list.Add(groups[keys[i]]);
}
var result = new Dictionary<string, List<T>>();
foreach (var kv in merged)
{
var combinedItems = kv.Value.SelectMany(g => g.Items).ToList();
var members = combinedItems.Select(it => it.Original).ToList();
var uniqueMembers = new List<T>();
var seen = new HashSet<string>();
foreach (var it in combinedItems)
if (seen.Add(it.Original)) uniqueMembers.Add((T)it.Payload);
string rawPrefix = LongestCommonPrefix(members);
string groupName = TrimToTokenBoundary(rawPrefix);
groupName = Regex.Replace(groupName, @"[\s_\-\.]+$", "");
result[groupName] = uniqueMembers;
}
return result;
}
private static bool CompatibleForMerge(Group g1, Group g2)
{
if (g1.HasAnyAlphaTokenCountGreaterThanOne() != g2.HasAnyAlphaTokenCountGreaterThanOne())
return false;
if (g1.HasTrailingNumber() != g2.HasTrailingNumber())
return false;
return true;
}
#region Helpers & Internal Types
private class Item
{
public string Original { get; }
public string[] Tokens { get; }
public string KeyOriginal { get; }
public string KeyNorm { get; }
public int AlphaTokenCount { get; }
public bool EndsWithNumber { get; }
public object Payload { get; }
public Item(string original, object? payload = null)
{
Original = original;
Payload = payload ?? original;
Tokens = TokenizeAlphaNum(Original).ToArray();
EndsWithNumber = Tokens.Length > 0 && Regex.IsMatch(Tokens.Last(), "^[0-9]+$");
var alphaTokens = Tokens.Where(t => Regex.IsMatch(t, "^[A-Za-z]+$")).ToList();
AlphaTokenCount = alphaTokens.Count;
string candidate;
if (EndsWithNumber && alphaTokens.Count >= 1)
candidate = alphaTokens.Last();
else if (alphaTokens.Count > 0)
candidate = alphaTokens.OrderByDescending(t => t.Length).First();
else if (Tokens.Length > 0)
candidate = Tokens[0];
else
candidate = Original.Trim();
KeyOriginal = candidate;
KeyNorm = NormalizeKey(candidate);
}
public static IEnumerable<string> TokenizeAlphaNum(string s)
{
if (string.IsNullOrEmpty(s)) yield break;
var matches = Regex.Matches(s, @"[\p{IsCJKUnifiedIdeographs}\p{IsHiragana}\p{IsKatakana}]+|[A-Za-z]+|[0-9]+");
foreach (Match m in matches) yield return m.Value;
}
}
private class Group
{
public string KeyNorm { get; }
public List<Item> Items { get; } = new List<Item>();
public Group(string keyNorm) { KeyNorm = keyNorm; }
public bool HasAnyAlphaTokenCountGreaterThanOne()
=> Items.Any(it => it.AlphaTokenCount > 1);
public bool HasTrailingNumber()
=> Items.Any(it => it.EndsWithNumber);
public string RepresentativeOriginal()
=> Items.Select(i => i.KeyOriginal).FirstOrDefault() ?? KeyNorm;
}
private static string NormalizeKey(string s)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
string formD = s.Normalize(NormalizationForm.FormD);
var sb = new StringBuilder();
foreach (var ch in formD)
{
var uc = CharUnicodeInfo.GetUnicodeCategory(ch);
if (uc == UnicodeCategory.NonSpacingMark) continue;
if (char.IsLetterOrDigit(ch)) sb.Append(char.ToLowerInvariant(ch));
}
return sb.ToString();
}
private static int Levenshtein(string a, string b)
{
int n = a.Length, m = b.Length;
if (n == 0) return m;
if (m == 0) return n;
var d = new int[n + 1, m + 1];
for (int i = 0; i <= n; i++) d[i, 0] = i;
for (int j = 0; j <= m; j++) d[0, j] = j;
for (int i = 1; i <= n; i++)
{
for (int j = 1; j <= m; j++)
{
int cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
d[i, j] = Math.Min(
Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1),
d[i - 1, j - 1] + cost
);
}
}
return d[n, m];
}
private class UnionFind
{
private int[] _p;
public UnionFind(int n) { _p = Enumerable.Range(0, n).ToArray(); }
public int Find(int x) { return _p[x] == x ? x : (_p[x] = Find(_p[x])); }
public void Union(int a, int b) { a = Find(a); b = Find(b); if (a != b) _p[b] = a; }
}
private static string LongestCommonPrefix(List<string> strs)
{
if (strs.Count == 0) return string.Empty;
string prefix = strs[0];
foreach (var s in strs)
{
int len = Math.Min(prefix.Length, s.Length);
int i = 0;
while (i < len && prefix[i] == s[i]) i++;
prefix = prefix.Substring(0, i);
if (prefix == string.Empty) break;
}
return prefix;
}
private static string TrimToTokenBoundary(string prefix)
{
if (string.IsNullOrEmpty(prefix)) return string.Empty;
var boundary = new Regex(@"[\s0-9_\-\.]");
int lastBoundary = -1;
for (int i = 0; i < prefix.Length; i++)
{
if (boundary.IsMatch(prefix[i].ToString()))
lastBoundary = i;
}
if (lastBoundary >= 0)
{
return prefix.Substring(0, lastBoundary).TrimEnd();
}
return prefix;
}
#endregion
}

19
Abyss/Model/Admin/Ctl.cs Normal file
View File

@@ -0,0 +1,19 @@
using System.Text;
using Newtonsoft.Json;
namespace Abyss.Model.Admin;
public class Ctl
{
[JsonProperty("head")]
public int Head { get; set; }
[JsonProperty("params")] public string[] Params { get; set; } = [];
public static string MakeBase64(int head, string[] param)
{
return Convert.ToBase64String(
Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new Ctl
{ Head = head, Params = param })));
}
}

View File

@@ -1,6 +1,6 @@
using Newtonsoft.Json;
namespace Abyss.Model;
namespace Abyss.Model.Media;
public class Bookmark
{

View File

@@ -1,4 +1,4 @@
namespace Abyss.Model;
namespace Abyss.Model.Media;
public enum ChipState
{

View File

@@ -1,6 +1,6 @@
using Newtonsoft.Json;
namespace Abyss.Model;
namespace Abyss.Model.Media;
public class Comic
{
@@ -8,10 +8,14 @@ public class Comic
public string ComicName { get; set; } = "";
[JsonProperty("page_count")]
public int PageCount { get; set; }
[JsonProperty("cover")]
public string? Cover { get; set; } = "";
[JsonProperty("bookmarks")]
public List<Bookmark> Bookmarks { get; set; } = new();
[JsonProperty("author")]
public string Author { get; set; } = "";
public string Author { get; set; } = "";
[JsonProperty("tags")]
public List<string> Tags { get; set; } = new();
[JsonProperty("list")]
public List<string> List { get; set; } = new();
}

View File

@@ -1,4 +1,4 @@
namespace Abyss.Model;
namespace Abyss.Model.Media;
public class Comment
{

View File

@@ -0,0 +1,24 @@
using SQLite;
namespace Abyss.Model.Media;
[Table("Index")]
public class Index
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
// 0: folder, 1: video, 2: comic
public int Type { get; set; }
// The resources referenced by the index node, the format is "Video, Class, ID", "Comic, ID"
// eg: "Video,Animation,12"
// eg: "Comic,9"
// eg: "Video,Movie,45"
// When a directory node references an actual resource, the resource is treated as the cover page of the directory
public string Reference { get; set; } = "";
// The direct successor node of this node
// eg: "1,2,3,4"
public string Children { get; set; } = "";
}

View File

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

View File

@@ -1,4 +1,4 @@
namespace Abyss.Model;
namespace Abyss.Model.Media;
public enum TaskType
{
@@ -9,7 +9,7 @@ public enum TaskType
public class Task
{
public uint Id;
public string Owner = "";
public int Owner;
public string Class = "";
public string Name = "";
public TaskType Type;

View File

@@ -1,4 +1,4 @@
namespace Abyss.Model;
namespace Abyss.Model.Media;
public class TaskCreation
{

View File

@@ -0,0 +1,27 @@
using Newtonsoft.Json;
namespace Abyss.Model.Media;
public class Video
{
[JsonProperty("name")] public string Name { get; set; } = "";
[JsonProperty("duration")]
public ulong Duration { get; set; }
[JsonProperty("gallery")]
public List<string> Gallery { get; set; } = new();
[JsonProperty("comment")]
public List<Comment> Comment { get; set; } = new();
[JsonProperty("star")]
public bool Star { get; set; }
[JsonProperty("like")] public uint Like { get; set; }
[JsonProperty("author")] public string Author { get; set; } = "";
[JsonProperty("group")]
public string? Group { get; set; }
}

View File

@@ -1,9 +0,0 @@
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; } = "--,--,--";
}

View File

@@ -1,4 +1,4 @@
namespace Abyss.Model;
namespace Abyss.Model.Security;
public class ChallengeResponse
{

View File

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

View File

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

View File

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

@@ -1,12 +0,0 @@
namespace Abyss.Model;
public class Video
{
public string name;
public ulong duration;
public List<string> gallery = new();
public List<Comment> comment = new();
public bool star;
public uint like;
public string author;
}

View File

@@ -1,6 +1,14 @@
using System.Threading.RateLimiting;
using Abyss.Components.Controllers.Middleware;
using Abyss.Components.Controllers.Task;
using Abyss.Components.Services;
using Abyss.Components.Services.Admin;
using Abyss.Components.Services.Admin.Attributes;
using Abyss.Components.Services.Admin.Modules;
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Misc;
using Abyss.Components.Services.Security;
using Microsoft.AspNetCore.RateLimiting;
namespace Abyss;
@@ -13,19 +21,28 @@ public class Program
builder.Services.AddAuthorization();
builder.Services.AddMemoryCache();
builder.Services.AddOpenApi();
builder.Services.AddControllers();
builder.Services.AddSingleton<ResourceDatabaseService>();
builder.Services.AddSingleton<ConfigureService>();
builder.Services.AddSingleton<UserService>();
builder.Services.AddSingleton<ResourceService>();
builder.Services.AddSingleton<TaskController>();
builder.Services.AddSingleton<TaskService>();
builder.Services.AddSingleton<IndexService>();
builder.Services.AddSingleton<VideoService>();
builder.Services.AddSingleton<ComicService>();
builder.Services.AddHostedService<AbyssService>();
builder.Services.AddHostedService<CtlService>();
foreach (var t in ModuleAttribute.Modules)
{
builder.Services.AddTransient(t);
}
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("Fixed", policyOptions =>
{
// 时间窗口长度
policyOptions.Window = TimeSpan.FromSeconds(30);
policyOptions.PermitLimit = 10;
policyOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
@@ -38,20 +55,12 @@ public class Program
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.UseHttpsRedirection();
app.UseMiddleware<BadRequestExceptionMiddleware>();
app.UseAuthorization();
app.MapStaticAssets();
app.MapControllers();
app.UseRateLimiter();

View File

@@ -5,21 +5,12 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://192.168.1.244:5198",
"applicationUrl": "http://localhost:3000",
"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",
"MEDIA_ROOT" : "/storage",
"DEBUG_MODE" : "Debug"
"MEDIA_ROOT" : "/opt/abyss",
"ALLOWED_PORTS" : "3000",
"DEBUG_MODE": "Debug"
}
}
}

321
Abyss/Toolkits/abyss-cli.py Normal file
View File

@@ -0,0 +1,321 @@
#!/usr/bin/env python3
# abysscli.py
"""
Abyss CLI — Python 3 实现
Commands:
open <baseUrl> <user> <privateKeyBase64>
destroy <baseUrl> <token>
valid <baseUrl> <token>
create <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>
"""
from __future__ import annotations
import sys
import argparse
import base64
import json
import typing as t
from urllib.parse import quote
import requests
from requests import Session
from nacl.signing import SigningKey
from nacl.exceptions import BadSignatureError
# ---- Utilities for Ed25519 handling ----
def generate_keypair_base64() -> t.Tuple[str, str]:
"""
Generate Ed25519 keypair.
Returns (private_base64, public_base64).
private is encoded as 64 bytes: seed(32) || pub(32) to align with many raw-private formats.
public is 32 bytes.
"""
sk = SigningKey.generate()
seed = sk.encode() # 32 bytes seed
vk = sk.verify_key.encode() # 32 bytes pubkey
priv_raw = seed + vk # 64 bytes
return base64.b64encode(priv_raw).decode('ascii'), base64.b64encode(vk).decode('ascii')
def sign_with_private_base64(private_base64: str, data: bytes) -> str:
"""
Accept private key as base64. Supports:
- 32-byte seed (seed only)
- 64-byte raw private (seed + pub)
Returns base64(signature).
"""
try:
raw = base64.b64decode(private_base64)
except Exception as e:
raise ValueError(f"privateKeyBase64 is not valid base64: {e}")
if len(raw) == 32:
seed = raw
elif len(raw) == 64:
seed = raw[:32]
else:
raise ValueError(f"Unsupported private key length: {len(raw)} bytes (expected 32 or 64)")
sk = SigningKey(seed)
sig = sk.sign(data).signature # 64 bytes
return base64.b64encode(sig).decode('ascii')
# ---- HTTP helpers ----
def create_session(base_url: str) -> Session:
s = requests.Session()
base = base_url.rstrip('/')
s.headers.update({'User-Agent': 'AbyssCli-Python/1.0'})
s.base_url = base + '/' # attach attribute for convenience
return s
def _full_url(session: Session, path: str) -> str:
base = getattr(session, 'base_url', '')
# ensure no double slashes issues
return base + path.lstrip('/')
def try_read_response_text(resp: requests.Response) -> str:
try:
return resp.text or ""
except Exception:
return ""
def parse_possibly_json_string(text: str) -> str:
"""
Server sometimes returns a JSON-encoded string like: "username"
Try json.loads first, fall back to trimming quotes.
"""
if text is None:
return ""
text = text.strip()
if not text:
return ""
try:
parsed = json.loads(text)
# If parsed is a string, return it; otherwise return original trimmed
if isinstance(parsed, str):
return parsed
# otherwise return textual representation
return str(parsed)
except Exception:
# fallback trim quotes only at ends if present
if text.startswith('"') and text.endswith('"') and len(text) >= 2:
return text[1:-1]
return text
# ---- Command implementations ----
def cmd_open(args: argparse.Namespace) -> int:
base = args.baseUrl
user = args.user
priv_base64 = args.privateKeyBase64
sess = create_session(base)
# 1. GET challenge
url = _full_url(sess, f"api/user/{quote(user, safe='')}")
try:
r = sess.get(url, timeout=15)
except Exception as e:
print(f"Failed to GET challenge: {e}", file=sys.stderr)
return 1
if not r.ok:
print(f"Failed to get challenge: HTTP {r.status_code}", file=sys.stderr)
txt = try_read_response_text(r)
if txt:
print(txt, file=sys.stderr)
return 1
challenge_text = try_read_response_text(r)
challenge = parse_possibly_json_string(challenge_text)
# challenge is expected to be base64-encoded bytes
try:
challenge_bytes = base64.b64decode(challenge)
except Exception:
print("Challenge is not valid base64.", file=sys.stderr)
return 1
# 2. Sign
try:
signature_base64 = sign_with_private_base64(priv_base64, challenge_bytes)
except Exception as e:
print(f"Signing failed: {e}", file=sys.stderr)
return 1
# 3. POST response to get token
post_url = _full_url(sess, f"api/user/{quote(user, safe='')}")
payload = {"Response": signature_base64}
try:
r2 = sess.post(post_url, json=payload, timeout=15)
except Exception as e:
print(f"Failed to POST response: {e}", file=sys.stderr)
return 1
if not r2.ok:
print(f"Authentication failed: HTTP {r2.status_code}", file=sys.stderr)
txt = try_read_response_text(r2)
if txt:
print(txt, file=sys.stderr)
return 1
token_text = try_read_response_text(r2)
token = parse_possibly_json_string(token_text)
if not token:
print("Authentication failed or server returned no token.", file=sys.stderr)
return 1
print(token)
return 0
def cmd_destroy(args: argparse.Namespace) -> int:
base = args.baseUrl
token = args.token
sess = create_session(base)
url = _full_url(sess, f"api/user/destroy?token={quote(token, safe='')}")
try:
r = sess.post(url, timeout=15)
except Exception as e:
print(f"Destroy request failed: {e}", file=sys.stderr)
return 1
if not r.ok:
print(f"Destroy failed: HTTP {r.status_code}", file=sys.stderr)
txt = try_read_response_text(r)
if txt:
print(txt, file=sys.stderr)
return 1
# some servers return body, but original prints "Success"
print("Success")
return 0
def cmd_valid(args: argparse.Namespace) -> int:
base = args.baseUrl
token = args.token
sess = create_session(base)
url = _full_url(sess, f"api/user/validate?token={quote(token, safe='')}")
try:
r = sess.post(url, timeout=15)
except Exception as e:
print(f"Validate request failed: {e}", file=sys.stderr)
return 1
if not r.ok:
print("Invalid")
return 1
content = try_read_response_text(r)
username = parse_possibly_json_string(content)
if not username:
print("Invalid")
return 1
print(username)
return 0
def cmd_create(args: argparse.Namespace) -> int:
base = args.baseUrl
user = args.user
priv_base64 = args.privateKeyBase64
new_username = args.newUsername
privilege = args.privilege
sess = create_session(base)
# 1. Get challenge for creator
url = _full_url(sess, f"api/user/{quote(user, safe='')}")
try:
r = sess.get(url, timeout=15)
except Exception as e:
print(f"Failed to GET challenge for creator: {e}", file=sys.stderr)
return 1
if not r.ok:
print(f"Failed to get challenge for creator: HTTP {r.status_code}", file=sys.stderr)
txt = try_read_response_text(r)
if txt:
print(txt, file=sys.stderr)
return 1
challenge_text = try_read_response_text(r)
challenge = parse_possibly_json_string(challenge_text)
try:
challenge_bytes = base64.b64decode(challenge)
except Exception:
print("Challenge is not valid base64.", file=sys.stderr)
return 1
# 2. Sign challenge with creator private key
try:
signature_base64 = sign_with_private_base64(priv_base64, challenge_bytes)
except Exception as e:
print(f"Signing failed: {e}", file=sys.stderr)
return 1
# 3. Generate key pair for new user
new_priv_b64, new_pub_b64 = generate_keypair_base64()
# 4. Build create payload and PATCH
payload = {
"Response": signature_base64,
"Name": new_username,
"Parent": user,
"Privilege": int(privilege),
"PublicKey": new_pub_b64
}
patch_url = _full_url(sess, f"api/user/{quote(user, safe='')}")
try:
r2 = sess.request("PATCH", patch_url, json=payload, timeout=15)
except Exception as e:
print(f"Create request failed: {e}", file=sys.stderr)
return 1
resp_text = try_read_response_text(r2)
if not r2.ok:
print(f"Create failed: HTTP {r2.status_code}", file=sys.stderr)
if resp_text:
print(resp_text, file=sys.stderr)
return 1
print("Success")
print("NewUserPrivateKeyBase64:")
print(new_priv_b64)
print("NewUserPublicKeyBase64:")
print(new_pub_b64)
return 0
# ---- CLI entrypoint ----
def main(argv: t.Optional[t.List[str]] = None) -> int:
if argv is None:
argv = sys.argv[1:]
parser = argparse.ArgumentParser(prog="AbyssCli", description="Abyss CLI (Python)")
sub = parser.add_subparsers(dest="cmd")
p_open = sub.add_parser("open", help="open <baseUrl> <user> <privateKeyBase64>")
p_open.add_argument("baseUrl")
p_open.add_argument("user")
p_open.add_argument("privateKeyBase64")
p_destroy = sub.add_parser("destroy", help="destroy <baseUrl> <token>")
p_destroy.add_argument("baseUrl")
p_destroy.add_argument("token")
p_valid = sub.add_parser("valid", help="valid <baseUrl> <token>")
p_valid.add_argument("baseUrl")
p_valid.add_argument("token")
p_create = sub.add_parser("create", help="create <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>")
p_create.add_argument("baseUrl")
p_create.add_argument("user")
p_create.add_argument("privateKeyBase64")
p_create.add_argument("newUsername")
p_create.add_argument("privilege", type=int)
if not argv:
parser.print_help()
return 1
args = parser.parse_args(argv)
try:
if args.cmd == "open":
return cmd_open(args)
elif args.cmd == "destroy":
return cmd_destroy(args)
elif args.cmd == "valid":
return cmd_valid(args)
elif args.cmd == "create":
return cmd_create(args)
else:
print("Unknown command.", file=sys.stderr)
parser.print_help()
return 2
except Exception as ex:
print(f"Error: {ex}", file=sys.stderr)
return 3
if __name__ == "__main__":
sys.exit(main())

View File

@@ -5,11 +5,13 @@ import subprocess
import shutil
from pathlib import Path
ALLOWED_VIDEO_EXTS = [".mp4", ".mkv", ".webm", ".mov", ".ogg", ".ts", ".m2ts"]
def get_video_duration(video_path):
"""Get video duration in milliseconds using ffprobe"""
try:
cmd = [
'ffprobe',
'ffprobe',
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
@@ -27,14 +29,12 @@ def create_thumbnails(video_path, gallery_path, num_thumbnails=10):
Extracts thumbnails from a video and saves them to the gallery directory.
"""
try:
# Check if ffmpeg is installed
subprocess.run(['ffmpeg', '-version'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except (subprocess.CalledProcessError, FileNotFoundError):
print("Error: ffmpeg is not installed or not in your PATH. Skipping thumbnail creation.")
return
try:
# Get video duration using ffprobe
duration_cmd = [
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', str(video_path)
@@ -44,15 +44,15 @@ def create_thumbnails(video_path, gallery_path, num_thumbnails=10):
except (subprocess.CalledProcessError, ValueError) as e:
print(f"Could not get duration for '{video_path}': {e}. Skipping thumbnail creation.")
return
if duration <= 0:
print(f"Warning: Invalid video duration for '{video_path}'. Skipping thumbnail creation.")
return
interval = duration / (num_thumbnails + 1)
print(f"Generating {num_thumbnails} thumbnails for {video_path.name}...")
for i in range(num_thumbnails):
timestamp = (i + 1) * interval
output_thumbnail_path = gallery_path / f"{i}.jpg"
@@ -68,15 +68,83 @@ def create_thumbnails(video_path, gallery_path, num_thumbnails=10):
except subprocess.CalledProcessError as e:
print(f" Error extracting thumbnail {i}.jpg: {e}")
def update_summary(base_path, name_input=None, author_input=None):
def create_cover(video_path, output_path, time_percent):
"""
Creates a cover image from a video at a specified time percentage.
"""
try:
subprocess.run(['ffmpeg', '-version'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except (subprocess.CalledProcessError, FileNotFoundError):
print("Error: ffmpeg is not installed or not in your PATH. Cannot create cover.")
return
try:
duration_cmd = [
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', str(video_path)
]
result = subprocess.run(duration_cmd, capture_output=True, text=True, check=True)
duration = float(result.stdout)
except (subprocess.CalledProcessError, ValueError) as e:
print(f"Could not get duration for '{video_path}': {e}. Cannot create cover.")
return
if duration <= 0:
print(f"Warning: Invalid video duration for '{video_path}'. Cannot create cover.")
return
timestamp = duration * time_percent
ffmpeg_cmd = [
'ffmpeg', '-ss', str(timestamp), '-i', str(video_path),
'-vframes', '1', str(output_path), '-y'
]
print(f"Creating cover image from video at {timestamp:.2f} seconds...")
try:
subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(f"Cover image created at {output_path}")
except subprocess.CalledProcessError as e:
print(f"Error creating cover image: {e}")
def find_video_in_dir(base_path):
"""
Find video file in base_path. Preference:
1) file named 'video' with allowed ext (video.mp4, video.mkv, ...)
2) first file with allowed ext
Returns Path or None.
"""
if not base_path.exists() or not base_path.is_dir():
return None
# prefer explicit video.* name
for ext in ALLOWED_VIDEO_EXTS:
candidate = base_path / ("video" + ext)
if candidate.exists() and candidate.is_file():
return candidate
# otherwise find first allowed extension file
for f in base_path.iterdir():
if f.is_file() and f.suffix.lower() in ALLOWED_VIDEO_EXTS:
return f
return None
def update_summary(base_path, name_input=None, author_input=None, group_input=None):
"""
Updates the summary.json file for a given path.
name_input and author_input are optional, used for the '-a' mode.
name_input, author_input, group_input are optional, used for the '-a' and merging modes.
If group_input is provided, the written summary.json will include the "group" key.
If summary.json already contains a "group" and group_input is None, existing group is preserved.
"""
summary_path = base_path / "summary.json"
video_path = base_path / "video.mp4"
gallery_path = base_path / "gallery"
# Find the video file dynamically
video_path = find_video_in_dir(base_path)
if video_path is None:
print(f"Warning: no video file found in {base_path}")
# Default template
default_summary = {
"name": name_input if name_input is not None else "null",
@@ -87,25 +155,33 @@ def update_summary(base_path, name_input=None, author_input=None):
"like": 0,
"author": author_input if author_input is not None else "anonymous"
}
existing_data = {}
# Load existing summary if available
if summary_path.exists():
try:
with open(summary_path, 'r', encoding='utf-8') as f:
existing_data = json.load(f)
# Update default with existing values
# Update default with existing values for known keys
for key in default_summary:
if key in existing_data:
default_summary[key] = existing_data[key]
except json.JSONDecodeError:
print("Warning: Invalid JSON in summary.json, using defaults")
# Update duration from video file
if video_path.exists():
# Handle group: preserve existing if no new group provided; otherwise set new group
if group_input is not None:
default_summary["group"] = group_input
else:
if isinstance(existing_data, dict) and "group" in existing_data:
default_summary["group"] = existing_data["group"]
# Update duration from video file if found
if video_path and video_path.exists():
default_summary["duration"] = get_video_duration(video_path)
else:
print(f"Warning: video.mp4 not found at {video_path}")
print("Warning: video file for duration not found; duration set to 0")
# Update gallery from directory
if gallery_path.exists() and gallery_path.is_dir():
gallery_files = []
@@ -116,11 +192,11 @@ def update_summary(base_path, name_input=None, author_input=None):
default_summary["gallery"] = gallery_files
else:
print(f"Warning: gallery directory not found at {gallery_path}")
# Write updated summary
with open(summary_path, 'w', encoding='utf-8') as f:
json.dump(default_summary, f, indent=4, ensure_ascii=False)
print(f"Summary updated successfully at {summary_path}")
def find_next_directory(base_path):
@@ -129,22 +205,78 @@ def find_next_directory(base_path):
for item in base_path.iterdir():
if item.is_dir() and item.name.isdigit():
existing_dirs.add(int(item.name))
next_num = 1
while next_num in existing_dirs:
next_num += 1
return str(next_num)
def merge_projects(src_path, dst_path, group_override=None):
"""
Merge (copy) all video projects from src_path into dst_path, resolving ID conflicts by
allocating the next available integer directory names in dst_path.
If group_override is provided, it will be written into each merged project's summary.json
(overwriting any existing group value).
"""
src = Path(src_path)
dst = Path(dst_path)
if not src.exists() or not src.is_dir():
print(f"Error: Source path not found or is not a directory: {src}")
return
dst.mkdir(parents=True, exist_ok=True)
merged_count = 0
skipped_count = 0
# Iterate in sorted order for predictability
for child in sorted(src.iterdir(), key=lambda p: p.name):
if not child.is_dir():
continue
# Heuristic: treat as a project if it contains a video or a summary.json
has_video = find_video_in_dir(child) is not None
has_summary = (child / 'summary.json').exists()
if not has_video and not has_summary:
print(f"Skipping '{child.name}': not a project (no video or summary.json).")
skipped_count += 1
continue
# Allocate next ID in dst
new_dir_name = find_next_directory(dst)
dst_project = dst / new_dir_name
try:
shutil.copytree(child, dst_project)
print(f"Copied project '{child.name}' -> '{dst_project.name}'")
# Rebuild/adjust summary in destination project and optionally override group
update_summary(dst_project, group_input=group_override)
merged_count += 1
except Exception as e:
print(f"Failed to copy '{child}': {e}")
print(f"Merge complete: {merged_count} projects merged, {skipped_count} skipped.")
def main():
if len(sys.argv) < 2:
print("Usage: python script.py <command> [arguments]")
print("Commands:")
print(" -u <path> Update the summary.json in the specified path.")
print(" -a <video_file> <path> Add a new video project in a new directory under the specified path.")
print(" Optional flags for -a: -y (accept defaults anywhere), -g <group name> (set group in summary.json).")
print(" -c <path> <time> Create a cover image from the video in the specified path at a given time percentage (0.0-1.0).")
print(" -m <src> <dst> Merge all projects from <src> into <dst>. Optional flag -g <group name> will override group's field in merged summaries.")
sys.exit(1)
command = sys.argv[1]
# global -y flag (if present anywhere)
assume_yes = '-y' in sys.argv
if command == '-u':
if len(sys.argv) != 3:
print("Usage: python script.py -u <path>")
@@ -156,12 +288,33 @@ def main():
update_summary(base_path)
elif command == '-a':
if len(sys.argv) != 4:
print("Usage: python script.py -a <video_file> <path>")
# Parse tokens allowing -y (global) and -g <group> anywhere; remaining two positionals must be video_file and base_path
tokens = sys.argv[2:]
positional = []
group_name = None
i = 0
while i < len(tokens):
t = tokens[i]
if t == '-y':
i += 1
continue
if t == '-g':
if i + 1 >= len(tokens):
print("Usage: python script.py -a <video_file> <path> (optional -y to accept defaults, optional -g <group name>)")
sys.exit(1)
group_name = tokens[i + 1]
i += 2
continue
positional.append(t)
i += 1
if len(positional) != 2:
print("Usage: python script.py -a <video_file> <path> (optional -y to accept defaults, optional -g <group name>)")
sys.exit(1)
video_source_path = Path(sys.argv[2])
base_path = Path(sys.argv[3])
video_source_path = Path(positional[0])
base_path = Path(positional[1])
if not video_source_path.exists() or not video_source_path.is_file():
print(f"Error: Video file not found: {video_source_path}")
@@ -170,35 +323,123 @@ def main():
if not base_path.is_dir():
print(f"Error: Base path not found or is not a directory: {base_path}")
sys.exit(1)
# Find a new directory name (e.g., "1", "2", "3")
new_dir_name = find_next_directory(base_path)
new_project_path = base_path / new_dir_name
# Create the new project directory and the gallery subdirectory
new_project_path.mkdir(exist_ok=True)
gallery_path = new_project_path / "gallery"
gallery_path.mkdir(exist_ok=True)
print(f"New project directory created at {new_project_path}")
# Copy video file to the new directory
shutil.copy(video_source_path, new_project_path / "video.mp4")
print(f"Video copied to {new_project_path / 'video.mp4'}")
# --- 新增功能:自动生成缩略图 ---
video_dest_path = new_project_path / "video.mp4"
# Copy video file to the new directory, preserving extension in the target name
dest_video_name = "video" + video_source_path.suffix.lower()
video_dest_path = new_project_path / dest_video_name
shutil.copy(video_source_path, video_dest_path)
print(f"Video copied to {video_dest_path}")
# === 新增:如果源视频同目录存在同名 .vtt 字幕,复制为 subtitle.vtt 到新项目目录 ===
subtitle_copied = False
candidate_vtt = video_source_path.with_suffix('.vtt')
candidate_vtt_upper = video_source_path.with_suffix('.VTT')
for candidate in (candidate_vtt, candidate_vtt_upper):
if candidate.exists() and candidate.is_file():
try:
shutil.copy2(candidate, new_project_path / 'subtitle.vtt')
print(f"Subtitle '{candidate.name}' copied to {new_project_path / 'subtitle.vtt'}")
subtitle_copied = True
except Exception as e:
print(f"Warning: failed to copy subtitle '{candidate}': {e}")
break
if not subtitle_copied:
print("No matching .vtt subtitle found next to source video; skipping subtitle copy.")
# === 新增结束 ===
# Auto-generate thumbnails
create_thumbnails(video_dest_path, gallery_path)
# ------------------------------------
# Get user input for name and author
video_name = input("Enter the video name: ")
video_author = input("Enter the author's name: ")
# Auto-generate cover at 50%
cover_path = new_project_path / "cover.jpg"
create_cover(video_dest_path, cover_path, 0.5)
# Update the summary with user input
update_summary(new_project_path, name_input=video_name, author_input=video_author)
# Get user input for name and author, unless assume_yes is set
if assume_yes:
video_name = video_source_path.stem
video_author = "Anonymous"
print(f"Assume yes (-y): using defaults: name='{video_name}', author='{video_author}'")
else:
print("\nEnter the video name (press Enter to use the original filename):")
video_name = input(f"Video Name [{video_source_path.stem}]: ")
if not video_name:
video_name = video_source_path.stem
print("\nEnter the author's name (press Enter to use 'Anonymous'):")
video_author = input("Author Name [Anonymous]: ")
if not video_author:
video_author = "Anonymous"
# Update the summary with user input or default values, include group_name if provided
update_summary(new_project_path, name_input=video_name, author_input=video_author, group_input=group_name)
elif command == '-c':
if len(sys.argv) != 4:
print("Usage: python script.py -c <path> <time>")
sys.exit(1)
base_path = Path(sys.argv[2])
# find video dynamically
video_path = find_video_in_dir(base_path)
if video_path is None:
print(f"Error: no video file found in {base_path}")
sys.exit(1)
cover_path = base_path / "cover.jpg"
try:
time_percent = float(sys.argv[3])
if not 0.0 <= time_percent <= 1.0:
raise ValueError
except ValueError:
print("Error: Time value must be a number between 0.0 and 1.0.")
sys.exit(1)
if not video_path.exists() or not video_path.is_file():
print(f"Error: video file not found at {video_path}")
sys.exit(1)
create_cover(video_path, cover_path, time_percent)
elif command == '-m':
# Parse tokens allowing optional -g <group> anywhere; remaining two positionals must be src and dst
tokens = sys.argv[2:]
positional = []
group_name = None
i = 0
while i < len(tokens):
t = tokens[i]
if t == '-g':
if i + 1 >= len(tokens):
print("Usage: python script.py -m <src> <dst> (optional -g <group name>)")
sys.exit(1)
group_name = tokens[i + 1]
i += 2
continue
positional.append(t)
i += 1
if len(positional) != 2:
print("Usage: python script.py -m <src> <dst> (optional -g <group name>)")
sys.exit(1)
src_path = Path(positional[0])
dst_path = Path(positional[1])
merge_projects(src_path, dst_path, group_override=group_name)
else:
print("Invalid command. Use -u or -a.")
print("Invalid command. Use -u, -a, -c, or -m.")
print("Usage: python script.py <command> [arguments]")
sys.exit(1)

View File

@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
</ItemGroup>
</Project>

View File

@@ -1,364 +0,0 @@
// Program.cs
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Text.Json.Serialization.Metadata;
using NSec.Cryptography;
public class ChallengeRequestBody
{
public string Response { get; set; } = "";
}
public class CreateRequestBody
{
public string Response { get; set; } = "";
public string Name { get; set; } = "";
public string Parent { get; set; } = "";
public int Privilege { get; set; }
public string PublicKey { get; set; } = "";
}
public static class Ed25519Utils
{
public static (string privateBase64, string publicBase64) GenerateKeyPairBase64()
{
var algorithm = SignatureAlgorithm.Ed25519;
var creationParameters = new KeyCreationParameters
{
ExportPolicy = KeyExportPolicies.AllowPlaintextExport
};
using var key = Key.Create(algorithm, creationParameters);
var priv = key.Export(KeyBlobFormat.RawPrivateKey);
var pub = key.Export(KeyBlobFormat.RawPublicKey);
return (Convert.ToBase64String(priv), Convert.ToBase64String(pub));
}
public static string SignBase64PrivateKey(string privateKeyBase64, byte[] dataToSign)
{
var algorithm = SignatureAlgorithm.Ed25519;
var privateBytes = Convert.FromBase64String(privateKeyBase64);
using var key = Key.Import(algorithm, privateBytes, KeyBlobFormat.RawPrivateKey);
var sig = algorithm.Sign(key, dataToSign);
return Convert.ToBase64String(sig);
}
}
public static class Program
{
static async Task<int> Main(string[] args)
{
if (args == null || args.Length == 0)
{
PrintUsage();
return 1;
}
var cmd = args[0].ToLowerInvariant();
try
{
switch (cmd)
{
case "open":
return await CmdOpen(args);
case "destroy":
return await CmdDestroy(args);
case "valid":
return await CmdValid(args);
case "create":
return await CmdCreate(args);
default:
Console.Error.WriteLine("Unknown command.");
PrintUsage();
return 2;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 3;
}
}
static void PrintUsage()
{
Console.WriteLine("Usage:");
Console.WriteLine(" AbyssCli open <baseUrl> <user> <privateKeyBase64>");
Console.WriteLine(" AbyssCli destroy <baseUrl> <token>");
Console.WriteLine(" AbyssCli valid <baseUrl> <token>");
Console.WriteLine(" AbyssCli create <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>");
}
static HttpClient CreateHttpClient(string baseUrl)
{
var client = new HttpClient();
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
return client;
}
static async Task<int> CmdOpen(string[] args)
{
if (args.Length != 4)
{
Console.Error.WriteLine("open requires 3 arguments: <baseUrl> <user> <privateKeyBase64>");
return 1;
}
var baseUrl = args[1];
var user = args[2];
var privateKeyBase64 = args[3];
using var client = CreateHttpClient(baseUrl);
// 1. GET challenge
var challenge = await GetChallenge(client, user);
if (challenge == null)
{
Console.Error.WriteLine("Failed to get challenge.");
return 1;
}
// 2. Sign challenge (challenge is base64 string)
byte[] challengeBytes;
try
{
challengeBytes = Convert.FromBase64String(challenge);
}
catch
{
Console.Error.WriteLine("Challenge is not valid base64.");
return 1;
}
string signatureBase64;
try
{
signatureBase64 = Ed25519Utils.SignBase64PrivateKey(privateKeyBase64, challengeBytes);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Signing failed: {ex.Message}");
return 1;
}
// 3. POST response to get token
var token = await PostResponseForToken(client, user, signatureBase64);
if (token == null)
{
Console.Error.WriteLine("Authentication failed or server returned no token.");
return 1;
}
Console.WriteLine(token);
return 0;
}
static async Task<int> CmdDestroy(string[] args)
{
if (args.Length != 3)
{
Console.Error.WriteLine("destroy requires 2 arguments: <baseUrl> <token>");
return 1;
}
var baseUrl = args[1];
var token = args[2];
using var client = CreateHttpClient(baseUrl);
var resp = await client.PostAsync($"api/user/destroy?token={token}", null);
if (!resp.IsSuccessStatusCode)
{
Console.Error.WriteLine($"Destroy failed: {resp.StatusCode}");
var txt = await TryReadResponseText(resp);
if (!string.IsNullOrEmpty(txt)) Console.Error.WriteLine(txt);
return 1;
}
var body = await resp.Content.ReadAsStringAsync();
Console.WriteLine("Success");
return 0;
}
static async Task<int> CmdValid(string[] args)
{
if (args.Length != 3)
{
Console.Error.WriteLine("valid requires 2 arguments: <baseUrl> <token>");
return 1;
}
var baseUrl = args[1];
var token = args[2];
using var client = CreateHttpClient(baseUrl);
var resp = await client.PostAsync($"api/user/validate?token={token}", null);
if (!resp.IsSuccessStatusCode)
{
Console.WriteLine("Invalid");
return 1;
}
var content = await resp.Content.ReadAsStringAsync();
// server likely returns JSON string (e.g. "username"), try to parse JSON string
try
{
var username = JsonSerializer.Deserialize<string>(content, jsonOptions);
if (username == null)
{
Console.WriteLine("Invalid");
return 1;
}
Console.WriteLine(username);
return 0;
}
catch
{
// fallback
Console.WriteLine(content.Trim('"'));
return 0;
}
}
static async Task<int> CmdCreate(string[] args)
{
if (args.Length != 6)
{
Console.Error.WriteLine("create requires 5 arguments: <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>");
return 1;
}
var baseUrl = args[1];
var user = args[2];
var privateKeyBase64 = args[3];
var newUsername = args[4];
if (!int.TryParse(args[5], out var privilege))
{
Console.Error.WriteLine("Privilege must be an integer.");
return 1;
}
using var client = CreateHttpClient(baseUrl);
// 1. Get challenge for creator user
var challenge = await GetChallenge(client, user);
if (challenge == null)
{
Console.Error.WriteLine("Failed to get challenge for creator.");
return 1;
}
byte[] challengeBytes;
try
{
challengeBytes = Convert.FromBase64String(challenge);
}
catch
{
Console.Error.WriteLine("Challenge is not valid base64.");
return 1;
}
string signatureBase64;
try
{
signatureBase64 = Ed25519Utils.SignBase64PrivateKey(privateKeyBase64, challengeBytes);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Signing failed: {ex.Message}");
return 1;
}
// 2. Generate key pair for new user
var (newPrivBase64, newPubBase64) = Ed25519Utils.GenerateKeyPairBase64();
// 3. Build create payload
var payload = new CreateRequestBody
{
Response = signatureBase64,
Name = newUsername,
Parent = user,
Privilege = privilege,
PublicKey = newPubBase64
};
var json = JsonSerializer.Serialize(payload, jsonOptions);
var request = new HttpRequestMessage(new HttpMethod("PATCH"), $"api/user/{Uri.EscapeDataString(user)}")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
var resp = await client.SendAsync(request);
var respText = await TryReadResponseText(resp);
if (!resp.IsSuccessStatusCode)
{
Console.Error.WriteLine($"Create failed: {resp.StatusCode}");
if (!string.IsNullOrEmpty(respText)) Console.Error.WriteLine(respText);
return 1;
}
Console.WriteLine("Success");
Console.WriteLine("NewUserPrivateKeyBase64:");
Console.WriteLine(newPrivBase64);
Console.WriteLine("NewUserPublicKeyBase64:");
Console.WriteLine(newPubBase64);
return 0;
}
static async Task<string?> GetChallenge(HttpClient client, string user)
{
var resp = await client.GetAsync($"api/user/{Uri.EscapeDataString(user)}");
if (!resp.IsSuccessStatusCode) return null;
var content = await resp.Content.ReadAsStringAsync();
// server probably returns JSON string; try to deserialize to string
try
{
var s = JsonSerializer.Deserialize<string>(content, jsonOptions);
if (s != null) return s;
}
catch { /* ignore */ }
// fallback: trim quotes
return content.Trim('"');
}
static async Task<string?> PostResponseForToken(HttpClient client, string user, string signatureBase64)
{
var body = new ChallengeRequestBody { Response = signatureBase64 };
var json = JsonSerializer.Serialize(body, jsonOptions);
var resp = await client.PostAsync($"api/user/{Uri.EscapeDataString(user)}",
new StringContent(json, Encoding.UTF8, "application/json"));
if (!resp.IsSuccessStatusCode) return null;
var content = await resp.Content.ReadAsStringAsync();
try
{
var token = JsonSerializer.Deserialize<string>(content, jsonOptions);
if (!string.IsNullOrEmpty(token)) return token;
}
catch { /* ignore */ }
return content.Trim('"');
}
static async Task<string> TryReadResponseText(HttpResponseMessage resp)
{
try
{
return await resp.Content.ReadAsStringAsync();
}
catch
{
return "";
}
}
static readonly JsonSerializerOptions jsonOptions = new()
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
};
}

View File

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

BIN
abyss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
abyss_clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

56
abyssctl/App/App.cs Normal file
View File

@@ -0,0 +1,56 @@
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using abyssctl.App.Attributes;
using abyssctl.App.Interfaces;
using abyssctl.Model;
using abyssctl.Static;
using CommandLine;
using Newtonsoft.Json;
namespace abyssctl.App;
public class App
{
private static readonly string SocketPath = Path.Combine(Path.GetTempPath(), "abyss-ctl.sock");
public static async Task<Ctl> CtlWriteRead<T>(string[] param)
{
var attr = typeof(T).GetCustomAttribute<ModuleAttribute>()!;
var endPoint = new UnixDomainSocketEndPoint(SocketPath);
using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
try
{
await socket.ConnectAsync(endPoint);
await socket.WriteBase64Async(Ctl.MakeBase64(attr.Head, param));
var s = Encoding.UTF8.GetString(
Convert.FromBase64String(await socket.ReadBase64Async()));
return JsonConvert.DeserializeObject<Ctl>(s)!;
}
catch (Exception e)
{
return new Ctl
{
Head = 500,
Params = [e.Message]
};
}
}
public async Task<int> RunAsync(string[] args)
{
return await Task.Run(() =>
{
return Parser.Default.ParseArguments(args, ModuleAttribute.Modules)
.MapResult(
(object obj) =>
{
var s = (obj as IOptions)?.Run().GetAwaiter().GetResult();
return s!.Value;
},
_ => 1);
});
}
}

View File

@@ -0,0 +1,28 @@
using System.Reflection;
using abyssctl.App.Interfaces;
using CommandLine;
namespace abyssctl.App.Attributes;
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public class ModuleAttribute(int head) : Attribute
{
public int Head { get; } = head;
public static Type[] Modules
{
get
{
Assembly assembly = Assembly.GetExecutingAssembly();
const string targetNamespace = "abyssctl.App.Modules";
return assembly.GetTypes()
.Where(t => t is { IsClass: true, IsAbstract: false, IsInterface: false })
.Where(t => t.Namespace == targetNamespace)
.Where(t => typeof(IOptions).IsAssignableFrom(t))
.Where(t => t.IsDefined(typeof(VerbAttribute), inherit: true))
.Where(t => t.IsDefined(typeof(ModuleAttribute), inherit: false))
.ToArray();
}
}
}

View File

@@ -0,0 +1,6 @@
namespace abyssctl.App.Interfaces;
public interface IOptions
{
public Task<int> Run();
}

View File

@@ -0,0 +1,28 @@
using abyssctl.App.Attributes;
using abyssctl.App.Interfaces;
using CommandLine;
namespace abyssctl.App.Modules;
[Module(106)]
[Verb("chmod", HelpText = "Change resources permissions")]
public class ChmodOptions: IOptions
{
[Value(0, MetaName = "path", Required = true, HelpText = "Relative path to resources.")]
public string Path { get; set; } = "";
[Value(1, MetaName = "permission", Required = true, HelpText = "Permission mask.")]
public string Permission { get; set; } = "";
[Option('r', "recursive", Default = false, HelpText = "Recursive change resources.")]
public bool Recursive { get; set; }
public async Task<int> Run()
{
var r = await App.CtlWriteRead<ChmodOptions>([Path, Permission, Recursive.ToString()]);
Console.WriteLine($"Response Code: {r.Head}");
Console.WriteLine($"Params: {string.Join(",", r.Params)}");
return 0;
}
}

View File

@@ -0,0 +1,30 @@
using abyssctl.App.Attributes;
using abyssctl.App.Interfaces;
using abyssctl.Model;
using CommandLine;
namespace abyssctl.App.Modules;
[Module(100)]
[Verb("hello", HelpText = "Say hello to abyss server")]
public class HelloOptions: IOptions
{
[Option('r', "raw", Default = false, HelpText = "Show raw response.")]
public bool Raw { get; set; }
public async Task<int> Run()
{
var r = await App.CtlWriteRead<HelloOptions>([]);
if (Raw)
{
Console.WriteLine($"Response Code: {r.Head}");
Console.WriteLine($"Params: {string.Join(",", r.Params)}");
}
else
{
Console.WriteLine($"Server: {string.Join(",", r.Params)}");
}
return 0;
}
}

View File

@@ -0,0 +1,29 @@
using abyssctl.App.Attributes;
using abyssctl.App.Interfaces;
using CommandLine;
namespace abyssctl.App.Modules;
[Module(105)]
[Verb("include", HelpText = "include resources to system")]
public class IncludeOptions: IOptions
{
[Value(0, MetaName = "path", Required = true, HelpText = "Relative path to resources.")]
public string Path { get; set; } = "";
[Value(1, MetaName = "owner", Required = true, HelpText = "Owner id.")]
public int Id { get; set; }
[Option('r', "recursive", Default = false, HelpText = "Recursive include resources.")]
public bool Recursive { get; set; }
public async Task<int> Run()
{
var r = await App.CtlWriteRead<IncludeOptions>([Path, Id.ToString(), Recursive.ToString()]);
Console.WriteLine($"Response Code: {r.Head}");
Console.WriteLine($"Params: {string.Join(",", r.Params)}");
return 0;
}
}

View File

@@ -0,0 +1,20 @@
using abyssctl.App.Attributes;
using abyssctl.App.Interfaces;
using CommandLine;
namespace abyssctl.App.Modules;
[Module(103)]
[Verb("init", HelpText = "Initialize abyss server")]
public class InitOptions: IOptions
{
public async Task<int> Run()
{
var r = await App.CtlWriteRead<InitOptions>([]);
Console.WriteLine($"Response Code: {r.Head}");
Console.WriteLine($"Params: {string.Join(",", r.Params)}");
return 0;
}
}

View File

@@ -0,0 +1,30 @@
using abyssctl.App.Attributes;
using abyssctl.App.Interfaces;
using CommandLine;
namespace abyssctl.App.Modules;
[Module(107)]
[Verb("list", HelpText = "List items")]
public class ListOptions: IOptions
{
[Value(0, MetaName = "path", Required = true, HelpText = "Relative path to resources.")]
public string Path { get; set; } = "";
public async Task<int> Run()
{
var r = await App.CtlWriteRead<ListOptions>([Path]);
if (r.Head != 200)
{
Console.WriteLine($"Response Code: {r.Head}");
Console.WriteLine($"Params: {string.Join(",", r.Params)}");
}
else
{
Console.WriteLine(r.Params[0]);
}
return 0;
}
}

View File

@@ -0,0 +1,27 @@
using abyssctl.App.Attributes;
using abyssctl.App.Interfaces;
using CommandLine;
namespace abyssctl.App.Modules;
[Module(104)]
[Verb("useradd", HelpText = "Add user")]
public class UserAddOptions: IOptions
{
[Value(0, MetaName = "username", Required = true, HelpText = "Username for new user.")]
public string Username { get; set; } = "";
[Value(1, MetaName = "privilege", Required = true, HelpText = "User privilege.")]
public int Privilege { get; set; }
public async Task<int> Run()
{
var r = await App.CtlWriteRead<UserAddOptions>([Username, Privilege.ToString()]);
Console.WriteLine($"Response Code: {r.Head}");
Console.WriteLine($"Params: {string.Join(",", r.Params)}");
return 0;
}
}

View File

@@ -0,0 +1,16 @@
using abyssctl.App.Attributes;
using abyssctl.App.Interfaces;
using CommandLine;
namespace abyssctl.App.Modules;
[Module(101)]
[Verb("ver", HelpText = "Get server version")]
public class VersionOptions: IOptions
{
public async Task<int> Run()
{
Console.WriteLine("Version");
return 0;
}
}

19
abyssctl/Model/Ctl.cs Normal file
View File

@@ -0,0 +1,19 @@
using System.Text;
using Newtonsoft.Json;
namespace abyssctl.Model;
public class Ctl
{
[JsonProperty("head")] public int Head { get; set; }
[JsonProperty("params")] public string[] Params { get; set; } = [];
public static string MakeBase64(int head, string[] param)
{
return Convert.ToBase64String(
Encoding.UTF8.GetBytes(
JsonConvert.SerializeObject(new Ctl
{ Head = head, Params = param })));
}
}

13
abyssctl/Program.cs Normal file
View File

@@ -0,0 +1,13 @@

namespace abyssctl;
static class Program
{
static async Task<int> Main(string[] args)
{
var app = new App.App();
return await app.RunAsync(args);
}
}

View File

@@ -0,0 +1,50 @@
using System.Net.Sockets;
using System.Text;
namespace abyssctl.Static;
public static class SocketExtensions
{
public static async Task<string> ReadBase64Async(this Socket socket, CancellationToken cancellationToken = default)
{
var buffer = new byte[4096];
var sb = new StringBuilder();
while (true)
{
int bytesRead = await socket.ReceiveAsync(buffer, SocketFlags.None, cancellationToken).ConfigureAwait(false);
if (bytesRead == 0)
throw new SocketException((int)SocketError.ConnectionReset);
string chunk = Encoding.UTF8.GetString(buffer, 0, bytesRead);
sb.Append(chunk);
int newlineIndex = sb.ToString().IndexOf('\n');
if (newlineIndex >= 0)
{
string base64 = sb.ToString(0, newlineIndex).Trim();
sb.Remove(0, newlineIndex + 1);
return base64;
}
}
}
public static async Task WriteBase64Async(this Socket socket, string base64, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(base64))
throw new ArgumentException("Base64 string cannot be null or empty.", nameof(base64));
string message = base64 + "\n";
byte[] data = Encoding.UTF8.GetBytes(message);
int totalSent = 0;
while (totalSent < data.Length)
{
int sent = await socket.SendAsync(data.AsMemory(totalSent), SocketFlags.None, cancellationToken).ConfigureAwait(false);
if (sent == 0)
throw new SocketException((int)SocketError.ConnectionReset);
totalSent += sent;
}
}
}

24
abyssctl/abyssctl.csproj Normal file
View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>13</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>false</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<OutputPath>../build/</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="9.0.9" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="System.CommandLine" Version="2.0.0-rc.1.25451.107" />
</ItemGroup>
</Project>