Compare commits

15 Commits

Author SHA1 Message Date
rootacite
815c88f2bd [feat] Image achieve download. 2025-11-01 23:39:12 +08:00
rootacite
c23a3260aa [feat] User announce. 2025-10-27 12:25:04 +08:00
acite
2433175757 [feat] Abyssctl Basic functions 2 2025-10-05 21:43:20 +08:00
acite
a0273e3334 [feat] Abyssctl Basic functions 2025-10-05 16:48:54 +08:00
acite
50eae5e275 [feat] Abyssctl Automatic module discovery 2025-10-05 11:41:06 +08:00
acite
af6dfbac8c [feat] Ctl framework 2025-10-05 03:00:26 +08:00
acite
dcdd9d840e [optimize] Merge user logic to service 2025-10-02 18:47:15 +08:00
acite
db58091814 [feat] Add cover felid to Comic model 2025-10-01 18:49:55 +08:00
acite
eb72efb338 [doc] Doc Update 2025-09-29 20:49:29 +08:00
acite
46ffba7098 [update] Remove token from Query param, move to cookies 2025-09-29 01:18:37 +08:00
acite
52c18212a4 [optimize] Phase 1 Architecture Review 2025-09-28 22:03:15 +08:00
acite
c8d9e4f9ee [feat] Add Merge Command to Script 2025-09-27 01:17:05 +08:00
acite
b2f91175f7 [feat] Add support for grouping in management scripts 2025-09-27 00:51:23 +08:00
acite
79add11c08 [feat] Video Group 2025-09-25 23:54:09 +08:00
acite
76bdba1755 [feat] Index Service 2025-09-25 15:14:02 +08:00
75 changed files with 2963 additions and 886 deletions

2
.gitignore vendored
View File

@@ -58,3 +58,5 @@ nunit-*.xml
appsettings.json appsettings.json
appsettings.Development.json appsettings.Development.json
build/
publish/

View File

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

View File

@@ -499,7 +499,7 @@
</routine> </routine>
<schema id="191" parent="1" name="main"> <schema id="191" parent="1" name="main">
<Current>1</Current> <Current>1</Current>
<LastIntrospectionLocalTimestamp>2025-08-23.08:35:53</LastIntrospectionLocalTimestamp> <LastIntrospectionLocalTimestamp>2025-09-25.10:18:41</LastIntrospectionLocalTimestamp>
</schema> </schema>
<argument id="192" parent="16"> <argument id="192" parent="16">
<ArgumentDirection>R</ArgumentDirection> <ArgumentDirection>R</ArgumentDirection>
@@ -1590,45 +1590,72 @@
<argument id="554" parent="190"> <argument id="554" parent="190">
<Position>1</Position> <Position>1</Position>
</argument> </argument>
<table id="555" parent="191" name="User"/> <table id="555" parent="191" name="Users"/>
<table id="556" parent="191" name="sqlite_master"> <table id="556" parent="191" name="sqlite_master">
<System>1</System> <System>1</System>
</table> </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> <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> <StoredType>integer|0s</StoredType>
</column> </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> <Position>1</Position>
<StoredType>TEXT|0s</StoredType> <StoredType>TEXT|0s</StoredType>
</column> </column>
<column id="562" parent="556" name="name"> <column id="566" parent="556" name="name">
<Position>2</Position> <Position>2</Position>
<StoredType>TEXT|0s</StoredType> <StoredType>TEXT|0s</StoredType>
</column> </column>
<column id="563" parent="556" name="tbl_name"> <column id="567" parent="556" name="tbl_name">
<Position>3</Position> <Position>3</Position>
<StoredType>TEXT|0s</StoredType> <StoredType>TEXT|0s</StoredType>
</column> </column>
<column id="564" parent="556" name="rootpage"> <column id="568" parent="556" name="rootpage">
<Position>4</Position> <Position>4</Position>
<StoredType>INT|0s</StoredType> <StoredType>INT|0s</StoredType>
</column> </column>
<column id="565" parent="556" name="sql"> <column id="569" parent="556" name="sql">
<Position>5</Position> <Position>5</Position>
<StoredType>TEXT|0s</StoredType> <StoredType>TEXT|0s</StoredType>
</column> </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> </database-model>
</dataSource> </dataSource>

View File

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

View File

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

View File

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

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

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

View File

@@ -4,12 +4,17 @@
<projectFile profileName="http">Abyss/Abyss.csproj</projectFile> <projectFile profileName="http">Abyss/Abyss.csproj</projectFile>
<projectFile profileName="https">Abyss/Abyss.csproj</projectFile> <projectFile profileName="https">Abyss/Abyss.csproj</projectFile>
<projectFile>AbyssCli/AbyssCli.csproj</projectFile> <projectFile>AbyssCli/AbyssCli.csproj</projectFile>
<projectFile>abyssctl/abyssctl.csproj</projectFile>
</component> </component>
<component name="AutoImportSettings"> <component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="bf317275-3039-49bb-a475-725a800a0cce" name="Changes" comment="" /> <list default="true" id="bf317275-3039-49bb-a475-725a800a0cce" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.idea/.idea.Abyss/.idea/dataSources.local.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.Abyss/.idea/dataSources.local.xml" 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/Components/Services/Media/ComicService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Services/Media/ComicService.cs" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
@@ -23,45 +28,75 @@
</component> </component>
<component name="HighlightingSettingsPerFile"> <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/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/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/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/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/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/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/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://$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/AbyssController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Media/LiveController.cs" root0="SKIP_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Media/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/Media/LiveController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Media/LiveController.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Middleware/BadRequestExceptionMiddleware.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Middleware/BadRequestExceptionMiddleware.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Security/RootController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/RootController.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///home/acite/embd/WebProjects/Abyss/Abyss/Components/Controllers/Security/RootController.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Security/UserController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Task/TaskController.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Components/Controllers/Task/TaskController.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Services/AbyssService.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/ConfigureService.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/ResourceService.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/TaskService.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/UserService.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="FORCE_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/Components/Static/Helpers.cs" 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/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/HttpHelper.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Components/Tools/HttpReader.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Components/Tools/HttpReader.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Bookmark.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Misc/StringClusterer.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/ChallengeResponse.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Admin/Ctl.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Chip.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Bookmark.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Comic.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Chip.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Comment.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Comic.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/ResourceAttribute.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Comment.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Task.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Index.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/TaskCreation.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Media/ResourceAttribute.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/User.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Task.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/UserCreating.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Media/TaskCreation.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/Abyss/Model/Video.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Media/Video.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/AbyssCli/Program.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$PROJECT_DIR$/Abyss/Model/Security/ChallengeResponse.cs" 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/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>
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" /> <component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
<component name="ProblemsViewState"> <component name="ProblemsViewState">
@@ -77,49 +112,52 @@
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent">{
&quot;keyToString&quot;: { &quot;keyToString&quot;: {
&quot;.NET Launch Settings Profile.Abyss: http.executor&quot;: &quot;Debug&quot;, &quot;.NET Launch Settings Profile.Abyss: http.executor&quot;: &quot;Run&quot;,
&quot;.NET Launch Settings Profile.Abyss: https.executor&quot;: &quot;Debug&quot;, &quot;.NET Launch Settings Profile.Abyss: https.executor&quot;: &quot;Debug&quot;,
&quot;.NET Project.AbyssCli.executor&quot;: &quot;Run&quot;, &quot;.NET Project.AbyssCli.executor&quot;: &quot;Run&quot;,
&quot;.NET Project.abyssctl.executor&quot;: &quot;Debug&quot;,
&quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;, &quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;, &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;Publish to folder.Publish Abyss to folder x86.executor&quot;: &quot;Run&quot;, &quot;Publish to folder.Publish Abyss to folder x86.executor&quot;: &quot;Run&quot;,
&quot;Publish to folder.Publish Abyss to folder.executor&quot;: &quot;Run&quot;, &quot;Publish to folder.Publish Abyss to folder.executor&quot;: &quot;Run&quot;,
&quot;Publish to folder.p1.executor&quot;: &quot;Run&quot;,
&quot;Publish to folder.p2.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;, &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;XThreadsFramesViewSplitterKey&quot;: &quot;0.30266345&quot;, &quot;XThreadsFramesViewSplitterKey&quot;: &quot;0.55813956&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;, &quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/acite/embd/WebProjects/Abyss/README.md&quot;, &quot;last_opened_file_path&quot;: &quot;/home/acite/AciteProjects/Abyss/README.md&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;, &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable&quot;, &quot;settings.editor.selected.configurable&quot;: &quot;preferences.lookFeel&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot; &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}</component> }</component>
<component name="RunManager" selected="Publish to folder.Publish Abyss to folder"> <component name="RunManager" selected="Publish to folder.p1">
<configuration name="Publish Abyss to folder x86" type="DotNetFolderPublish" factoryName="Publish to folder"> <configuration name="p1" type="DotNetFolderPublish" factoryName="Publish to folder" singleton="false">
<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"> <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-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">
<runtimes> <runtimes>
<item value="linux-arm64" /> <item value="linux-arm64" />
</runtimes> </runtimes>
</riderPublish> </riderPublish>
<method v="2" /> <method v="2" />
</configuration> </configuration>
<configuration name="AbyssCli" type="DotNetProject" factoryName=".NET Project"> <configuration name="p2" type="DotNetFolderPublish" factoryName="Publish to folder">
<option name="EXE_PATH" value="" /> <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">
<option name="PROGRAM_PARAMETERS" value="" /> <runtimes>
<option name="WORKING_DIRECTORY" value="" /> <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="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" /> <option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" /> <option name="ENV_FILE_PATHS" value="" />
@@ -129,12 +167,12 @@
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" /> <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_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="" /> <option name="PROJECT_TFM" value="net9.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>
@@ -157,9 +195,9 @@
</configuration> </configuration>
<list> <list>
<item itemvalue=".NET Launch Settings Profile.Abyss: http" /> <item itemvalue=".NET Launch Settings Profile.Abyss: http" />
<item itemvalue=".NET Project.AbyssCli" /> <item itemvalue=".NET Project.abyssctl" />
<item itemvalue="Publish to folder.Publish Abyss to folder" /> <item itemvalue="Publish to folder.p1" />
<item itemvalue="Publish to folder.Publish Abyss to folder x86" /> <item itemvalue="Publish to folder.p2" />
</list> </list>
</component> </component>
<component name="TaskManager"> <component name="TaskManager">
@@ -229,6 +267,58 @@
<workItem from="1758349710909" duration="16000" /> <workItem from="1758349710909" duration="16000" />
<workItem from="1758350096355" duration="452000" /> <workItem from="1758350096355" duration="452000" />
<workItem from="1758350848039" duration="946000" /> <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" />
<workItem from="1761539035937" duration="91000" />
<workItem from="1761757068457" duration="722000" />
<workItem from="1761825530863" duration="1000" />
<workItem from="1761825565817" duration="897000" />
<workItem from="1761841595489" duration="267000" />
<workItem from="1761988700826" duration="6000" />
<workItem from="1762005816621" duration="2025000" />
<workItem from="1762010975464" duration="363000" />
</task> </task>
<servers /> <servers />
</component> </component>

View File

@@ -2,6 +2,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Abyss", "Abyss\Abyss.csproj", "{3337C1CD-2419-4922-BC92-AF1A825DDF23}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Abyss", "Abyss\Abyss.csproj", "{3337C1CD-2419-4922-BC92-AF1A825DDF23}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "abyssctl", "abyssctl\abyssctl.csproj", "{F6DEF111-0CAD-4DCC-8957-7EBAFCF3D2C4}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -12,5 +14,9 @@ Global
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Debug|Any CPU.Build.0 = Debug|Any CPU {3337C1CD-2419-4922-BC92-AF1A825DDF23}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.ActiveCfg = Release|Any CPU {3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3337C1CD-2419-4922-BC92-AF1A825DDF23}.Release|Any CPU.Build.0 = Release|Any CPU {3337C1CD-2419-4922-BC92-AF1A825DDF23}.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 EndGlobalSection
EndGlobal EndGlobal

View File

@@ -1,9 +1,15 @@
<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"> <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_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_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_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_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_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_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_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_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>

View File

@@ -6,6 +6,10 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<OutputPath>../build/</OutputPath>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="K4os.Hash.xxHash" Version="1.0.8" /> <PackageReference Include="K4os.Hash.xxHash" Version="1.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/>

View File

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

View File

@@ -1,102 +1,63 @@
using Abyss.Components.Services; using Abyss.Components.Services.Media;
using Abyss.Components.Static; using Abyss.Components.Static;
using Abyss.Components.Tools; using Abyss.Components.Tools;
using Abyss.Model; using Abyss.Model.Media;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Abyss.Components.Controllers.Media; namespace Abyss.Components.Controllers.Media;
using System.IO;
using Task = System.Threading.Tasks.Task;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class ImageController(ResourceService rs, ConfigureService config) : BaseController public class ImageController(ComicService comicService) : BaseController
{ {
public readonly string ImageFolder = Path.Combine(config.MediaRoot, "Images");
[HttpPost("init")] [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); var r = await comicService.InitAsync(Token, owner, Ip);
if(r) return Ok(r); return r ? Ok("Initialize Success") : _403;
return StatusCode(403, new { message = "403 Denied" });
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> QueryCollections(string token) public async Task<IActionResult> QueryCollections()
{ {
var r = await rs.Query(ImageFolder, token, Ip); var r = await comicService.QueryCollections(Token, Ip);
return r != null ? Ok(r.NaturalSort(x => x)) : _403;
if(r == null)
return StatusCode(401, new { message = "Unauthorized" });
return Ok(r.NaturalSort(x => x));
} }
[HttpGet("{id}")] [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"]); var r = await comicService.Query(id, Token, Ip);
if (d == null) return StatusCode(403, new { message = "403 Denied" }); return r != null ? Ok(r) : _403;
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));
} }
[HttpPost("bulkquery")] [HttpPost("bulkquery")]
public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id) public async Task<IActionResult> QueryBulk([FromBody] string[] id)
{ {
List<string> result = new List<string>(); var r = await comicService.QueryBulk(Token, id, Ip);
return Ok(JsonConvert.SerializeObject(r));
var db = id.Select(x => Helpers.SafePathCombine(ImageFolder, [x, "summary.json"])).ToArray();
if (db.Any(x => x == null))
return BadRequest();
if(!await rs.GetAll(db!, token, Ip))
return StatusCode(403, new { message = "403 Denied" });
var rc = db.Select(x => System.IO.File.ReadAllTextAsync(x!)).ToArray();
string[] rcs = await Task.WhenAll(rc);
var rjs = rcs.Select(JsonConvert.DeserializeObject<Comic>).Select(x => x!).ToArray();
return Ok(JsonConvert.SerializeObject(rjs));
} }
[HttpPost("{id}/bookmark")] [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"]); var r = await comicService.Bookmark(id, Token, bookmark, Ip);
if (d == null) return StatusCode(403, new { message = "403 Denied" }); return r ? Ok("Success") : _403;
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();
} }
[HttpGet("{id}/{file}")] [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]); var r = await comicService.Page(id, file, Token, Ip);
if (d == null) return StatusCode(403, new { message = "403 Denied" }); return r ?? _403;
}
var r = await rs.Get(d, token, Ip); [HttpGet("{id}/achieve")]
if (!r) return StatusCode(403, new { message = "403 Denied" }); public async Task<IActionResult> Achieve(string id)
{
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true); var r = await comicService.Achieve(id, Token, Ip);
return r ?? _404;
} }
} }

View File

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

View File

@@ -1,4 +1,6 @@
using Abyss.Components.Services;
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Misc;
using Abyss.Components.Static; using Abyss.Components.Static;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -11,46 +13,40 @@ public class LiveController(ResourceService rs, ConfigureService config): BaseCo
public readonly string LiveFolder = Path.Combine(config.MediaRoot, "Live"); public readonly string LiveFolder = Path.Combine(config.MediaRoot, "Live");
[HttpPost("{id}")] [HttpPost("{id}")]
public async Task<IActionResult> AddLive(string id, string token, int owner) public async Task<IActionResult> AddLive(string id, int owner)
{ {
var d = Helpers.SafePathCombine(LiveFolder, [id]); var d = Helpers.SafePathCombine(LiveFolder, [id]);
if (d == null) return StatusCode(403, new { message = "403 Denied" }); if (d == null) return _403;
bool r = await rs.Include(d, token, Ip, owner, "rw,--,--"); bool r = await rs.Include(d, Token, Ip, owner, "rw,--,--");
return r ? Ok("Success") : BadRequest(); return r ? Ok("Success") : _400;
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
public async Task<IActionResult> RemoveLive(string id, string token) public async Task<IActionResult> RemoveLive(string id)
{ {
var d = Helpers.SafePathCombine(LiveFolder, [id]); var d = Helpers.SafePathCombine(LiveFolder, [id]);
if (d == null) if (d == null)
return StatusCode(403, new { message = "403 Denied" }); return _403;
bool r = await rs.Exclude(d, token, Ip); bool r = await rs.Exclude(d, Token, Ip);
return r ? Ok("Success") : BadRequest(); return r ? Ok("Success") : _400;
} }
[HttpGet("{id}/{token}/{item}")] [HttpGet("{id}/{item}")]
public async Task<IActionResult> GetLive(string id, string token, string item) public async Task<IActionResult> GetLive(string id, string item)
{ {
var d = Helpers.SafePathCombine(LiveFolder, [id, item]); var d = Helpers.SafePathCombine(LiveFolder, [id, item]);
var f = Helpers.SafePathCombine(LiveFolder, [id]); if (d == null) return _400;
if (d == null || f == null) return BadRequest();
// TODO: (History)ffplay does not add the m3u8 query parameter in ts requests, so special treatment is given to ts here // 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: (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 // TODO: It's still not very elegant, but it's a bit better to some extent
bool r = await rs.Valid(f, token, OperationType.Read, Ip); var r = await rs.Get(d, Token, Ip, Helpers.GetContentType(d));
if(!r) return StatusCode(403, new { message = "403 Denied" }); return r ?? _404;
if(System.IO.File.Exists(d))
return PhysicalFile(d, Helpers.GetContentType(d));
else
return NotFound();
} }
} }

View File

@@ -1,229 +1,79 @@
using Abyss.Components.Services;
using Abyss.Components.Services.Media;
using Abyss.Components.Static; using Abyss.Components.Static;
using Abyss.Components.Tools;
using Abyss.Model;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace Abyss.Components.Controllers.Media; namespace Abyss.Components.Controllers.Media;
using Task = System.Threading.Tasks.Task;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class VideoController(ILogger<VideoController> logger, ResourceService rs, ConfigureService config) public class VideoController(VideoService videoService)
: BaseController : BaseController
{ {
private ILogger<VideoController> _logger = logger;
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
[HttpPost("init")] [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 (await videoService.Init(Token, owner, Ip))
if (r) return Ok(r); return Ok("Initialized Successfully");
return StatusCode(403, new { message = "403 Denied" }); return _403;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> GetClass(string token) public async Task<IActionResult> GetClass()
{ {
var r = (await rs.Query(VideoFolder, token, Ip))?.SortLikeWindows(); var r = await videoService.GetClasses(Token, Ip);
return r != null ? Ok(r) : _403;
if (r == null)
return StatusCode(401, new { message = "Unauthorized" });
return Ok(r);
} }
[HttpGet("{klass}")] [HttpGet("{klass}")]
public async Task<IActionResult> QueryClass(string klass, string token) public async Task<IActionResult> QueryClass(string klass)
{ {
var d = Helpers.SafePathCombine(VideoFolder, klass); var r = await videoService.QueryClass(klass, Token, Ip);
if (d == null) return StatusCode(403, new { message = "403 Denied" }); return r != null ? Ok(r) : _403;
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());
} }
[HttpGet("{klass}/{id}")] [HttpGet("{klass}/{id}")]
public async Task<IActionResult> QueryVideo(string klass, string id, string token) public async Task<IActionResult> QueryVideo(string klass, string id)
{ {
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "summary.json"]); var r = await videoService.QueryVideo(klass, id, Token, Ip);
if (d == null) return StatusCode(403, new { message = "403 Denied" }); return r != null ? Ok(r) : _403;
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));
} }
[HttpPost("{klass}/bulkquery")] [HttpPost("{klass}/bulkquery")]
public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id, public async Task<IActionResult> QueryBulk([FromBody] string[] id,
[FromRoute] string klass) [FromRoute] string klass)
{ {
var db = id.Select(x => Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"])).ToArray(); var r = await videoService.QueryBulk(klass, id, Token, Ip);
if (db.Any(x => x == null)) return Ok(JsonConvert.SerializeObject(r));
return BadRequest();
if (!await rs.GetAll(db!, token, Ip))
return StatusCode(403, new { message = "403 Denied" });
var rc = db.Select(x => System.IO.File.ReadAllTextAsync(x!)).ToArray();
string[] rcs = await Task.WhenAll(rc);
var rjs = rcs.Select(JsonConvert.DeserializeObject<Video>).Select(x => x!).ToList();
return Ok(JsonConvert.SerializeObject(rjs));
} }
[HttpGet("{klass}/{id}/cover")] [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"]); var r = await videoService.Cover(klass, id, Token, Ip);
if (d == null) return StatusCode(403, new { message = "403 Denied" }); return r ?? _403;
var r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
} }
[HttpGet("{klass}/{id}/gallery/{pic}")] [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]); var r = await videoService.Gallery(klass, id, pic, Token, Ip);
if (d == null) return StatusCode(403, new { message = "403 Denied" }); return r ?? _403;
var r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
} }
[HttpGet("{klass}/{id}/subtitle")] [HttpGet("{klass}/{id}/subtitle")]
public async Task<IActionResult> Subtitle(string klass, string id, string token) public async Task<IActionResult> Subtitle(string klass, string id)
{ {
var folder = Helpers.SafePathCombine(VideoFolder, new[] { klass, id }); var r = await videoService.Subtitle(klass, id, Token, Ip);
if (folder == null) return r ?? _404;
return StatusCode(403, new { message = "403 Denied" });
string? subtitlePath = null;
try
{
var preferredVtt = Path.Combine(folder, "subtitle.vtt");
if (System.IO.File.Exists(preferredVtt))
{
subtitlePath = preferredVtt;
}
else
{
subtitlePath = Directory.EnumerateFiles(folder, "*.vtt").FirstOrDefault();
if (subtitlePath == null)
{
var preferredAss = Path.Combine(folder, "subtitle.ass");
if (System.IO.File.Exists(preferredAss))
{
subtitlePath = preferredAss;
}
else
{
subtitlePath = Directory.EnumerateFiles(folder, "*.ass").FirstOrDefault();
}
}
}
}
catch (DirectoryNotFoundException)
{
return NotFound(new { message = "video folder not found" });
}
if (subtitlePath == null)
return NotFound(new { message = "subtitle not found" });
var r = await rs.Get(subtitlePath, token, Ip);
if (!r)
return StatusCode(403, new { message = "403 Denied" });
var ext = Path.GetExtension(subtitlePath).ToLowerInvariant();
var contentType = ext switch
{
".vtt" => "text/vtt",
".ass" => "text/x-ssa",
_ => "text/plain"
};
return PhysicalFile(subtitlePath, contentType, enableRangeProcessing: false);
} }
[HttpGet("{klass}/{id}/av")] [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 folder = Helpers.SafePathCombine(VideoFolder, new[] { klass, id }); var r = await videoService.Av(klass, id, Token, Ip);
if (folder == null) return StatusCode(403, new { message = "403 Denied" }); return r ?? _403;
var allowedExt = new[] { ".mp4", ".mkv", ".webm", ".mov", ".ogg" };
string? videoPath = null;
foreach (var ext in allowedExt)
{
var p = Path.Combine(folder, "video" + ext);
if (System.IO.File.Exists(p))
{
videoPath = p;
break;
}
}
if (videoPath == null)
{
try
{
videoPath = Directory.EnumerateFiles(folder)
.FirstOrDefault(f => allowedExt.Contains(Path.GetExtension(f).ToLowerInvariant()));
}
catch (DirectoryNotFoundException)
{
return NotFound(new { message = "video folder not found" });
}
}
if (videoPath == null) return NotFound(new { message = "video not found" });
var r = await rs.Get(videoPath, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
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 PhysicalFile(videoPath, contentType, enableRangeProcessing: true);
} }
} }

View File

@@ -12,7 +12,7 @@ public class BadRequestExceptionMiddleware(RequestDelegate next, ILogger<BadRequ
{ {
logger.LogError(ex.Message); logger.LogError(ex.Message);
context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Bad Request"); await context.Response.WriteAsync(ex.Message);
} }
} }
} }

View File

@@ -1,5 +1,7 @@
using System.Text; using System.Text;
using Abyss.Components.Services; using Abyss.Components.Services;
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Security;
using Abyss.Components.Static; using Abyss.Components.Static;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -11,52 +13,52 @@ public class RootController(ILogger<RootController> logger, UserService userServ
: BaseController : BaseController
{ {
[HttpPost("chmod")] [HttpPost("chmod")]
public async Task<IActionResult> Chmod(string token, string path, string permission, string? recursive) public async Task<IActionResult> Chmod(string path, string permission, string? recursive)
{ {
logger.LogInformation("Chmod method called with path: {Path}, permission: {Permission}", path, permission); logger.LogInformation("Chmod method called with path: {Path}, permission: {Permission}", path, permission);
if (userService.Validate(token, Ip) != 1) if (userService.Validate(Token, Ip) != 1)
{ {
logger.LogInformation("Chmod authorization failed for token: {Token}", token); logger.LogInformation("Chmod authorization failed for token: {Token}", Token);
return StatusCode(401, "Unauthorized"); return _401;
} }
bool r = await resourceService.Chmod(path, token, permission, Ip, recursive == "true"); bool r = await resourceService.Chmod(path, Token, permission, Ip, recursive == "true");
logger.LogInformation("Chmod operation completed with result: {Result}", r); logger.LogInformation("Chmod operation completed with result: {Result}", r);
return r ? Ok() : StatusCode(502); return r ? Ok() : StatusCode(500);
} }
[HttpPost("chown")] [HttpPost("chown")]
public async Task<IActionResult> Chown(string token, string path, int owner, string? recursive) public async Task<IActionResult> Chown(string path, int owner, string? recursive)
{ {
logger.LogInformation("Chown method called with path: {Path}, owner: {Owner}", path, owner); logger.LogInformation("Chown method called with path: {Path}, owner: {Owner}", path, owner);
if (userService.Validate(token, Ip) != 1) if (userService.Validate(Token, Ip) != 1)
{ {
logger.LogInformation("Chown authorization failed for token: {Token}", token); logger.LogInformation("Chown authorization failed for token: {Token}", Token);
return StatusCode(401, "Unauthorized"); return _401;
} }
bool r = await resourceService.Chown(path, token, owner, Ip, recursive == "true"); bool r = await resourceService.Chown(path, Token, owner, Ip, recursive == "true");
logger.LogInformation("Chown operation completed with result: {Result}", r); logger.LogInformation("Chown operation completed with result: {Result}", r);
return r ? Ok() : StatusCode(502); return r ? Ok() : StatusCode(502);
} }
[HttpGet("ls")] [HttpGet("ls")]
public async Task<IActionResult> Ls(string token, string path) public async Task<IActionResult> Ls(string path)
{ {
logger.LogInformation("Ls method called with path: {Path}", path); logger.LogInformation("Ls method called with path: {Path}", path);
if (userService.Validate(token, Ip) != 1) if (userService.Validate(Token, Ip) != 1)
{ {
logger.LogInformation("Ls authorization failed for token: {Token}", token); logger.LogInformation("Ls authorization failed for token: {Token}", Token);
return StatusCode(401, "Unauthorized"); return _401;
} }
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))
{ {
logger.LogInformation("Ls method received empty path parameter"); logger.LogInformation("Ls method received empty path parameter");
return BadRequest("path is required"); return _400;
} }
try try
@@ -67,10 +69,10 @@ public class RootController(ILogger<RootController> logger, UserService userServ
if (!Directory.Exists(fullPath)) if (!Directory.Exists(fullPath))
{ {
logger.LogInformation("Directory does not exist: {FullPath}", fullPath); logger.LogInformation("Directory does not exist: {FullPath}", fullPath);
return BadRequest("Path does not exist or is not a directory"); return _404;
} }
var entries = Directory.EnumerateFileSystemEntries(fullPath, "*", SearchOption.TopDirectoryOnly); var entries = Directory.EnumerateFileSystemEntries(fullPath, "*", SearchOption.TopDirectoryOnly).ToArray();
logger.LogInformation("Found {Count} entries in directory", entries.Count()); logger.LogInformation("Found {Count} entries in directory", entries.Count());
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -110,20 +112,20 @@ public class RootController(ILogger<RootController> logger, UserService userServ
} }
[HttpPost("init")] [HttpPost("init")]
public async Task<IActionResult> Init(string token, string path, int owner) public async Task<IActionResult> Init(string path, int owner)
{ {
if (userService.Validate(token, Ip) != 1) if (userService.Validate(Token, Ip) != 1)
{ {
logger.LogInformation("Init authorization failed for token: {Token}", token); logger.LogInformation("Init authorization failed for token: {Token}", Token);
return StatusCode(401, "Unauthorized"); return _401;
} }
var r = await resourceService.Initialize(path, token, owner, Ip); var r = await resourceService.Initialize(path, Token, owner, Ip);
if (r) return Ok(r); if (r) return Ok(r);
return StatusCode(403, new { message = "403 Denied" }); return _403;
} }
private static string ConvertToLsPerms(string permRaw, bool isDirectory) public static string ConvertToLsPerms(string permRaw, bool isDirectory)
{ {
// expects format like "rw,r-,r-" // expects format like "rw,r-,r-"
if (string.IsNullOrEmpty(permRaw)) if (string.IsNullOrEmpty(permRaw))
@@ -135,7 +137,7 @@ public class RootController(ILogger<RootController> logger, UserService userServ
return (isDirectory ? 'd' : '-') + "---------"; return (isDirectory ? 'd' : '-') + "---------";
} }
string makeTriplet(string token) string MakeTriplet(string token)
{ {
if (token.Length < 2) token = "--"; if (token.Length < 2) token = "--";
var r = token.Length > 0 && token[0] == 'r' ? 'r' : '-'; var r = token.Length > 0 && token[0] == 'r' ? 'r' : '-';
@@ -144,9 +146,9 @@ public class RootController(ILogger<RootController> logger, UserService userServ
return $"{r}{w}{x}"; return $"{r}{w}{x}";
} }
var owner = makeTriplet(parts[0]); var owner = MakeTriplet(parts[0]);
var group = makeTriplet(parts[1]); var group = MakeTriplet(parts[1]);
var other = makeTriplet(parts[2]); var other = MakeTriplet(parts[2]);
return (isDirectory ? 'd' : '-') + owner + group + other; return (isDirectory ? 'd' : '-') + owner + group + other;
} }

View File

@@ -2,8 +2,10 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Abyss.Components.Services; using Abyss.Components.Services;
using Abyss.Components.Services.Security;
using Abyss.Components.Static; using Abyss.Components.Static;
using Abyss.Model; using Abyss.Model;
using Abyss.Model.Security;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -12,114 +14,79 @@ namespace Abyss.Components.Controllers.Security;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[EnableRateLimiting("Fixed")] [EnableRateLimiting("Fixed")]
public class UserController(UserService userService, ILogger<UserController> logger) : BaseController public class UserController(UserService userService) : BaseController
{ {
[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}")] [HttpGet("{user}")]
public async Task<IActionResult> Challenge(string user) public async Task<IActionResult> Challenge(string user)
{ {
var c = await userService.Challenge(user); var c = await userService.Challenge(user);
if (c == null) return c != null ? Ok(c): _403;
return StatusCode(403, new { message = "Access forbidden" });
return Ok(c);
} }
[HttpPost("{user}")] [HttpPost("{user}")]
public async Task<IActionResult> Challenge(string user, [FromBody] ChallengeResponse response) public async Task<IActionResult> Challenge(string user, [FromBody] ChallengeResponse response)
{ {
var r = await userService.Verify(user, response.Response, Ip); var r = await userService.Verify(user, response.Response, Ip);
if (r == null) if (r != null)
return StatusCode(403, new { message = "Access forbidden" }); {
Response.Cookies.Append("token", r);
return Ok(r); return Ok(r);
} }
return _403;
}
[HttpPost("validate")] [HttpPost("validate")]
public IActionResult Validate(string token) public IActionResult Validate(string token)
{ {
var u = userService.Validate(token, Ip); var u = userService.Validate(token, Ip);
if (u == -1) return u == -1 ? _401 : Ok(u);
{
return StatusCode(401, new { message = "Invalid" });
}
return Ok(u);
} }
[HttpPost("destroy")] [HttpPost("destroy")]
public IActionResult Destroy(string token) public IActionResult Destroy(string token)
{ {
var u = userService.Validate(token, Ip); var u = userService.Validate(token, Ip);
if (u == -1) if (u != -1)
{ {
return StatusCode(401, new { message = "Invalid" });
}
userService.Destroy(token); userService.Destroy(token);
return Ok("Success"); return Ok("Success");
} }
return _401;
}
[HttpPatch("{user}")] [HttpPatch("{user}")]
public async Task<IActionResult> Create(string user, [FromBody] UserCreating creating) public async Task<IActionResult> Create(string user, [FromBody] UserCreating creating)
{ {
// Valid token bool r = await userService.CreateUserAsync(user, creating, Ip);
var r = await userService.Verify(user, creating.Response, Ip); return r ? Ok("Success") : _403;
if (r == null)
return StatusCode(403, new { message = "Denied" });
// User exists ?
var cu = await userService.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 userService.QueryUser(userService.Validate(r, Ip));
if (creating.Privilege > ou?.Privilege || ou == null)
return StatusCode(403, new { message = "Denied" });
await userService.CreateUser(new User
{
Username = creating.Name,
ParentId = ou.Uuid,
Privilege = creating.Privilege,
PublicKey = creating.PublicKey,
});
userService.Destroy(r);
return Ok("Success");
} }
[HttpGet("{user}/open")] [HttpGet("{user}/open")]
public async Task<IActionResult> Open(string user, [FromQuery] string token, [FromQuery] string? bindIp = null) public async Task<IActionResult> Open(string user, [FromQuery] string token, [FromQuery] string? bindIp = null)
{ {
var caller = userService.Validate(token, Ip); string? r = await userService.OpenUserAsync(user, token, bindIp, Ip);
if (caller != 1) return r != null ? Ok(r) : _403;
{
return StatusCode(403, new { message = "Access forbidden" });
}
var target = await userService.QueryUser(user);
if (target == null)
{
return StatusCode(404, new { message = "User not found" });
}
var ipToBind = string.IsNullOrWhiteSpace(bindIp) ? Ip : bindIp;
var t = userService.CreateToken(target.Uuid, ipToBind, TimeSpan.FromHours(1));
logger.LogInformation("Root created 1h token for {User}, bound to {BindIp}, request from {ReqIp}", user,
ipToBind, Ip);
return Ok(new { token = t, user, boundIp = ipToBind });
}
public static bool IsAlphanumeric(string input)
{
if (string.IsNullOrEmpty(input))
return false;
return Regex.IsMatch(input, @"^[a-zA-Z0-9]+$");
} }
} }

View File

@@ -1,6 +1,9 @@
using Abyss.Components.Services; using Abyss.Components.Services;
using Abyss.Components.Services.Media;
using Abyss.Components.Services.Misc;
using Abyss.Components.Static; using Abyss.Components.Static;
using Abyss.Model; using Abyss.Model;
using Abyss.Model.Media;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -10,21 +13,21 @@ namespace Abyss.Components.Controllers.Task;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class TaskController(ConfigureService config, TaskService taskService) : Controller public class TaskController(ConfigureService config, TaskService taskService) : BaseController
{ {
public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks"); public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks");
[HttpGet] [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 // 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] [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) if(r == null)
{ {
return BadRequest(); return BadRequest();
@@ -32,29 +35,26 @@ public class TaskController(ConfigureService config, TaskService taskService) :
return Ok(JsonConvert.SerializeObject(r, Formatting.Indented)); return Ok(JsonConvert.SerializeObject(r, Formatting.Indented));
} }
[HttpGet("{id}")] // [HttpGet("{id}")]
public async Task<IActionResult> GetTask(string id) // public async Task<IActionResult> GetTask(string id)
{ // {
throw new NotImplementedException(); // throw new NotImplementedException();
} // }
//
[HttpPatch("{id}")] // [HttpPatch("{id}")]
public async Task<IActionResult> PutChip(string id) // public async Task<IActionResult> PutChip(string id)
{ // {
throw new NotImplementedException(); // throw new NotImplementedException();
} // }
//
[HttpPost("{id}")] // [HttpPost("{id}")]
public async Task<IActionResult> VerifyChip(string id) // public async Task<IActionResult> VerifyChip(string id)
{ // {
throw new NotImplementedException(); // throw new NotImplementedException();
} // }
// [HttpDelete("{id}")]
[HttpDelete("{id}")] // public async Task<IActionResult> DeleteTask(string id)
public async Task<IActionResult> DeleteTask(string id) // {
{ // throw new NotImplementedException();
throw new NotImplementedException(); // }
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
using System.Text.RegularExpressions;
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))?
.Where(x => Regex.Match(x, @"^-?\d+$").Success)
.ToArray();
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;
}
public async Task<PhysicalFileResult?> Achieve(string id, string token, string ip)
{
if (Helpers.SafePathCombine(ImageFolder, [".achieve", $"{id}.zip"]) is { } d)
{
return await rs.Get(d, token, ip, "application/zip");
}
return null;
}
}

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
// ResourceService.cs // ResourceService.cs
using System.Text; using Abyss.Components.Services.Misc;
using System.Text.RegularExpressions; using Abyss.Components.Services.Security;
using Abyss.Components.Static; using Abyss.Components.Static;
using Abyss.Model; using Abyss.Model.Media;
using SQLite; using Abyss.Model.Security;
using System.IO.Hashing; using Microsoft.AspNetCore.Mvc;
namespace Abyss.Components.Services; namespace Abyss.Components.Services.Media;
public enum OperationType public enum OperationType
{ {
@@ -16,46 +16,14 @@ public enum OperationType
Security // Chown, Chmod Security // Chown, Chmod
} }
public class ResourceService public class ResourceService(
ILogger<ResourceService> logger,
ConfigureService config,
UserService user,
ResourceDatabaseService db)
{ {
private readonly ILogger<ResourceService> _logger;
private readonly ConfigureService _config;
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, UserService user)
{
_logger = logger;
_config = config;
_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, 1, "rw,r-,r-", true).Wait();
}
var livePath = Helpers.SafePathCombine(_config.MediaRoot, "Live");
if (livePath != null)
{
InsertRaRow(livePath, 1, "rw,r-,r-", true).Wait();
}
}
// Create UID only for resources, without considering advanced hash security such as adding salt // Create UID only for resources, without considering advanced hash security such as adding salt
private static string Uid(string path) private async Task<Dictionary<string, bool>> ValidAny(string[] paths, string token, OperationType type, string ip)
{
var b = Encoding.UTF8.GetBytes(path);
var r = XxHash128.Hash(b, 0x11451419);
return Convert.ToBase64String(r);
}
public async Task<Dictionary<string, bool>> ValidAny(string[] paths, string token, OperationType type, string ip)
{ {
var result = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase); var result = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
@@ -63,7 +31,7 @@ public class ResourceService
return result; // empty input -> empty result return result; // empty input -> empty result
// Normalize media root // Normalize media root
var mediaRootFull = Path.GetFullPath(_config.MediaRoot); var mediaRootFull = Path.GetFullPath(config.MediaRoot);
// Prepare normalized full paths and early-check outside-media-root // Prepare normalized full paths and early-check outside-media-root
var fullPaths = new List<string>(paths.Length); var fullPaths = new List<string>(paths.Length);
@@ -75,7 +43,7 @@ public class ResourceService
// record normalized path as key // record normalized path as key
if (!full.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase)) if (!full.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogError($"Path outside media root or null: {p}"); logger.LogError($"Path outside media root or null: {p}");
result[full] = false; result[full] = false;
} }
else else
@@ -88,7 +56,7 @@ public class ResourceService
catch (Exception ex) catch (Exception ex)
{ {
// malformed path -> mark false and continue // malformed path -> mark false and continue
_logger.LogError(ex, $"Invalid path encountered in ValidAny: {p}"); logger.LogError(ex, $"Invalid path encountered in ValidAny: {p}");
try try
{ {
result[Path.GetFullPath(p)] = false; result[Path.GetFullPath(p)] = false;
@@ -104,18 +72,18 @@ public class ResourceService
return result; return result;
// Validate token and user once // Validate token and user once
int uuid = _user.Validate(token, ip); int uuid = user.Validate(token, ip);
if (uuid == -1) if (uuid == -1)
{ {
_logger.LogError($"Invalid token: {token}"); logger.LogError($"Invalid token: {token}");
// all previously-initialized keys remain false // all previously-initialized keys remain false
return result; return result;
} }
User? user = await _user.QueryUser(uuid); User? user1 = await user.QueryUser(uuid);
if (user == null || user.Uuid != uuid) if (user1 == null || user1.Uuid != uuid)
{ {
_logger.LogError($"Verification failed: {token}"); logger.LogError($"Verification failed: {token}");
return result; return result;
} }
@@ -130,7 +98,7 @@ public class ResourceService
try try
{ {
// rel path relative to media root for Uid calculation // rel path relative to media root for Uid calculation
var rel = Path.GetRelativePath(_config.MediaRoot, full); var rel = Path.GetRelativePath(config.MediaRoot, full);
var parts = rel var parts = rel
.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, .Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
@@ -144,7 +112,7 @@ public class ResourceService
for (int i = 0; i < parts.Length - 1; i++) for (int i = 0; i < parts.Length - 1; i++)
{ {
var subPath = Path.Combine(parts.Take(i + 1).ToArray()); var subPath = Path.Combine(parts.Take(i + 1).ToArray());
var uidDir = Uid(subPath); var uidDir = ResourceDatabaseService.Uid(subPath);
reqs.Add((uidDir, OperationType.Read)); reqs.Add((uidDir, OperationType.Read));
if (!uidToOps.TryGetValue(uidDir, out var ops)) if (!uidToOps.TryGetValue(uidDir, out var ops))
@@ -159,7 +127,7 @@ public class ResourceService
// resource itself requires requested 'type' // resource itself requires requested 'type'
var resourcePath = (parts.Length == 0) ? string.Empty : Path.Combine(parts); var resourcePath = (parts.Length == 0) ? string.Empty : Path.Combine(parts);
var uidRes = Uid(resourcePath); var uidRes = ResourceDatabaseService.Uid(resourcePath);
reqs.Add((uidRes, type)); reqs.Add((uidRes, type));
if (!uidToOps.TryGetValue(uidRes, out var resOps)) if (!uidToOps.TryGetValue(uidRes, out var resOps))
@@ -175,27 +143,17 @@ public class ResourceService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"Error building requirements for path '{full}' in ValidAny."); logger.LogError(ex, $"Error building requirements for path '{full}' in ValidAny.");
// leave result[full] as false // leave result[full] as false
} }
} }
// Batch query DB for all UIDs (chunked) // Batch query DB for all UIDs (via DatabaseService)
var uidsNeeded = uidToOps.Keys.ToList(); var uidsNeeded = uidToOps.Keys.ToList();
var rasList = new List<ResourceAttribute>(); var rasList = new List<ResourceAttribute>();
const int sqliteMaxVariableNumber = 900;
if (uidsNeeded.Count > 0) if (uidsNeeded.Count > 0)
{ {
for (int i = 0; i < uidsNeeded.Count; i += sqliteMaxVariableNumber) rasList = await db.GetResourceAttributesByUidsAsync(uidsNeeded);
{
var chunk = uidsNeeded.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
var queryArgs = chunk.Cast<object>().ToArray();
var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})";
var chunkResult = await _database.QueryAsync<ResourceAttribute>(sql, queryArgs);
rasList.AddRange(chunkResult);
}
} }
var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase); var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
@@ -214,7 +172,7 @@ public class ResourceService
{ {
permCache[(uid, op)] = false; permCache[(uid, op)] = false;
var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid); var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid);
_logger.LogDebug($"ValidAny: missing ResourceAttribute for Uid={uid}, example='{examplePath}'"); logger.LogDebug($"ValidAny: missing ResourceAttribute for Uid={uid}, example='{examplePath}'");
} }
continue; continue;
@@ -225,7 +183,7 @@ public class ResourceService
var key = (uid, op); var key = (uid, op);
if (!permCache.TryGetValue(key, out var ok)) if (!permCache.TryGetValue(key, out var ok))
{ {
ok = await CheckPermission(user, ra, op); ok = await CheckPermission(user1, ra, op);
permCache[key] = ok; permCache[key] = ok;
} }
} }
@@ -257,11 +215,11 @@ public class ResourceService
{ {
if (paths.Length == 0) if (paths.Length == 0)
{ {
_logger.LogError("ValidAll called with empty path set"); logger.LogError("ValidAll called with empty path set");
return false; return false;
} }
var mediaRootFull = Path.GetFullPath(_config.MediaRoot); var mediaRootFull = Path.GetFullPath(config.MediaRoot);
// 1. basic path checks & normalize to relative // 1. basic path checks & normalize to relative
var relPaths = new List<string>(paths.Length); var relPaths = new List<string>(paths.Length);
@@ -269,25 +227,25 @@ public class ResourceService
{ {
if (!p.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase)) if (!p.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogError($"Path outside media root or null: {p}"); logger.LogError($"Path outside media root or null: {p}");
return false; return false;
} }
relPaths.Add(Path.GetRelativePath(_config.MediaRoot, Path.GetFullPath(p))); relPaths.Add(Path.GetRelativePath(config.MediaRoot, Path.GetFullPath(p)));
} }
// 2. validate token and user once // 2. validate token and user once
int uuid = _user.Validate(token, ip); int uuid = user.Validate(token, ip);
if (uuid == -1) if (uuid == -1)
{ {
_logger.LogError($"Invalid token: {token}"); logger.LogError($"Invalid token: {token}");
return false; return false;
} }
User? user = await _user.QueryUser(uuid); User? user1 = await user.QueryUser(uuid);
if (user == null || user.Uuid != uuid) if (user1 == null || user1.Uuid != uuid)
{ {
_logger.LogError($"Verification failed: {token}"); logger.LogError($"Verification failed: {token}");
return false; return false;
} }
@@ -307,7 +265,7 @@ public class ResourceService
for (int i = 0; i < parts.Length - 1; i++) for (int i = 0; i < parts.Length - 1; i++)
{ {
var subPath = Path.Combine(parts.Take(i + 1).ToArray()); var subPath = Path.Combine(parts.Take(i + 1).ToArray());
var uidDir = Uid(subPath); var uidDir = ResourceDatabaseService.Uid(subPath);
if (!uidToOps.TryGetValue(uidDir, out var ops)) if (!uidToOps.TryGetValue(uidDir, out var ops))
{ {
ops = new HashSet<OperationType>(); ops = new HashSet<OperationType>();
@@ -320,7 +278,7 @@ public class ResourceService
// resource itself requires requested 'type' // resource itself requires requested 'type'
var resourcePath = (parts.Length == 0) ? string.Empty : Path.Combine(parts); var resourcePath = (parts.Length == 0) ? string.Empty : Path.Combine(parts);
var uidRes = Uid(resourcePath); var uidRes = ResourceDatabaseService.Uid(resourcePath);
if (!uidToOps.TryGetValue(uidRes, out var resOps)) if (!uidToOps.TryGetValue(uidRes, out var resOps))
{ {
resOps = new HashSet<OperationType>(); resOps = new HashSet<OperationType>();
@@ -331,33 +289,12 @@ public class ResourceService
resOps.Add(type); resOps.Add(type);
} }
// 4. batch query DB for all UIDs using parameterized IN (...) and chunking to respect SQLite param limits // 4. batch query DB for all UIDs using DatabaseService
var uidsNeeded = uidToOps.Keys.ToList(); var uidsNeeded = uidToOps.Keys.ToList();
var rasList = new List<ResourceAttribute>(); var rasList = new List<ResourceAttribute>();
const int sqliteMaxVariableNumber = 900; // keep below default 999 for safety
if (uidsNeeded.Count > 0) if (uidsNeeded.Count > 0)
{ {
if (uidsNeeded.Count <= sqliteMaxVariableNumber) rasList = await db.GetResourceAttributesByUidsAsync(uidsNeeded);
{
var placeholders = string.Join(",", uidsNeeded.Select(_ => "?"));
var queryArgs = uidsNeeded.Cast<object>().ToArray();
var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})";
var chunkResult = await _database.QueryAsync<ResourceAttribute>(sql, queryArgs);
rasList.AddRange(chunkResult);
}
else
{
for (int i = 0; i < uidsNeeded.Count; i += sqliteMaxVariableNumber)
{
var chunk = uidsNeeded.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
var queryArgs = chunk.Cast<object>().ToArray();
var sql = $"SELECT * FROM ResourceAttributes WHERE Uid IN ({placeholders})";
var chunkResult = await _database.QueryAsync<ResourceAttribute>(sql, queryArgs);
rasList.AddRange(chunkResult);
}
}
} }
var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase); var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
@@ -371,7 +308,7 @@ public class ResourceService
if (!raDict.TryGetValue(uid, out var ra)) if (!raDict.TryGetValue(uid, out var ra))
{ {
var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid); var examplePath = uidToExampleRelPath.GetValueOrDefault(uid, uid);
_logger.LogError( logger.LogError(
$"Permission check failed (missing resource attribute): User: {uuid}, Resource: {examplePath}, Uid: {uid}"); $"Permission check failed (missing resource attribute): User: {uuid}, Resource: {examplePath}, Uid: {uid}");
return false; return false;
} }
@@ -381,14 +318,14 @@ public class ResourceService
var key = (uid, op); var key = (uid, op);
if (!permCache.TryGetValue(key, out var ok)) if (!permCache.TryGetValue(key, out var ok))
{ {
ok = await CheckPermission(user, ra, op); ok = await CheckPermission(user1, ra, op);
permCache[key] = ok; permCache[key] = ok;
} }
if (!ok) if (!ok)
{ {
var examplePath = uidToExampleRelPath.TryGetValue(uid, out var p) ? p : uid; var examplePath = uidToExampleRelPath.TryGetValue(uid, out var p) ? p : uid;
_logger.LogError( logger.LogError(
$"Permission check failed: User: {uuid}, Resource: {examplePath}, Uid: {uid}, Type: {op}"); $"Permission check failed: User: {uuid}, Resource: {examplePath}, Uid: {uid}, Type: {op}");
return false; return false;
} }
@@ -398,27 +335,26 @@ public class ResourceService
return true; return true;
} }
private async Task<bool> Valid(string path, string token, OperationType type, string ip)
public async Task<bool> Valid(string path, string token, OperationType type, string ip)
{ {
// Path is abs path here, due to Helpers.SafePathCombine // Path is abs path here, due to Helpers.SafePathCombine
if (!path.StartsWith(Path.GetFullPath(_config.MediaRoot), StringComparison.OrdinalIgnoreCase)) if (!path.StartsWith(Path.GetFullPath(config.MediaRoot), StringComparison.OrdinalIgnoreCase))
return false; return false;
path = Path.GetRelativePath(_config.MediaRoot, path); path = Path.GetRelativePath(config.MediaRoot, path);
int uuid = _user.Validate(token, ip); int uuid = user.Validate(token, ip);
if (uuid == -1) if (uuid == -1)
{ {
// No permission granted for invalid tokens // No permission granted for invalid tokens
_logger.LogError($"Invalid token: {token}"); logger.LogError($"Invalid token: {token}");
return false; return false;
} }
User? user = await _user.QueryUser(uuid); User? user1 = await user.QueryUser(uuid);
if (user == null || user.Uuid != uuid) if (user1 == null || user1.Uuid != uuid)
{ {
_logger.LogError($"Verification failed: {token}"); logger.LogError($"Verification failed: {token}");
return false; // Two-factor authentication return false; // Two-factor authentication
} }
@@ -428,58 +364,52 @@ public class ResourceService
for (int i = 0; i < parts.Length - 1; i++) for (int i = 0; i < parts.Length - 1; i++)
{ {
var subPath = Path.Combine(parts.Take(i + 1).ToArray()); var subPath = Path.Combine(parts.Take(i + 1).ToArray());
var uidDir = Uid(subPath); var uidDir = ResourceDatabaseService.Uid(subPath);
var raDir = await _database var raDir = await db.GetResourceAttributeByUidAsync(uidDir);
.Table<ResourceAttribute>()
.Where(r => r.Uid == uidDir)
.FirstOrDefaultAsync();
if (raDir == null) if (raDir == null)
{ {
_logger.LogError($"Permission denied: {uuid} has no read access to parent directory {subPath}"); logger.LogError($"Permission denied: {uuid} has no read access to parent directory {subPath}");
return false; return false;
} }
if (!await CheckPermission(user, raDir, OperationType.Read)) if (!await CheckPermission(user1, raDir, OperationType.Read))
{ {
_logger.LogError($"Permission denied: {uuid} has no read access to parent directory {subPath}"); logger.LogError($"Permission denied: {uuid} has no read access to parent directory {subPath}");
return false; return false;
} }
} }
var uid = Uid(path); var uid = ResourceDatabaseService.Uid(path);
ResourceAttribute? ra = await _database ResourceAttribute? ra = await db.GetResourceAttributeByUidAsync(uid);
.Table<ResourceAttribute>()
.Where(r => r.Uid == uid)
.FirstOrDefaultAsync();
if (ra == null) if (ra == null)
{ {
_logger.LogError($"Permission check failed: User: {uuid}, Resource: {path}, Type: {type.ToString()} "); logger.LogError($"Permission check failed: User: {uuid}, Resource: {path}, Type: {type.ToString()} ");
return false; return false;
} }
var l = await CheckPermission(user, ra, type); var l = await CheckPermission(user1, ra, type);
if (!l) if (!l)
{ {
_logger.LogError($"Permission check failed: User: {uuid}, Resource: {path}, Type: {type.ToString()} "); logger.LogError($"Permission check failed: User: {uuid}, Resource: {path}, Type: {type.ToString()} ");
} }
return l; return l;
} }
private async Task<bool> CheckPermission(User? user, ResourceAttribute? ra, OperationType type) private async Task<bool> CheckPermission(User? user1, ResourceAttribute? ra, OperationType type)
{ {
if (user == null || ra == null) return false; if (user1 == null || ra == null) return false;
if (!PermissionRegex.IsMatch(ra.Permission)) return false; if (!ResourceDatabaseService.PermissionRegex.IsMatch(ra.Permission)) return false;
var perms = ra.Permission.Split(','); var perms = ra.Permission.Split(',');
if (perms.Length != 3) return false; if (perms.Length != 3) return false;
var owner = await _user.QueryUser(ra.Owner); var owner = await user.QueryUser(ra.Owner);
if (owner == null) return false; if (owner == null) return false;
bool isOwner = ra.Owner == user.Uuid; bool isOwner = ra.Owner == user1.Uuid;
bool isPeer = !isOwner && user.Privilege == owner.Privilege; bool isPeer = !isOwner && user1.Privilege == owner.Privilege;
bool isOther = !isOwner && !isPeer; bool isOther = !isOwner && !isPeer;
string currentPerm; string currentPerm;
@@ -491,11 +421,11 @@ public class ResourceService
switch (type) switch (type)
{ {
case OperationType.Read: case OperationType.Read:
return currentPerm.Contains('r') || (user.Privilege > owner.Privilege); return currentPerm.Contains('r') || (user1.Privilege > owner.Privilege);
case OperationType.Write: case OperationType.Write:
return currentPerm.Contains('w') || (user.Privilege > owner.Privilege); return currentPerm.Contains('w') || (user1.Privilege > owner.Privilege);
case OperationType.Security: case OperationType.Security:
return (isOwner && currentPerm.Contains('w')) || user.Uuid == 1; return (isOwner && currentPerm.Contains('w')) || user1.Uuid == 1;
default: default:
return false; return false;
} }
@@ -531,13 +461,13 @@ public class ResourceService
} }
else else
{ {
_logger.LogDebug( logger.LogDebug(
$"Query: access denied or not managed for '{entry}' (user token: {token}) - item skipped."); $"Query: access denied or not managed for '{entry}' (user token: {token}) - item skipped.");
} }
} }
catch (Exception exEntry) catch (Exception exEntry)
{ {
_logger.LogError(exEntry, $"Error processing entry '{entry}' in Query."); logger.LogError(exEntry, $"Error processing entry '{entry}' in Query.");
} }
} }
@@ -545,44 +475,77 @@ public class ResourceService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"Error while listing directory '{path}' in Query."); logger.LogError(ex, $"Error while listing directory '{path}' in Query.");
return null; return null;
} }
} }
public async Task<bool> Get(string path, string token, string ip) public async Task<PhysicalFileResult?> Get(string path, string token, string ip, string contentType)
{ {
return await Valid(path, token, OperationType.Read, ip); var b = await Valid(path, token, OperationType.Read, ip);
if (b) return new PhysicalFileResult(path, contentType)
{
EnableRangeProcessing = true
};
return null;
} }
public async Task<bool> GetAll(string[] path, string token, string ip) public async Task<string?> GetString(string path, string token, string ip)
{ {
return await ValidAll(path, token, OperationType.Read, ip); var b = await Valid(path, token, OperationType.Read, ip);
if (b)
{
return await File.ReadAllTextAsync(path);
}
return null;
} }
public async Task<bool> Update(string path, string token, string ip) public async Task<Dictionary<string, string?>> GetAllString(string[] paths, string token, string ip)
{ {
return await Valid(path, token, OperationType.Write, 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) public async Task<bool> Initialize(string path, string token, string owner, string ip)
{ {
var u = await _user.QueryUser(owner); var u = await user.QueryUser(owner);
if (u == null || u.Uuid == -1) return false; if (u == null || u.Uuid == -1) return false;
return await Initialize(path, token, u.Uuid, ip); return await Initialize(path, token, u.Uuid!.Value, ip);
} }
public async Task<bool> Initialize(string path, string token, int owner, string ip) public async Task<bool> Initialize(string path, string token, int owner, string ip)
{ {
// TODO: Use a more elegant Debug mode // TODO: Use a more elegant Debug mode
if (_config.DebugMode == "Debug") if (config.DebugMode == "Debug")
goto debug; goto debug;
// 1. Authorization: Verify the operation is performed by 'root' // 1. Authorization: Verify the operation is performed by 'root'
var requester = _user.Validate(token, ip); var requester = user.Validate(token, ip);
if (requester != 1) if (requester != 1)
{ {
_logger.LogWarning( logger.LogWarning(
$"Permission denied: Non-root user '{requester}' attempted to initialize resources."); $"Permission denied: Non-root user '{requester}' attempted to initialize resources.");
return false; return false;
} }
@@ -591,14 +554,14 @@ public class ResourceService
// 2. Validation: Ensure the target path and owner are valid // 2. Validation: Ensure the target path and owner are valid
if (!Directory.Exists(path)) if (!Directory.Exists(path))
{ {
_logger.LogError($"Initialization failed: Path '{path}' does not exist or is not a directory."); logger.LogError($"Initialization failed: Path '{path}' does not exist or is not a directory.");
return false; return false;
} }
var ownerUser = await _user.QueryUser(owner); var ownerUser = await user.QueryUser(owner);
if (ownerUser == null) if (ownerUser == null)
{ {
_logger.LogError($"Initialization failed: Owner user '{owner}' does not exist."); logger.LogError($"Initialization failed: Owner user '{owner}' does not exist.");
return false; return false;
} }
@@ -611,10 +574,9 @@ public class ResourceService
var newResources = new List<ResourceAttribute>(); var newResources = new List<ResourceAttribute>();
foreach (var p in allPaths) foreach (var p in allPaths)
{ {
var currentPath = Path.GetRelativePath(_config.MediaRoot, p); var currentPath = Path.GetRelativePath(config.MediaRoot, p);
var uid = Uid(currentPath); var uid = ResourceDatabaseService.Uid(currentPath);
var existing = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid) var existing = await db.GetResourceAttributeByUidAsync(uid);
.FirstOrDefaultAsync();
// If it's not in the database, add it to our list for batch insertion // If it's not in the database, add it to our list for batch insertion
if (existing == null) if (existing == null)
@@ -631,13 +593,13 @@ public class ResourceService
// 5. Database Insertion: Add all new resources in a single, efficient transaction // 5. Database Insertion: Add all new resources in a single, efficient transaction
if (newResources.Any()) if (newResources.Any())
{ {
await _database.InsertAllAsync(newResources); await db.InsertResourceAttributesAsync(newResources);
_logger.LogInformation( logger.LogInformation(
$"Successfully initialized {newResources.Count} new resources under '{path}' for user '{owner}'."); $"Successfully initialized {newResources.Count} new resources under '{path}' for user '{owner}'.");
} }
else else
{ {
_logger.LogInformation( logger.LogInformation(
$"No new resources to initialize under '{path}'. All items already exist in the database."); $"No new resources to initialize under '{path}'. All items already exist in the database.");
} }
@@ -645,94 +607,84 @@ public class ResourceService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"An error occurred during resource initialization for path '{path}'."); logger.LogError(ex, $"An error occurred during resource initialization for path '{path}'.");
return false; 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> Exclude(string path, string token, string ip) public async Task<bool> Exclude(string path, string token, string ip)
{ {
var requester = _user.Validate(token, ip); var requester = user.Validate(token, ip);
if (requester != 1) if (requester != 1)
{ {
_logger.LogWarning( logger.LogWarning(
$"Permission denied: Non-root user '{requester}' attempted to exclude resource '{path}'."); $"Permission denied: Non-root user '{requester}' attempted to exclude resource '{path}'.");
return false; return false;
} }
try try
{ {
var relPath = Path.GetRelativePath(_config.MediaRoot, path); var relPath = Path.GetRelativePath(config.MediaRoot, path);
var uid = Uid(relPath); var uid = ResourceDatabaseService.Uid(relPath);
var resource = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync(); var resource = await db.GetResourceAttributeByUidAsync(uid);
if (resource == null) if (resource == null)
{ {
_logger.LogError($"Exclude failed: Resource '{relPath}' not found in database."); logger.LogError($"Exclude failed: Resource '{relPath}' not found in database.");
return false; return false;
} }
var deleted = await _database.DeleteAsync(resource); var deleted = await db.DeleteByUidAsync(uid);
if (deleted > 0) if (deleted > 0)
{ {
_logger.LogInformation($"Successfully excluded resource '{relPath}' from management."); logger.LogInformation($"Successfully excluded resource '{relPath}' from management.");
return true; return true;
} }
else else
{ {
_logger.LogError($"Failed to exclude resource '{relPath}' from database."); logger.LogError($"Failed to exclude resource '{relPath}' from database.");
return false; return false;
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"Error excluding resource '{path}'."); logger.LogError(ex, $"Error excluding resource '{path}'.");
return false; return false;
} }
} }
public async Task<bool> Include(string path, string token, string ip, int owner, string permission) public async Task<bool> Include(string path, string token, string ip, int owner, string permission)
{ {
var requester = _user.Validate(token, ip); var requester = user.Validate(token, ip);
if (requester != 1) if (requester != 1)
{ {
_logger.LogWarning( logger.LogWarning(
$"Permission denied: Non-root user '{requester}' attempted to include resource '{path}'."); $"Permission denied: Non-root user '{requester}' attempted to include resource '{path}'.");
return false; return false;
} }
if (!PermissionRegex.IsMatch(permission)) if (!ResourceDatabaseService.PermissionRegex.IsMatch(permission))
{ {
_logger.LogError($"Invalid permission format: {permission}"); logger.LogError($"Invalid permission format: {permission}");
return false; return false;
} }
var ownerUser = await _user.QueryUser(owner); var ownerUser = await user.QueryUser(owner);
if (ownerUser == null) if (ownerUser == null)
{ {
_logger.LogError($"Include failed: Owner user '{owner}' does not exist."); logger.LogError($"Include failed: Owner user '{owner}' does not exist.");
return false; return false;
} }
try try
{ {
var relPath = Path.GetRelativePath(_config.MediaRoot, path); var relPath = Path.GetRelativePath(config.MediaRoot, path);
var uid = Uid(relPath); var uid = ResourceDatabaseService.Uid(relPath);
var existing = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync(); var existing = await db.GetResourceAttributeByUidAsync(uid);
if (existing != null) if (existing != null)
{ {
_logger.LogError($"Include failed: Resource '{relPath}' already exists in database."); logger.LogError($"Include failed: Resource '{relPath}' already exists in database.");
return false; return false;
} }
@@ -743,22 +695,22 @@ public class ResourceService
Permission = permission Permission = permission
}; };
var inserted = await _database.InsertAsync(newResource); var inserted = await db.InsertResourceAttributeAsync(newResource);
if (inserted > 0) if (inserted > 0)
{ {
_logger.LogInformation( logger.LogInformation(
$"Successfully included '{relPath}' into resource management (Owner={owner}, Permission={permission})."); $"Successfully included '{relPath}' into resource management (Owner={owner}, Permission={permission}).");
return true; return true;
} }
else else
{ {
_logger.LogError($"Failed to include resource '{relPath}' into database."); logger.LogError($"Failed to include resource '{relPath}' into database.");
return false; return false;
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"Error including resource '{path}'."); logger.LogError(ex, $"Error including resource '{path}'.");
return false; return false;
} }
} }
@@ -767,15 +719,14 @@ public class ResourceService
{ {
try try
{ {
var relPath = Path.GetRelativePath(_config.MediaRoot, path); var relPath = Path.GetRelativePath(config.MediaRoot, path);
var uid = Uid(relPath); var uid = ResourceDatabaseService.Uid(relPath);
var resource = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync(); return await db.ExistsUidAsync(uid);
return resource != null;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"Error checking existence of resource '{path}'."); logger.LogError(ex, $"Error checking existence of resource '{path}'.");
return false; return false;
} }
} }
@@ -783,9 +734,9 @@ public class ResourceService
public async Task<bool> Chmod(string path, string token, string permission, string ip, bool recursive = false) public async Task<bool> Chmod(string path, string token, string permission, string ip, bool recursive = false)
{ {
// Validate permission format first // Validate permission format first
if (!PermissionRegex.IsMatch(permission)) if (!ResourceDatabaseService.PermissionRegex.IsMatch(permission))
{ {
_logger.LogError($"Invalid permission format: {permission}"); logger.LogError($"Invalid permission format: {permission}");
return false; return false;
} }
@@ -798,7 +749,7 @@ public class ResourceService
{ {
if (recursive && Directory.Exists(path)) if (recursive && Directory.Exists(path))
{ {
_logger.LogInformation($"Recursive directory '{path}'."); logger.LogInformation($"Recursive directory '{path}'.");
targets.Add(path); targets.Add(path);
foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories)) foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
{ {
@@ -807,17 +758,17 @@ public class ResourceService
if (!await ValidAll(targets.ToArray(), token, OperationType.Security, ip)) if (!await ValidAll(targets.ToArray(), token, OperationType.Security, ip))
{ {
_logger.LogWarning($"Permission denied for recursive chmod on '{path}'"); logger.LogWarning($"Permission denied for recursive chmod on '{path}'");
return false; return false;
} }
_logger.LogInformation($"Successfully validated chmod on '{path}'."); logger.LogInformation($"Successfully validated chmod on '{path}'.");
} }
else else
{ {
if (!await Valid(path, token, OperationType.Security, ip)) if (!await Valid(path, token, OperationType.Security, ip))
{ {
_logger.LogWarning($"Permission denied for chmod on '{path}'"); logger.LogWarning($"Permission denied for chmod on '{path}'");
return false; return false;
} }
@@ -826,57 +777,35 @@ public class ResourceService
// Build distinct UIDs // Build distinct UIDs
var relUids = targets var relUids = targets
.Select(t => Path.GetRelativePath(_config.MediaRoot, t)) .Select(t => Path.GetRelativePath(config.MediaRoot, t))
.Select(rel => Uid(rel)) .Select(rel => ResourceDatabaseService.Uid(rel))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
if (relUids.Count == 0) if (relUids.Count == 0)
{ {
_logger.LogWarning($"No targets resolved for chmod on '{path}'"); logger.LogWarning($"No targets resolved for chmod on '{path}'");
return false; return false;
} }
// Chunked bulk UPDATE using SQL "UPDATE ... WHERE Uid IN (...)" // Use DatabaseService to perform chunked updates
int updatedCount = 0; var updatedCount = await db.UpdatePermissionsByUidsAsync(relUids, permission);
const int sqliteMaxVariableNumber = 900; // leave some headroom for other params
for (int i = 0; i < relUids.Count; i += sqliteMaxVariableNumber)
{
var chunk = relUids.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
// First param is permission, rest are Uid values
var args = new List<object> { permission };
args.AddRange(chunk);
var sql = $"UPDATE ResourceAttributes SET Permission = ? WHERE Uid IN ({placeholders})";
try
{
var rowsAffected = await _database.ExecuteAsync(sql, args.ToArray());
updatedCount += rowsAffected;
_logger.LogInformation($"Chmod chunk updated {rowsAffected} rows (chunk size {chunk.Count}).");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error executing chmod update chunk for path '{path}'.");
// continue with other chunks; do not abort whole operation on one chunk error
}
}
if (updatedCount > 0) if (updatedCount > 0)
{ {
_logger.LogInformation( logger.LogInformation(
$"Chmod: updated permissions for {updatedCount} resource(s) (root='{path}', recursive={recursive})"); $"Chmod: updated permissions for {updatedCount} resource(s) (root='{path}', recursive={recursive})");
return true; return true;
} }
else else
{ {
_logger.LogWarning($"Chmod: no resources updated for '{path}' (recursive={recursive})"); logger.LogWarning($"Chmod: no resources updated for '{path}' (recursive={recursive})");
return false; return false;
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"Error changing permissions for: {path}"); logger.LogError(ex, $"Error changing permissions for: {path}");
return false; return false;
} }
} }
@@ -884,10 +813,10 @@ public class ResourceService
public async Task<bool> Chown(string path, string token, int owner, string ip, bool recursive = false) public async Task<bool> Chown(string path, string token, int owner, string ip, bool recursive = false)
{ {
// Validate new owner exists // Validate new owner exists
var newOwner = await _user.QueryUser(owner); var newOwner = await user.QueryUser(owner);
if (newOwner == null) if (newOwner == null)
{ {
_logger.LogError($"New owner '{owner}' does not exist"); logger.LogError($"New owner '{owner}' does not exist");
return false; return false;
} }
@@ -908,7 +837,7 @@ public class ResourceService
if (!await ValidAll(targets.ToArray(), token, OperationType.Security, ip)) if (!await ValidAll(targets.ToArray(), token, OperationType.Security, ip))
{ {
_logger.LogWarning($"Permission denied for recursive chown on '{path}'"); logger.LogWarning($"Permission denied for recursive chown on '{path}'");
return false; return false;
} }
} }
@@ -916,7 +845,7 @@ public class ResourceService
{ {
if (!await Valid(path, token, OperationType.Security, ip)) if (!await Valid(path, token, OperationType.Security, ip))
{ {
_logger.LogWarning($"Permission denied for chown on '{path}'"); logger.LogWarning($"Permission denied for chown on '{path}'");
return false; return false;
} }
@@ -925,89 +854,39 @@ public class ResourceService
// Build distinct UIDs // Build distinct UIDs
var relUids = targets var relUids = targets
.Select(t => Path.GetRelativePath(_config.MediaRoot, t)) .Select(t => Path.GetRelativePath(config.MediaRoot, t))
.Select(rel => Uid(rel)) .Select(rel => ResourceDatabaseService.Uid(rel))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
if (relUids.Count == 0) if (relUids.Count == 0)
{ {
_logger.LogWarning($"No targets resolved for chown on '{path}'"); logger.LogWarning($"No targets resolved for chown on '{path}'");
return false; return false;
} }
// Chunked bulk UPDATE: SET Owner = ? WHERE Uid IN (...) // Use DatabaseService to perform chunked owner updates
int updatedCount = 0; var updatedCount = await db.UpdateOwnerByUidsAsync(relUids, owner);
const int sqliteMaxVariableNumber = 900;
for (int i = 0; i < relUids.Count; i += sqliteMaxVariableNumber)
{
var chunk = relUids.Skip(i).Take(sqliteMaxVariableNumber).ToList();
var placeholders = string.Join(",", chunk.Select(_ => "?"));
var args = new List<object> { owner };
args.AddRange(chunk);
var sql = $"UPDATE ResourceAttributes SET Owner = ? WHERE Uid IN ({placeholders})";
try
{
var rowsAffected = await _database.ExecuteAsync(sql, args.ToArray());
updatedCount += rowsAffected;
_logger.LogInformation($"Chown chunk updated {rowsAffected} rows (chunk size {chunk.Count}).");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error executing chown update chunk for path '{path}'.");
// continue with remaining chunks
}
}
if (updatedCount > 0) if (updatedCount > 0)
{ {
_logger.LogInformation( logger.LogInformation(
$"Chown: changed owner for {updatedCount} resource(s) (root='{path}', recursive={recursive})"); $"Chown: changed owner for {updatedCount} resource(s) (root='{path}', recursive={recursive})");
return true; return true;
} }
else else
{ {
_logger.LogWarning($"Chown: no resources updated for '{path}' (recursive={recursive})"); logger.LogWarning($"Chown: no resources updated for '{path}' (recursive={recursive})");
return false; return false;
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"Error changing ownership for: {path}"); logger.LogError(ex, $"Error changing ownership for: {path}");
return false; return false;
} }
} }
private 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);
if (update)
return await _database.InsertOrReplaceAsync(new ResourceAttribute()
{
Uid = Uid(path),
Owner = owner,
Permission = permission,
}) == 1;
else
{
return await _database.InsertAsync(new ResourceAttribute()
{
Uid = Uid(path),
Owner = owner,
Permission = permission,
}) == 1;
}
}
public async Task<ResourceAttribute?> GetAttribute(string path) public async Task<ResourceAttribute?> GetAttribute(string path)
{ {
try try
@@ -1016,22 +895,20 @@ public class ResourceService
var full = Path.GetFullPath(path); var full = Path.GetFullPath(path);
// ensure it's under media root // ensure it's under media root
var mediaRootFull = Path.GetFullPath(_config.MediaRoot); var mediaRootFull = Path.GetFullPath(config.MediaRoot);
if (!full.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase)) if (!full.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
return null; return null;
var rel = Path.GetRelativePath(_config.MediaRoot, full); var rel = Path.GetRelativePath(config.MediaRoot, full);
var uid = Uid(rel); var uid = ResourceDatabaseService.Uid(rel);
var ra = await _database.Table<ResourceAttribute>() var ra = await db.GetResourceAttributeByUidAsync(uid);
.Where(r => r.Uid == uid)
.FirstOrDefaultAsync();
return ra; return ra;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"GetAttribute failed for path '{path}'"); logger.LogError(ex, $"GetAttribute failed for path '{path}'");
return null; return null;
} }
} }

View File

@@ -1,14 +1,16 @@
using Abyss.Components.Services.Misc;
using Abyss.Components.Services.Security;
using Abyss.Components.Static; using Abyss.Components.Static;
using Abyss.Model; using Abyss.Model.Media;
using Newtonsoft.Json; using Newtonsoft.Json;
using SQLite; 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 TaskFolder = Path.Combine(config.MediaRoot, "Tasks");
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos"); 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 ?? []) foreach (var i in r ?? [])
{ {
var p = Helpers.SafePathCombine(TaskFolder, [i, "task.json"]); 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); if(c?.Owner == u) s.Add(i);
} }
@@ -48,7 +50,8 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
switch ((TaskType)creation.Type) switch ((TaskType)creation.Type)
{ {
case TaskType.Image: case TaskType.Image:
return await CreateImageTask(token, ip, creation); throw new NotImplementedException();
// return await CreateImageTask(token, ip, creation);
case TaskType.Video: case TaskType.Video:
return await CreateVideoTask(token, ip, creation); return await CreateVideoTask(token, ip, creation);
default: default:
@@ -58,8 +61,6 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
private async Task<TaskCreationResponse?> CreateVideoTask(string token, string ip, TaskCreation creation) 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); var u = user.Validate(token, ip);
if(u == -1) if(u == -1)
return null; return null;
@@ -105,10 +106,10 @@ public class TaskService(ILogger<TaskService> logger, ConfigureService config, R
return r; return r;
} }
private async Task<TaskCreationResponse?> CreateImageTask(string token, string ip, TaskCreation creation) // private async Task<TaskCreationResponse?> CreateImageTask(string token, string ip, TaskCreation creation)
{ // {
throw new NotImplementedException(); // throw new NotImplementedException();
} // }
public static uint GenerateUniqueId(string parentDirectory) public static uint GenerateUniqueId(string parentDirectory)
{ {

View File

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

View File

@@ -1,4 +1,4 @@
namespace Abyss.Components.Services; namespace Abyss.Components.Services.Misc;
public class ConfigureService public class ConfigureService
{ {
@@ -8,4 +8,5 @@ public class ConfigureService
public string Version { get; } = "Alpha v0.1"; public string Version { get; } = "Alpha v0.1";
public string UserDatabase { get; set; } = "user.db"; public string UserDatabase { get; set; } = "user.db";
public string RaDatabase { get; set; } = "ra.db"; public string RaDatabase { get; set; } = "ra.db";
public string IndexDatabase { get; set; } = "index.db";
} }

View File

@@ -1,9 +1,10 @@
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using Abyss.Components.Services.Misc;
using Abyss.Components.Tools; using Abyss.Components.Tools;
namespace Abyss.Components.Services; namespace Abyss.Components.Services.Security;
public class AbyssService(ILogger<AbyssService> logger, ConfigureService config, UserService user) : IHostedService, IDisposable public class AbyssService(ILogger<AbyssService> logger, ConfigureService config, UserService user) : IHostedService, IDisposable
{ {

View File

@@ -3,19 +3,22 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Abyss.Model; using System.Text.RegularExpressions;
using Abyss.Components.Services.Misc;
using Abyss.Model.Security;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using NSec.Cryptography; using NSec.Cryptography;
using SQLite; using SQLite;
using Task = System.Threading.Tasks.Task; using Task = System.Threading.Tasks.Task;
namespace Abyss.Components.Services; namespace Abyss.Components.Services.Security;
public class UserService public class UserService
{ {
private readonly ILogger<UserService> _logger; private readonly ILogger<UserService> _logger;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly SQLiteAsyncConnection _database; private readonly SQLiteAsyncConnection _database;
private readonly Dictionary<int, string> _userAnnounces = new();
public UserService(ILogger<UserService> logger, ConfigureService config, IMemoryCache cache) public UserService(ILogger<UserService> logger, ConfigureService config, IMemoryCache cache)
{ {
@@ -24,39 +27,94 @@ public class UserService
_database = new SQLiteAsyncConnection(config.UserDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create); _database = new SQLiteAsyncConnection(config.UserDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
_database.CreateTableAsync<User>().Wait(); _database.CreateTableAsync<User>().Wait();
var rootUser = _database.Table<User>().Where(x => x.Uuid == 1).FirstOrDefaultAsync().Result;
if (config.DebugMode == "Debug") if (config.DebugMode == "Debug")
_cache.Set("abyss", $"1@127.0.0.1", DateTimeOffset.Now.AddHours(1)); _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. // 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."); public string? GetAnnounce(int id)
Console.WriteLine("key: '" + privateKeyBase64 + "'");
_database.InsertAsync(new User()
{ {
Uuid = 1, return _userAnnounces.GetValueOrDefault(id);
Username = "root",
ParentId = 1,
PublicKey = publicKeyBase64,
Privilege = 1145141919,
}).Wait();
Console.ReadKey();
} }
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) public async Task<string?> Challenge(string user)
{ {
@@ -65,7 +123,7 @@ public class UserService
if (u == null) // Error: User not exists if (u == null) // Error: User not exists
return null; return null;
if (_cache.TryGetValue(u.Uuid, out _)) // The previous challenge has not yet expired if (_cache.TryGetValue(u.Uuid!.Value, out _)) // The previous challenge has not yet expired
_cache.Remove(u.Uuid); _cache.Remove(u.Uuid);
var c = Convert.ToBase64String(Encoding.UTF8.GetBytes(GenerateRandomAsciiString(32))); var c = Convert.ToBase64String(Encoding.UTF8.GetBytes(GenerateRandomAsciiString(32)));
@@ -83,7 +141,7 @@ public class UserService
return null; return null;
} }
if (_cache.TryGetValue(u.Uuid, out string? challenge)) if (_cache.TryGetValue(u.Uuid!.Value, out string? challenge))
{ {
bool isVerified = VerifySignature( bool isVerified = VerifySignature(
PublicKey.Import( PublicKey.Import(
@@ -119,7 +177,7 @@ public class UserService
{ {
if (_cache.TryGetValue(token, out string? userAndIp)) if (_cache.TryGetValue(token, out string? userAndIp))
{ {
if (ip != userAndIp?.Split('@')[1] && ip != "127.0.0.1") if (ip != userAndIp?.Split('@')[1] && ip != "127.0.0.1" && token != "abyss")
{ {
_logger.LogError($"Token used from another Host: {token}"); _logger.LogError($"Token used from another Host: {token}");
Destroy(token); Destroy(token);
@@ -151,13 +209,13 @@ public class UserService
return u; return u;
} }
public async Task CreateUser(User user) public async Task AddUserAsync(User user)
{ {
await _database.InsertAsync(user); await _database.InsertAsync(user);
_logger.LogInformation($"Created user: {user.Username}, Uid: {user.Uuid}, Parent: {user.ParentId}, Privilege: {user.Privilege}"); _logger.LogInformation($"Created user: {user.Username}, Uid: {user.Uuid}, Parent: {user.ParentId}, Privilege: {user.Privilege}");
} }
static Key GenerateKeyPair() public static Key GenerateKeyPair()
{ {
var algorithm = SignatureAlgorithm.Ed25519; var algorithm = SignatureAlgorithm.Ed25519;
var creationParameters = new KeyCreationParameters var creationParameters = new KeyCreationParameters
@@ -225,4 +283,11 @@ public class UserService
_logger.LogInformation($"Created token for {uid}@{ip}, valid {lifetime.TotalMinutes} minutes"); _logger.LogInformation($"Created token for {uid}@{ip}, valid {lifetime.TotalMinutes} minutes");
return token; return token;
} }
public static bool IsAlphanumeric(string input)
{
if (string.IsNullOrEmpty(input))
return false;
return Regex.IsMatch(input, @"^[a-zA-Z0-9]+$");
}
} }

View File

@@ -1,10 +1,28 @@
using System.Net; using System.Net;
using System.Security.Authentication;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Abyss.Components.Static; namespace Abyss.Components.Static;
public abstract class BaseController : Controller 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; private string? _ip;
protected string Ip protected string Ip

View File

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

View File

@@ -11,6 +11,7 @@ using System.Runtime.InteropServices;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Abyss.Components.Services; using Abyss.Components.Services;
using Abyss.Components.Services.Security;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using NSec.Cryptography; using NSec.Cryptography;

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,14 @@
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Abyss.Components.Controllers.Middleware; using Abyss.Components.Controllers.Middleware;
using Abyss.Components.Controllers.Task; 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; using Microsoft.AspNetCore.RateLimiting;
namespace Abyss; namespace Abyss;
@@ -15,12 +22,22 @@ public class Program
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddSingleton<ResourceDatabaseService>();
builder.Services.AddSingleton<ConfigureService>(); builder.Services.AddSingleton<ConfigureService>();
builder.Services.AddSingleton<UserService>(); builder.Services.AddSingleton<UserService>();
builder.Services.AddSingleton<ResourceService>(); builder.Services.AddSingleton<ResourceService>();
builder.Services.AddSingleton<TaskController>(); builder.Services.AddSingleton<TaskController>();
builder.Services.AddSingleton<TaskService>(); builder.Services.AddSingleton<TaskService>();
builder.Services.AddSingleton<IndexService>();
builder.Services.AddSingleton<VideoService>();
builder.Services.AddSingleton<ComicService>();
builder.Services.AddHostedService<AbyssService>(); builder.Services.AddHostedService<AbyssService>();
builder.Services.AddHostedService<CtlService>();
foreach (var t in ModuleAttribute.Modules)
{
builder.Services.AddTransient(t);
}
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {

View File

@@ -8,7 +8,7 @@
"applicationUrl": "http://localhost:3000", "applicationUrl": "http://localhost:3000",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_ENVIRONMENT": "Development",
"MEDIA_ROOT" : "/storage", "MEDIA_ROOT" : "/opt/abyss",
"ALLOWED_PORTS" : "3000", "ALLOWED_PORTS" : "3000",
"DEBUG_MODE": "Debug" "DEBUG_MODE": "Debug"
} }

View File

@@ -130,10 +130,12 @@ def find_video_in_dir(base_path):
return None return None
def update_summary(base_path, name_input=None, author_input=None): def update_summary(base_path, name_input=None, author_input=None, group_input=None):
""" """
Updates the summary.json file for a given path. 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" summary_path = base_path / "summary.json"
gallery_path = base_path / "gallery" gallery_path = base_path / "gallery"
@@ -154,18 +156,26 @@ def update_summary(base_path, name_input=None, author_input=None):
"author": author_input if author_input is not None else "anonymous" "author": author_input if author_input is not None else "anonymous"
} }
existing_data = {}
# Load existing summary if available # Load existing summary if available
if summary_path.exists(): if summary_path.exists():
try: try:
with open(summary_path, 'r', encoding='utf-8') as f: with open(summary_path, 'r', encoding='utf-8') as f:
existing_data = json.load(f) existing_data = json.load(f)
# Update default with existing values # Update default with existing values for known keys
for key in default_summary: for key in default_summary:
if key in existing_data: if key in existing_data:
default_summary[key] = existing_data[key] default_summary[key] = existing_data[key]
except json.JSONDecodeError: except json.JSONDecodeError:
print("Warning: Invalid JSON in summary.json, using defaults") print("Warning: Invalid JSON in summary.json, using defaults")
# 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 # Update duration from video file if found
if video_path and video_path.exists(): if video_path and video_path.exists():
default_summary["duration"] = get_video_duration(video_path) default_summary["duration"] = get_video_duration(video_path)
@@ -201,13 +211,65 @@ def find_next_directory(base_path):
next_num += 1 next_num += 1
return str(next_num) 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(): def main():
if len(sys.argv) < 2: if len(sys.argv) < 2:
print("Usage: python script.py <command> [arguments]") print("Usage: python script.py <command> [arguments]")
print("Commands:") print("Commands:")
print(" -u <path> Update the summary.json in the specified path.") 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. Optional -y to accept defaults.") 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(" -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) sys.exit(1)
command = sys.argv[1] command = sys.argv[1]
@@ -226,14 +288,33 @@ def main():
update_summary(base_path) update_summary(base_path)
elif command == '-a': elif command == '-a':
# allow invocation with optional -y flag anywhere; expecting at least video and base path # Parse tokens allowing -y (global) and -g <group> anywhere; remaining two positionals must be video_file and base_path
params = [p for p in sys.argv[2:] if p != '-y'] tokens = sys.argv[2:]
if len(params) != 2: positional = []
print("Usage: python script.py -a <video_file> <path> (optional -y to accept defaults)") 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) sys.exit(1)
video_source_path = Path(params[0]) video_source_path = Path(positional[0])
base_path = Path(params[1]) base_path = Path(positional[1])
if not video_source_path.exists() or not video_source_path.is_file(): if not video_source_path.exists() or not video_source_path.is_file():
print(f"Error: Video file not found: {video_source_path}") print(f"Error: Video file not found: {video_source_path}")
@@ -259,6 +340,23 @@ def main():
shutil.copy(video_source_path, video_dest_path) shutil.copy(video_source_path, video_dest_path)
print(f"Video copied to {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 # Auto-generate thumbnails
create_thumbnails(video_dest_path, gallery_path) create_thumbnails(video_dest_path, gallery_path)
@@ -282,8 +380,8 @@ def main():
if not video_author: if not video_author:
video_author = "Anonymous" video_author = "Anonymous"
# Update the summary with user input or default values # 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) update_summary(new_project_path, name_input=video_name, author_input=video_author, group_input=group_name)
elif command == '-c': elif command == '-c':
if len(sys.argv) != 4: if len(sys.argv) != 4:
@@ -312,8 +410,36 @@ def main():
create_cover(video_path, cover_path, time_percent) 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: else:
print("Invalid command. Use -u, -a, or -c.") print("Invalid command. Use -u, -a, -c, or -m.")
print("Usage: python script.py <command> [arguments]") print("Usage: python script.py <command> [arguments]")
sys.exit(1) sys.exit(1)

View File

@@ -13,6 +13,13 @@
--- ---
## 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 ## 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. **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.

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

13
abyssctl/Program.cs Normal file
View File

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

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

View File

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

24
abyssctl/abyssctl.csproj Normal file
View File

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