Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c23a3260aa | ||
|
|
2433175757 | ||
|
|
a0273e3334 | ||
|
|
50eae5e275 | ||
|
|
af6dfbac8c | ||
|
|
dcdd9d840e | ||
|
|
db58091814 | ||
|
|
eb72efb338 | ||
|
|
46ffba7098 | ||
|
|
52c18212a4 | ||
|
|
c8d9e4f9ee | ||
|
|
b2f91175f7 | ||
|
|
79add11c08 | ||
|
|
76bdba1755 | ||
|
|
46ff611706 | ||
|
|
f1e636a79d | ||
|
|
87536a1508 | ||
|
|
a7c522a61f | ||
|
|
3723ea32a7 | ||
|
|
e7d24aa20b | ||
|
|
a2f6eb1fba | ||
|
|
57c37bee51 | ||
|
|
a228d523a2 | ||
|
|
40c041444a | ||
|
|
8465ec5b2a | ||
|
|
ec7306ade2 | ||
|
|
9aa987d52a | ||
|
|
e174238d3c | ||
|
|
cad92f8fa5 | ||
|
|
9b6a4a9982 | ||
|
|
197cf525fb | ||
|
|
ae93b75e41 | ||
|
|
3f90ea9476 | ||
|
|
23b43f15b5 | ||
|
|
d2e11817db | ||
|
|
f9e4510553 | ||
|
|
4ef20f7dc7 | ||
|
|
5c4f1a87d2 | ||
|
|
c14823f971 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -57,4 +57,6 @@ nunit-*.xml
|
||||
*.db
|
||||
|
||||
appsettings.json
|
||||
appsettings.Development.json
|
||||
appsettings.Development.json
|
||||
build/
|
||||
publish/
|
||||
|
||||
2
.idea/.idea.Abyss/.idea/dataSources.local.xml
generated
2
.idea/.idea.Abyss/.idea/dataSources.local.xml
generated
@@ -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>"</identifier-quote-string>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
6
.idea/.idea.Abyss/.idea/data_source_mapping.xml
generated
Normal file
6
.idea/.idea.Abyss/.idea/data_source_mapping.xml
generated
Normal 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>
|
||||
7
.idea/.idea.Abyss/.idea/dictionaries/project.xml
generated
Normal file
7
.idea/.idea.Abyss/.idea/dictionaries/project.xml
generated
Normal 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
6
.idea/.idea.Abyss/.idea/sqldialects.xml
generated
Normal 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>
|
||||
290
.idea/.idea.Abyss/.idea/workspace.xml
generated
290
.idea/.idea.Abyss/.idea/workspace.xml
generated
@@ -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">{
|
||||
"associatedIndex": 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">{
|
||||
"keyToString": {
|
||||
".NET Launch Settings Profile.Abyss: http.executor": "Run",
|
||||
".NET Launch Settings Profile.Abyss: https.executor": "Debug",
|
||||
".NET Project.AbyssCli.executor": "Run",
|
||||
".NET Project.abyssctl.executor": "Debug",
|
||||
"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",
|
||||
"Publish to folder.p1.executor": "Run",
|
||||
"Publish to folder.p2.executor": "Run",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"XThreadsFramesViewSplitterKey": "0.55813956",
|
||||
"git-widget-placeholder": "main",
|
||||
"last_opened_file_path": "/home/acite/AciteProjects/Abyss/README.md",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "preferences.lookFeel",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}]]></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>
|
||||
|
||||
10
Abyss.sln
10
Abyss.sln
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Abyss.Components.Services;
|
||||
using Abyss.Components.Services.Misc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
11
Abyss/Components/Controllers/Media/IndexController.cs
Normal file
11
Abyss/Components/Controllers/Media/IndexController.cs
Normal 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
|
||||
{
|
||||
|
||||
}
|
||||
52
Abyss/Components/Controllers/Media/LiveController.cs
Normal file
52
Abyss/Components/Controllers/Media/LiveController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
明确几个此目录下的API的开发理念:
|
||||
- 永远不传输私钥
|
||||
|
||||
root用户的私钥仅通过服务器shell配置
|
||||
私钥在客户端生成,仅将公钥传输到服务器
|
||||
token通过挑战-响应机制创建,加密传输
|
||||
|
||||
|
||||
- 用户管理
|
||||
|
||||
创建任何新用户都必须通过一个已有用户的token,且新用户权限等级不大于该用户
|
||||
root用户的权限等级为 **114514**
|
||||
|
||||
155
Abyss/Components/Controllers/Security/RootController.cs
Normal file
155
Abyss/Components/Controllers/Security/RootController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
// }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
112
Abyss/Components/Services/Admin/CtlService.cs
Normal file
112
Abyss/Components/Services/Admin/CtlService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Abyss/Components/Services/Admin/Interfaces/IModule.cs
Normal file
8
Abyss/Components/Services/Admin/Interfaces/IModule.cs
Normal 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);
|
||||
}
|
||||
108
Abyss/Components/Services/Admin/Modules/ChmodModule.cs
Normal file
108
Abyss/Components/Services/Admin/Modules/ChmodModule.cs
Normal 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]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Abyss/Components/Services/Admin/Modules/HelloModule.cs
Normal file
18
Abyss/Components/Services/Admin/Modules/HelloModule.cs
Normal 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"],
|
||||
});
|
||||
}
|
||||
}
|
||||
88
Abyss/Components/Services/Admin/Modules/IncludeModule.cs
Normal file
88
Abyss/Components/Services/Admin/Modules/IncludeModule.cs
Normal 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"]
|
||||
};
|
||||
}
|
||||
}
|
||||
58
Abyss/Components/Services/Admin/Modules/InitModule.cs
Normal file
58
Abyss/Components/Services/Admin/Modules/InitModule.cs
Normal 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]
|
||||
};
|
||||
}
|
||||
}
|
||||
80
Abyss/Components/Services/Admin/Modules/ListModule.cs
Normal file
80
Abyss/Components/Services/Admin/Modules/ListModule.cs
Normal 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]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Abyss/Components/Services/Admin/Modules/UserAddModule.cs
Normal file
50
Abyss/Components/Services/Admin/Modules/UserAddModule.cs
Normal 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]
|
||||
};
|
||||
}
|
||||
}
|
||||
14
Abyss/Components/Services/Admin/Modules/VersionModule.cs
Normal file
14
Abyss/Components/Services/Admin/Modules/VersionModule.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
66
Abyss/Components/Services/Media/ComicService.cs
Normal file
66
Abyss/Components/Services/Media/ComicService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
231
Abyss/Components/Services/Media/IndexService.cs
Normal file
231
Abyss/Components/Services/Media/IndexService.cs
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
230
Abyss/Components/Services/Media/ResourceDatabaseService.cs
Normal file
230
Abyss/Components/Services/Media/ResourceDatabaseService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
915
Abyss/Components/Services/Media/ResourceService.cs
Normal file
915
Abyss/Components/Services/Media/ResourceService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
163
Abyss/Components/Services/Media/VideoService.cs
Normal file
163
Abyss/Components/Services/Media/VideoService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
190
Abyss/Components/Services/Security/AbyssService.cs
Normal file
190
Abyss/Components/Services/Security/AbyssService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
293
Abyss/Components/Services/Security/UserService.cs
Normal file
293
Abyss/Components/Services/Security/UserService.cs
Normal 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]+$");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
75
Abyss/Components/Static/ControllerExtensions.cs
Normal file
75
Abyss/Components/Static/ControllerExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
51
Abyss/Components/Static/SocketExtensions.cs
Normal file
51
Abyss/Components/Static/SocketExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
553
Abyss/Components/Tools/AbyssStream.cs
Normal file
553
Abyss/Components/Tools/AbyssStream.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
208
Abyss/Components/Tools/HttpHelper.cs
Normal file
208
Abyss/Components/Tools/HttpHelper.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
277
Abyss/Components/Tools/HttpReader.cs
Normal file
277
Abyss/Components/Tools/HttpReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
244
Abyss/Misc/StringClusterer.cs
Normal file
244
Abyss/Misc/StringClusterer.cs
Normal 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
19
Abyss/Model/Admin/Ctl.cs
Normal 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 })));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Abyss.Model;
|
||||
namespace Abyss.Model.Media;
|
||||
|
||||
public class Bookmark
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Abyss.Model;
|
||||
namespace Abyss.Model.Media;
|
||||
|
||||
public enum ChipState
|
||||
{
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Abyss.Model;
|
||||
namespace Abyss.Model.Media;
|
||||
|
||||
public class Comment
|
||||
{
|
||||
24
Abyss/Model/Media/Index.cs
Normal file
24
Abyss/Model/Media/Index.cs
Normal 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; } = "";
|
||||
}
|
||||
17
Abyss/Model/Media/ResourceAttribute.cs
Normal file
17
Abyss/Model/Media/ResourceAttribute.cs
Normal 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; } = "--,--,--";
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Abyss.Model;
|
||||
namespace Abyss.Model.Media;
|
||||
|
||||
public class TaskCreation
|
||||
{
|
||||
27
Abyss/Model/Media/Video.cs
Normal file
27
Abyss/Model/Media/Video.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; } = "--,--,--";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Abyss.Model;
|
||||
namespace Abyss.Model.Security;
|
||||
|
||||
public class ChallengeResponse
|
||||
{
|
||||
18
Abyss/Model/Security/User.cs
Normal file
18
Abyss/Model/Security/User.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
321
Abyss/Toolkits/abyss-cli.py
Normal file
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
# abysscli.py
|
||||
"""
|
||||
Abyss CLI — Python 3 实现
|
||||
Commands:
|
||||
open <baseUrl> <user> <privateKeyBase64>
|
||||
destroy <baseUrl> <token>
|
||||
valid <baseUrl> <token>
|
||||
create <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import typing as t
|
||||
from urllib.parse import quote
|
||||
import requests
|
||||
from requests import Session
|
||||
from nacl.signing import SigningKey
|
||||
from nacl.exceptions import BadSignatureError
|
||||
|
||||
# ---- Utilities for Ed25519 handling ----
|
||||
def generate_keypair_base64() -> t.Tuple[str, str]:
|
||||
"""
|
||||
Generate Ed25519 keypair.
|
||||
Returns (private_base64, public_base64).
|
||||
private is encoded as 64 bytes: seed(32) || pub(32) to align with many raw-private formats.
|
||||
public is 32 bytes.
|
||||
"""
|
||||
sk = SigningKey.generate()
|
||||
seed = sk.encode() # 32 bytes seed
|
||||
vk = sk.verify_key.encode() # 32 bytes pubkey
|
||||
priv_raw = seed + vk # 64 bytes
|
||||
return base64.b64encode(priv_raw).decode('ascii'), base64.b64encode(vk).decode('ascii')
|
||||
|
||||
def sign_with_private_base64(private_base64: str, data: bytes) -> str:
|
||||
"""
|
||||
Accept private key as base64. Supports:
|
||||
- 32-byte seed (seed only)
|
||||
- 64-byte raw private (seed + pub)
|
||||
Returns base64(signature).
|
||||
"""
|
||||
try:
|
||||
raw = base64.b64decode(private_base64)
|
||||
except Exception as e:
|
||||
raise ValueError(f"privateKeyBase64 is not valid base64: {e}")
|
||||
if len(raw) == 32:
|
||||
seed = raw
|
||||
elif len(raw) == 64:
|
||||
seed = raw[:32]
|
||||
else:
|
||||
raise ValueError(f"Unsupported private key length: {len(raw)} bytes (expected 32 or 64)")
|
||||
sk = SigningKey(seed)
|
||||
sig = sk.sign(data).signature # 64 bytes
|
||||
return base64.b64encode(sig).decode('ascii')
|
||||
|
||||
# ---- HTTP helpers ----
|
||||
def create_session(base_url: str) -> Session:
|
||||
s = requests.Session()
|
||||
base = base_url.rstrip('/')
|
||||
s.headers.update({'User-Agent': 'AbyssCli-Python/1.0'})
|
||||
s.base_url = base + '/' # attach attribute for convenience
|
||||
return s
|
||||
|
||||
def _full_url(session: Session, path: str) -> str:
|
||||
base = getattr(session, 'base_url', '')
|
||||
# ensure no double slashes issues
|
||||
return base + path.lstrip('/')
|
||||
|
||||
def try_read_response_text(resp: requests.Response) -> str:
|
||||
try:
|
||||
return resp.text or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def parse_possibly_json_string(text: str) -> str:
|
||||
"""
|
||||
Server sometimes returns a JSON-encoded string like: "username"
|
||||
Try json.loads first, fall back to trimming quotes.
|
||||
"""
|
||||
if text is None:
|
||||
return ""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
# If parsed is a string, return it; otherwise return original trimmed
|
||||
if isinstance(parsed, str):
|
||||
return parsed
|
||||
# otherwise return textual representation
|
||||
return str(parsed)
|
||||
except Exception:
|
||||
# fallback trim quotes only at ends if present
|
||||
if text.startswith('"') and text.endswith('"') and len(text) >= 2:
|
||||
return text[1:-1]
|
||||
return text
|
||||
|
||||
# ---- Command implementations ----
|
||||
def cmd_open(args: argparse.Namespace) -> int:
|
||||
base = args.baseUrl
|
||||
user = args.user
|
||||
priv_base64 = args.privateKeyBase64
|
||||
|
||||
sess = create_session(base)
|
||||
|
||||
# 1. GET challenge
|
||||
url = _full_url(sess, f"api/user/{quote(user, safe='')}")
|
||||
try:
|
||||
r = sess.get(url, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"Failed to GET challenge: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not r.ok:
|
||||
print(f"Failed to get challenge: HTTP {r.status_code}", file=sys.stderr)
|
||||
txt = try_read_response_text(r)
|
||||
if txt:
|
||||
print(txt, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
challenge_text = try_read_response_text(r)
|
||||
challenge = parse_possibly_json_string(challenge_text)
|
||||
# challenge is expected to be base64-encoded bytes
|
||||
try:
|
||||
challenge_bytes = base64.b64decode(challenge)
|
||||
except Exception:
|
||||
print("Challenge is not valid base64.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 2. Sign
|
||||
try:
|
||||
signature_base64 = sign_with_private_base64(priv_base64, challenge_bytes)
|
||||
except Exception as e:
|
||||
print(f"Signing failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 3. POST response to get token
|
||||
post_url = _full_url(sess, f"api/user/{quote(user, safe='')}")
|
||||
payload = {"Response": signature_base64}
|
||||
try:
|
||||
r2 = sess.post(post_url, json=payload, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"Failed to POST response: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not r2.ok:
|
||||
print(f"Authentication failed: HTTP {r2.status_code}", file=sys.stderr)
|
||||
txt = try_read_response_text(r2)
|
||||
if txt:
|
||||
print(txt, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
token_text = try_read_response_text(r2)
|
||||
token = parse_possibly_json_string(token_text)
|
||||
if not token:
|
||||
print("Authentication failed or server returned no token.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(token)
|
||||
return 0
|
||||
|
||||
def cmd_destroy(args: argparse.Namespace) -> int:
|
||||
base = args.baseUrl
|
||||
token = args.token
|
||||
sess = create_session(base)
|
||||
url = _full_url(sess, f"api/user/destroy?token={quote(token, safe='')}")
|
||||
try:
|
||||
r = sess.post(url, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"Destroy request failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not r.ok:
|
||||
print(f"Destroy failed: HTTP {r.status_code}", file=sys.stderr)
|
||||
txt = try_read_response_text(r)
|
||||
if txt:
|
||||
print(txt, file=sys.stderr)
|
||||
return 1
|
||||
# some servers return body, but original prints "Success"
|
||||
print("Success")
|
||||
return 0
|
||||
|
||||
def cmd_valid(args: argparse.Namespace) -> int:
|
||||
base = args.baseUrl
|
||||
token = args.token
|
||||
sess = create_session(base)
|
||||
url = _full_url(sess, f"api/user/validate?token={quote(token, safe='')}")
|
||||
try:
|
||||
r = sess.post(url, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"Validate request failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not r.ok:
|
||||
print("Invalid")
|
||||
return 1
|
||||
content = try_read_response_text(r)
|
||||
username = parse_possibly_json_string(content)
|
||||
if not username:
|
||||
print("Invalid")
|
||||
return 1
|
||||
print(username)
|
||||
return 0
|
||||
|
||||
def cmd_create(args: argparse.Namespace) -> int:
|
||||
base = args.baseUrl
|
||||
user = args.user
|
||||
priv_base64 = args.privateKeyBase64
|
||||
new_username = args.newUsername
|
||||
privilege = args.privilege
|
||||
|
||||
sess = create_session(base)
|
||||
|
||||
# 1. Get challenge for creator
|
||||
url = _full_url(sess, f"api/user/{quote(user, safe='')}")
|
||||
try:
|
||||
r = sess.get(url, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"Failed to GET challenge for creator: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not r.ok:
|
||||
print(f"Failed to get challenge for creator: HTTP {r.status_code}", file=sys.stderr)
|
||||
txt = try_read_response_text(r)
|
||||
if txt:
|
||||
print(txt, file=sys.stderr)
|
||||
return 1
|
||||
challenge_text = try_read_response_text(r)
|
||||
challenge = parse_possibly_json_string(challenge_text)
|
||||
try:
|
||||
challenge_bytes = base64.b64decode(challenge)
|
||||
except Exception:
|
||||
print("Challenge is not valid base64.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 2. Sign challenge with creator private key
|
||||
try:
|
||||
signature_base64 = sign_with_private_base64(priv_base64, challenge_bytes)
|
||||
except Exception as e:
|
||||
print(f"Signing failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 3. Generate key pair for new user
|
||||
new_priv_b64, new_pub_b64 = generate_keypair_base64()
|
||||
|
||||
# 4. Build create payload and PATCH
|
||||
payload = {
|
||||
"Response": signature_base64,
|
||||
"Name": new_username,
|
||||
"Parent": user,
|
||||
"Privilege": int(privilege),
|
||||
"PublicKey": new_pub_b64
|
||||
}
|
||||
patch_url = _full_url(sess, f"api/user/{quote(user, safe='')}")
|
||||
try:
|
||||
r2 = sess.request("PATCH", patch_url, json=payload, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"Create request failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
resp_text = try_read_response_text(r2)
|
||||
if not r2.ok:
|
||||
print(f"Create failed: HTTP {r2.status_code}", file=sys.stderr)
|
||||
if resp_text:
|
||||
print(resp_text, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("Success")
|
||||
print("NewUserPrivateKeyBase64:")
|
||||
print(new_priv_b64)
|
||||
print("NewUserPublicKeyBase64:")
|
||||
print(new_pub_b64)
|
||||
return 0
|
||||
|
||||
# ---- CLI entrypoint ----
|
||||
def main(argv: t.Optional[t.List[str]] = None) -> int:
|
||||
if argv is None:
|
||||
argv = sys.argv[1:]
|
||||
parser = argparse.ArgumentParser(prog="AbyssCli", description="Abyss CLI (Python)")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
p_open = sub.add_parser("open", help="open <baseUrl> <user> <privateKeyBase64>")
|
||||
p_open.add_argument("baseUrl")
|
||||
p_open.add_argument("user")
|
||||
p_open.add_argument("privateKeyBase64")
|
||||
|
||||
p_destroy = sub.add_parser("destroy", help="destroy <baseUrl> <token>")
|
||||
p_destroy.add_argument("baseUrl")
|
||||
p_destroy.add_argument("token")
|
||||
|
||||
p_valid = sub.add_parser("valid", help="valid <baseUrl> <token>")
|
||||
p_valid.add_argument("baseUrl")
|
||||
p_valid.add_argument("token")
|
||||
|
||||
p_create = sub.add_parser("create", help="create <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>")
|
||||
p_create.add_argument("baseUrl")
|
||||
p_create.add_argument("user")
|
||||
p_create.add_argument("privateKeyBase64")
|
||||
p_create.add_argument("newUsername")
|
||||
p_create.add_argument("privilege", type=int)
|
||||
|
||||
if not argv:
|
||||
parser.print_help()
|
||||
return 1
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.cmd == "open":
|
||||
return cmd_open(args)
|
||||
elif args.cmd == "destroy":
|
||||
return cmd_destroy(args)
|
||||
elif args.cmd == "valid":
|
||||
return cmd_valid(args)
|
||||
elif args.cmd == "create":
|
||||
return cmd_create(args)
|
||||
else:
|
||||
print("Unknown command.", file=sys.stderr)
|
||||
parser.print_help()
|
||||
return 2
|
||||
except Exception as ex:
|
||||
print(f"Error: {ex}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,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)
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PublishAot>true</PublishAot>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,364 +0,0 @@
|
||||
// Program.cs
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using NSec.Cryptography;
|
||||
|
||||
public class ChallengeRequestBody
|
||||
{
|
||||
public string Response { get; set; } = "";
|
||||
}
|
||||
|
||||
public class CreateRequestBody
|
||||
{
|
||||
public string Response { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Parent { get; set; } = "";
|
||||
public int Privilege { get; set; }
|
||||
public string PublicKey { get; set; } = "";
|
||||
}
|
||||
|
||||
public static class Ed25519Utils
|
||||
{
|
||||
public static (string privateBase64, string publicBase64) GenerateKeyPairBase64()
|
||||
{
|
||||
var algorithm = SignatureAlgorithm.Ed25519;
|
||||
var creationParameters = new KeyCreationParameters
|
||||
{
|
||||
ExportPolicy = KeyExportPolicies.AllowPlaintextExport
|
||||
};
|
||||
using var key = Key.Create(algorithm, creationParameters);
|
||||
var priv = key.Export(KeyBlobFormat.RawPrivateKey);
|
||||
var pub = key.Export(KeyBlobFormat.RawPublicKey);
|
||||
return (Convert.ToBase64String(priv), Convert.ToBase64String(pub));
|
||||
}
|
||||
|
||||
public static string SignBase64PrivateKey(string privateKeyBase64, byte[] dataToSign)
|
||||
{
|
||||
var algorithm = SignatureAlgorithm.Ed25519;
|
||||
var privateBytes = Convert.FromBase64String(privateKeyBase64);
|
||||
using var key = Key.Import(algorithm, privateBytes, KeyBlobFormat.RawPrivateKey);
|
||||
var sig = algorithm.Sign(key, dataToSign);
|
||||
return Convert.ToBase64String(sig);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Program
|
||||
{
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
if (args == null || args.Length == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
var cmd = args[0].ToLowerInvariant();
|
||||
try
|
||||
{
|
||||
switch (cmd)
|
||||
{
|
||||
case "open":
|
||||
return await CmdOpen(args);
|
||||
case "destroy":
|
||||
return await CmdDestroy(args);
|
||||
case "valid":
|
||||
return await CmdValid(args);
|
||||
case "create":
|
||||
return await CmdCreate(args);
|
||||
default:
|
||||
Console.Error.WriteLine("Unknown command.");
|
||||
PrintUsage();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" AbyssCli open <baseUrl> <user> <privateKeyBase64>");
|
||||
Console.WriteLine(" AbyssCli destroy <baseUrl> <token>");
|
||||
Console.WriteLine(" AbyssCli valid <baseUrl> <token>");
|
||||
Console.WriteLine(" AbyssCli create <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>");
|
||||
}
|
||||
|
||||
static HttpClient CreateHttpClient(string baseUrl)
|
||||
{
|
||||
var client = new HttpClient();
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
return client;
|
||||
}
|
||||
|
||||
static async Task<int> CmdOpen(string[] args)
|
||||
{
|
||||
if (args.Length != 4)
|
||||
{
|
||||
Console.Error.WriteLine("open requires 3 arguments: <baseUrl> <user> <privateKeyBase64>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var baseUrl = args[1];
|
||||
var user = args[2];
|
||||
var privateKeyBase64 = args[3];
|
||||
|
||||
using var client = CreateHttpClient(baseUrl);
|
||||
|
||||
// 1. GET challenge
|
||||
var challenge = await GetChallenge(client, user);
|
||||
if (challenge == null)
|
||||
{
|
||||
Console.Error.WriteLine("Failed to get challenge.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 2. Sign challenge (challenge is base64 string)
|
||||
byte[] challengeBytes;
|
||||
try
|
||||
{
|
||||
challengeBytes = Convert.FromBase64String(challenge);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.Error.WriteLine("Challenge is not valid base64.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string signatureBase64;
|
||||
try
|
||||
{
|
||||
signatureBase64 = Ed25519Utils.SignBase64PrivateKey(privateKeyBase64, challengeBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Signing failed: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 3. POST response to get token
|
||||
var token = await PostResponseForToken(client, user, signatureBase64);
|
||||
if (token == null)
|
||||
{
|
||||
Console.Error.WriteLine("Authentication failed or server returned no token.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine(token);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> CmdDestroy(string[] args)
|
||||
{
|
||||
if (args.Length != 3)
|
||||
{
|
||||
Console.Error.WriteLine("destroy requires 2 arguments: <baseUrl> <token>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var baseUrl = args[1];
|
||||
var token = args[2];
|
||||
|
||||
using var client = CreateHttpClient(baseUrl);
|
||||
|
||||
var resp = await client.PostAsync($"api/user/destroy?token={token}", null);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
Console.Error.WriteLine($"Destroy failed: {resp.StatusCode}");
|
||||
var txt = await TryReadResponseText(resp);
|
||||
if (!string.IsNullOrEmpty(txt)) Console.Error.WriteLine(txt);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Console.WriteLine("Success");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> CmdValid(string[] args)
|
||||
{
|
||||
if (args.Length != 3)
|
||||
{
|
||||
Console.Error.WriteLine("valid requires 2 arguments: <baseUrl> <token>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var baseUrl = args[1];
|
||||
var token = args[2];
|
||||
|
||||
using var client = CreateHttpClient(baseUrl);
|
||||
|
||||
var resp = await client.PostAsync($"api/user/validate?token={token}", null);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine("Invalid");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var content = await resp.Content.ReadAsStringAsync();
|
||||
// server likely returns JSON string (e.g. "username"), try to parse JSON string
|
||||
try
|
||||
{
|
||||
var username = JsonSerializer.Deserialize<string>(content, jsonOptions);
|
||||
if (username == null)
|
||||
{
|
||||
Console.WriteLine("Invalid");
|
||||
return 1;
|
||||
}
|
||||
Console.WriteLine(username);
|
||||
return 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fallback
|
||||
Console.WriteLine(content.Trim('"'));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<int> CmdCreate(string[] args)
|
||||
{
|
||||
if (args.Length != 6)
|
||||
{
|
||||
Console.Error.WriteLine("create requires 5 arguments: <baseUrl> <user> <privateKeyBase64> <newUsername> <privilege>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var baseUrl = args[1];
|
||||
var user = args[2];
|
||||
var privateKeyBase64 = args[3];
|
||||
var newUsername = args[4];
|
||||
if (!int.TryParse(args[5], out var privilege))
|
||||
{
|
||||
Console.Error.WriteLine("Privilege must be an integer.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
using var client = CreateHttpClient(baseUrl);
|
||||
|
||||
// 1. Get challenge for creator user
|
||||
var challenge = await GetChallenge(client, user);
|
||||
if (challenge == null)
|
||||
{
|
||||
Console.Error.WriteLine("Failed to get challenge for creator.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
byte[] challengeBytes;
|
||||
try
|
||||
{
|
||||
challengeBytes = Convert.FromBase64String(challenge);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.Error.WriteLine("Challenge is not valid base64.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string signatureBase64;
|
||||
try
|
||||
{
|
||||
signatureBase64 = Ed25519Utils.SignBase64PrivateKey(privateKeyBase64, challengeBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Signing failed: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 2. Generate key pair for new user
|
||||
var (newPrivBase64, newPubBase64) = Ed25519Utils.GenerateKeyPairBase64();
|
||||
|
||||
// 3. Build create payload
|
||||
var payload = new CreateRequestBody
|
||||
{
|
||||
Response = signatureBase64,
|
||||
Name = newUsername,
|
||||
Parent = user,
|
||||
Privilege = privilege,
|
||||
PublicKey = newPubBase64
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, jsonOptions);
|
||||
var request = new HttpRequestMessage(new HttpMethod("PATCH"), $"api/user/{Uri.EscapeDataString(user)}")
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
var resp = await client.SendAsync(request);
|
||||
var respText = await TryReadResponseText(resp);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
Console.Error.WriteLine($"Create failed: {resp.StatusCode}");
|
||||
if (!string.IsNullOrEmpty(respText)) Console.Error.WriteLine(respText);
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine("Success");
|
||||
Console.WriteLine("NewUserPrivateKeyBase64:");
|
||||
Console.WriteLine(newPrivBase64);
|
||||
Console.WriteLine("NewUserPublicKeyBase64:");
|
||||
Console.WriteLine(newPubBase64);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<string?> GetChallenge(HttpClient client, string user)
|
||||
{
|
||||
var resp = await client.GetAsync($"api/user/{Uri.EscapeDataString(user)}");
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var content = await resp.Content.ReadAsStringAsync();
|
||||
|
||||
// server probably returns JSON string; try to deserialize to string
|
||||
try
|
||||
{
|
||||
var s = JsonSerializer.Deserialize<string>(content, jsonOptions);
|
||||
if (s != null) return s;
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
|
||||
// fallback: trim quotes
|
||||
return content.Trim('"');
|
||||
}
|
||||
|
||||
static async Task<string?> PostResponseForToken(HttpClient client, string user, string signatureBase64)
|
||||
{
|
||||
var body = new ChallengeRequestBody { Response = signatureBase64 };
|
||||
var json = JsonSerializer.Serialize(body, jsonOptions);
|
||||
var resp = await client.PostAsync($"api/user/{Uri.EscapeDataString(user)}",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var content = await resp.Content.ReadAsStringAsync();
|
||||
try
|
||||
{
|
||||
var token = JsonSerializer.Deserialize<string>(content, jsonOptions);
|
||||
if (!string.IsNullOrEmpty(token)) return token;
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
return content.Trim('"');
|
||||
}
|
||||
|
||||
static async Task<string> TryReadResponseText(HttpResponseMessage resp)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await resp.Content.ReadAsStringAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
static readonly JsonSerializerOptions jsonOptions = new()
|
||||
{
|
||||
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
|
||||
};
|
||||
}
|
||||
62
README.md
62
README.md
@@ -1,16 +1,66 @@
|
||||
_<div align="center">
|
||||
<div align="center">
|
||||
|
||||
# Abyss (Server for Aether)
|
||||
|
||||
[](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_clip.png
Normal file
BIN
abyss_clip.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 510 KiB |
56
abyssctl/App/App.cs
Normal file
56
abyssctl/App/App.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
28
abyssctl/App/Attributes/ModuleAttribute.cs
Normal file
28
abyssctl/App/Attributes/ModuleAttribute.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
6
abyssctl/App/Interfaces/IOptions.cs
Normal file
6
abyssctl/App/Interfaces/IOptions.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace abyssctl.App.Interfaces;
|
||||
|
||||
public interface IOptions
|
||||
{
|
||||
public Task<int> Run();
|
||||
}
|
||||
28
abyssctl/App/Modules/ChmodOptions.cs
Normal file
28
abyssctl/App/Modules/ChmodOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
30
abyssctl/App/Modules/HelloOptions.cs
Normal file
30
abyssctl/App/Modules/HelloOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
29
abyssctl/App/Modules/IncludeOptions.cs
Normal file
29
abyssctl/App/Modules/IncludeOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
20
abyssctl/App/Modules/InitOptions.cs
Normal file
20
abyssctl/App/Modules/InitOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
30
abyssctl/App/Modules/ListOptions.cs
Normal file
30
abyssctl/App/Modules/ListOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
27
abyssctl/App/Modules/UserAddOptions.cs
Normal file
27
abyssctl/App/Modules/UserAddOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
16
abyssctl/App/Modules/VersionOptions.cs
Normal file
16
abyssctl/App/Modules/VersionOptions.cs
Normal 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
19
abyssctl/Model/Ctl.cs
Normal 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
13
abyssctl/Program.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
50
abyssctl/Static/SocketExtensions.cs
Normal file
50
abyssctl/Static/SocketExtensions.cs
Normal 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
24
abyssctl/abyssctl.csproj
Normal 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>
|
||||
Reference in New Issue
Block a user