Add project files

This commit is contained in:
2025-10-04 13:27:29 +03:00
parent 75eba696c9
commit cc53824229
46 changed files with 3328 additions and 7 deletions

26
.dockerignore Normal file
View File

@@ -0,0 +1,26 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/db_data
LICENSE
README.md

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/db_data/

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,240 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoGeneratedRunConfigurationManager">
<projectFile profileName="http">T120B165-ImgBoard/T120B165-ImgBoard.csproj</projectFile>
<projectFile profileName="https">T120B165-ImgBoard/T120B165-ImgBoard.csproj</projectFile>
</component>
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="4e14f3e6-3f4d-450c-8a9e-273b19823ca0" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/.dockerignore" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/.idea.T120B165-ImgBoard/.idea/encodings.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/.idea.T120B165-ImgBoard/.idea/indexLayout.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/.idea.T120B165-ImgBoard/.idea/projectSettingsUpdater.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/.idea.T120B165-ImgBoard/.idea/vcs.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/.idea.T120B165-ImgBoard/.idea/workspace.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard.sln" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard.sln.DotSettings.user" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/.gitignore" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Controllers/AuthController.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Controllers/PostController.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Controllers/TagController.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Data/DbInitializer.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Data/ImgBoardContext.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dockerfile" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Comment/CommentDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Comment/CreateCommentDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/LoginDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Post/CreatePostDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Post/PostDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/RefreshDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/RegisterDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/SlimUserDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Tag/CreateTagDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/TokenDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/UserDto.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Models/Comment.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Models/File.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Models/Post.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Models/Tag.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Models/User.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Program.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Properties/launchSettings.json" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Services/CommentService.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Services/FileService.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Services/PostService.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Services/TagService.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Services/TokenService.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Services/UserService.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/T120B165-ImgBoard.csproj" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Utils/PagedList.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/T120B165-ImgBoard/appsettings.Development.json" afterDir="false" />
<change afterPath="$PROJECT_DIR$/compose.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="DpaMonitoringSettings">
<option name="firstShow" value="false" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/ILViewer/286e202dd8b6498aaf4d306beb6010b86a800/69/ec005a21/02000048.il" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/ILViewer/286e202dd8b6498aaf4d306beb6010b86a800/c0/34726a3e/0200002A.il" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/ILViewer/9dc46363eb12453791df46baf16699c41a928/86/01a85f52/0200001Cpdb2.il" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/286e202dd8b6498aaf4d306beb6010b86a800/42/185ed81a/IUserRoleStore`1.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/286e202dd8b6498aaf4d306beb6010b86a800/81/11a2ff3a/UserManager`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/286e202dd8b6498aaf4d306beb6010b86a800/b5/88947990/IPasswordHasher`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/477051138f1f40de9077b7b1cdc55c6215fb0/d0/53d5065a/JwtRegisteredClaimNames.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/4afd6eaa21b44c15b819957c24b8b6071daa00/a4/86d4eee3/StatusCodeResult.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/560c8584411b436bb783d358249acd4fe400/15/6f12b841/IHostEnvironment.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/8cb87e61921c49bcadb380781eccdeb4228600/16/829c32b2/NSwagApplicationBuilderExtensions.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/b13cb16f7e5749269b6922dc6752bd1c291448/5b/5bdfe94c/DatabaseFacade.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/b9c62e8e682440cd84e6081f7672fcd3d1a000/61/073f2693/String.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/fe0eb928ebdd4a47ae76673fbad3037d10800/30/30f1fe01/IdentityUser`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/fe0eb928ebdd4a47ae76673fbad3037d10800/9f/a723ee44/IdentityUser.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/10624a394067e79df04da5d3a67dad2f18e6c46e3cc06bac64a918f6540cc/UserStore.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/f9342fb513b7b524925c79d9822cd3ecaf072c50231aa3b469875f4c2cdf/IdentityDbContext.cs" root0="FORCE_HIGHLIGHTING" />
</component>
<component name="KubernetesApiPersistence">{}</component>
<component name="KubernetesApiProvider">{
&quot;isMigrated&quot;: true
}</component>
<component name="ProjectColorInfo">{
&quot;customColor&quot;: &quot;&quot;,
&quot;associatedIndex&quot;: 3
}</component>
<component name="ProjectId" id="32iwKzz0G6elTlo2DhCLGO5rXHo" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
".NET Launch Settings Profile.T120B165-ImgBoard: http.executor": "Run",
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"git-widget-placeholder": "main",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager" selected=".NET Launch Settings Profile.T120B165-ImgBoard: http">
<configuration name="T120B165-ImgBoard: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/T120B165-ImgBoard/T120B165-ImgBoard.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="T120B165-ImgBoard: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/T120B165-ImgBoard/T120B165-ImgBoard.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="T120B165-ImgBoard/Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="t120b165-imgboard" />
<option name="containerName" value="t120b165-imgboard" />
<option name="contextFolderPath" value="$PROJECT_DIR$" />
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="8080" />
<option name="hostIp" value="127.0.0.1" />
<option name="hostPort" value="8080" />
</DockerPortBindingImpl>
</list>
</option>
<option name="sourceFilePath" value="T120B165-ImgBoard/Dockerfile" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="docker-compose.yml" temporary="true">
<deployment type="docker-compose.yml">
<settings />
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true">
<deployment type="dockerfile">
<settings />
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration name="compose.yaml: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
<deployment type="docker-compose.yml">
<settings>
<option name="sourceFilePath" value="compose.yaml" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="4e14f3e6-3f4d-450c-8a9e-273b19823ca0" name="Changes" comment="" />
<created>1757916641972</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1757916641972</updated>
<workItem from="1757916644799" duration="6218000" />
<workItem from="1757954672397" duration="682000" />
<workItem from="1758526401446" duration="4339000" />
<workItem from="1758727364837" duration="1057000" />
<workItem from="1758786058133" duration="5007000" />
<workItem from="1758791727481" duration="3280000" />
<workItem from="1759132739938" duration="15494000" />
<workItem from="1759305506296" duration="7211000" />
<workItem from="1759334279107" duration="1064000" />
<workItem from="1759446792755" duration="16154000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.OperationCanceledException" breakIfHandledByOtherCode="false" displayValue="System.OperationCanceledException" />
<option name="timeStamp" value="1" />
</breakpoint>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.Threading.Tasks.TaskCanceledException" breakIfHandledByOtherCode="false" displayValue="System.Threading.Tasks.TaskCanceledException" />
<option name="timeStamp" value="2" />
</breakpoint>
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
<properties exception="System.Threading.ThreadAbortException" breakIfHandledByOtherCode="false" displayValue="System.Threading.ThreadAbortException" />
<option name="timeStamp" value="3" />
</breakpoint>
</breakpoints>
</breakpoint-manager>
<pin-to-top-manager>
<pinned-members>
<PinnedItemInfo parentTag="Type#System.Security.Claims.ClaimsPrincipal" memberName="Claims" />
</pinned-members>
</pin-to-top-manager>
</component>
</project>

View File

@@ -7,32 +7,73 @@ Veikimo principas - pati platforma bus prieinama per interneto naršyklę ir bus
Sistema bus kuriama naudojant modernias technologijas, o diegimas bus supaprastintas, siekiant greitesnio diegimo. Naudotojams bus suteikiami skirtingi vaidmenys su atitinkamomis teisėmis, užtikrinant sklandų ir kontroliuojamą turinio valdymą. Naudotojai galės filtruoti nuotraukas pagal joms paskirtas žymas. Sistema bus kuriama naudojant modernias technologijas, o diegimas bus supaprastintas, siekiant greitesnio diegimo. Naudotojams bus suteikiami skirtingi vaidmenys su atitinkamomis teisėmis, užtikrinant sklandų ir kontroliuojamą turinio valdymą. Naudotojai galės filtruoti nuotraukas pagal joms paskirtas žymas.
## Funkciniai reikalavimai ### Funkciniai reikalavimai
### Bendrieji reikalavimai #### Bendrieji reikalavimai
- Sistema privalo turėti vartotojo sąsają, kuri leistų peržiūrėti, įkelti, ir tvarkyti nuotraukas bei komentarus. - Sistema privalo turėti vartotojo sąsają, kuri leistų peržiūrėti, įkelti, ir tvarkyti nuotraukas bei komentarus.
- Duomenų bazė turi būti lengvai keičiama dėl pasirinkto _ORM_ (angl. Object-Relational Mapping) sluoksnio. - Duomenų bazė turi būti lengvai keičiama dėl pasirinkto _ORM_ (angl. Object-Relational Mapping) sluoksnio.
- Serverio ir klientinės dalys turi būti supakuotos į vieną diegimo vienetą (binary), siekiant supaprastinti diegiamosios sistemos procesą. - Serverio ir klientinės dalys turi būti supakuotos į vieną diegimo vienetą (binary), siekiant supaprastinti diegiamosios sistemos procesą.
### Rolių reikalavimai #### Rolių reikalavimai
**! Nubraukti reikalavimai yra kol kas nėra įgyvendinti. Taip pat kol kas nėra rolių.**
**Svečiai** **Svečiai**
1. Gali peržiūrėti visus įkeltus paveikslus. 1. Gali peržiūrėti visus įkeltus paveikslus.
2. Gali matyti visus komentarus po paveikslais. 2. Gali matyti visus komentarus po paveikslais.
3. Gali filtruoti paveikslus pagal nustatytas žymas. 3. ~~Gali filtruoti paveikslus pagal nustatytas žymas.~~
4. Gali užsiregistruoti ir tapti registruotais naudotojais. 4. Gali užsiregistruoti ir tapti registruotais naudotojais.
**Registruoti naudotojai** **Registruoti naudotojai**
1. Gali prisijungti prie sistemos. 1. Gali prisijungti prie sistemos.
2. Gali atsijungti nuo sistemos. 2. ~~Gali atsijungti nuo sistemos.~~
3. Gali įkelti naujus paveikslus. 3. Gali įkelti naujus paveikslus.
4. Gali redaguoti savo įkeltų paveikslų metaduomenis (žymas, aprašą). 4. Gali redaguoti savo įkeltų paveikslų metaduomenis (žymas, aprašą).
5. Gali ištrinti savo įkeltus paveikslus. 5. Gali ištrinti savo įkeltus paveikslus.
6. Gali rašyti komentarus po paveikslais. 6. Gali rašyti komentarus po paveikslais.
7. Gali redaguoti savo komentarus. 7. Gali redaguoti savo komentarus.
8. Gali ištrinti savo komentarus. 8. Gali ištrinti savo komentarus.
9. Gali keisti savo paskyros informaciją (slaptažodį, el. paštą). 9. ~~Gali keisti savo paskyros informaciją (slaptažodį, el. paštą).~~
**Moderatoriai** **Moderatoriai**
1. Turi teisę šalinti ir redaguoti kitų naudotojų paveikslus bei komentarus. 1. Turi teisę šalinti ir redaguoti kitų naudotojų paveikslus bei komentarus.
2. Gali kurti, redaguoti ir ištrinti žymas, kurios naudojamos turinio kategorizavimui. 2. Gali kurti, redaguoti ir ištrinti žymas, kurios naudojamos turinio kategorizavimui.
## Konfiguracija
**apsettings.json**
```json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DbContext": "server=localhost;port=3306;database=database_name;user id=user_name;password=password;"
},
"AllowedHosts": "*",
"Jwt": {
"Key": "openssl rand -base64 32",
"Issuer": "ImgBoard",
"Audience": "ImgBoard-Users",
"DurationInMinutes": 10
}
}
```
## Paleidimas
Paleidimui naudojama Docker komanda:
```sh
docker compose up --build
```
## API dokumentacija
API dokumentacija yra pasiekiama naudojant `Development` versiją šia nuoroda:
http://localhost:5259/swagger/v1/swagger.json
A copy of the file is provided in the repository called `swagger.json`. May not be up-to-date.

21
T120B165-ImgBoard.sln Normal file
View File

@@ -0,0 +1,21 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "T120B165-ImgBoard", "T120B165-ImgBoard\T120B165-ImgBoard.csproj", "{FDD7EF71-09BD-4FB3-B0A4-18AFEE0794B1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{0D67DB4A-BAE6-47D3-8E4E-4D3814C796E5}"
ProjectSection(SolutionItems) = preProject
compose.yaml = compose.yaml
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{FDD7EF71-09BD-4FB3-B0A4-18AFEE0794B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FDD7EF71-09BD-4FB3-B0A4-18AFEE0794B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FDD7EF71-09BD-4FB3-B0A4-18AFEE0794B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FDD7EF71-09BD-4FB3-B0A4-18AFEE0794B1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,11 @@
<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_003ADatabaseFacade_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb13cb16f7e5749269b6922dc6752bd1c291448_003F5b_003F5bdfe94c_003FDatabaseFacade_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityDbContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Ff9342fb513b7b524925c79d9822cd3ecaf072c50231aa3b469875f4c2cdf_003FIdentityDbContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityUser_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe0eb928ebdd4a47ae76673fbad3037d10800_003F9f_003Fa723ee44_003FIdentityUser_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityUser_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe0eb928ebdd4a47ae76673fbad3037d10800_003F30_003F30f1fe01_003FIdentityUser_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIPasswordHasher_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F286e202dd8b6498aaf4d306beb6010b86a800_003Fb5_003F88947990_003FIPasswordHasher_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtRegisteredClaimNames_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F477051138f1f40de9077b7b1cdc55c6215fb0_003Fd0_003F53d5065a_003FJwtRegisteredClaimNames_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F4afd6eaa21b44c15b819957c24b8b6071daa00_003Fa4_003F86d4eee3_003FStatusCodeResult_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_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb9c62e8e682440cd84e6081f7672fcd3d1a000_003F61_003F073f2693_003FString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUserManager_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F286e202dd8b6498aaf4d306beb6010b86a800_003F81_003F11a2ff3a_003FUserManager_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUserStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F10624a394067e79df04da5d3a67dad2f18e6c46e3cc06bac64a918f6540cc_003FUserStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

4
T120B165-ImgBoard/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/bin/
/obj/
/Storage/
/appsettings.json

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using T120B165_ImgBoard.Dtos;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Services;
namespace T120B165_ImgBoard.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController(UserManager<User> userManager, ITokenService tokenService): ControllerBase
{
[HttpPost("register")]
public async Task<ActionResult<User>> Register(RegisterDto dto)
{
var user = new User
{
UserName = dto.UserName,
Email = dto.Email,
};
var result = await userManager.CreateAsync(user, dto.Password);
await userManager.AddToRoleAsync(user, UserRoles.Regular);
if (!result.Succeeded)
{
return BadRequest(result.Errors);
}
return Ok(user);
}
[HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenDto>> Login(LoginDto dto)
{
var user = await userManager.FindByEmailAsync(dto.Email);
if (user == null || !await userManager.CheckPasswordAsync(user, dto.Password))
return Unauthorized();
var accessToken = await tokenService.GenerateJwtToken(user);
var refreshToken = await tokenService.GenerateRefreshToken(user);
return Ok(new TokenDto(AccessToken: accessToken, RefreshToken: refreshToken));
}
[HttpPost("refresh")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenDto>> Refresh(RefreshDto dto)
{
var token = await tokenService.GetRefreshTokenByValue(dto.RefreshToken);
if (token == null) return Unauthorized();
var user = token.User;
await tokenService.InvalidateRefreshToken(token);
var accessToken = await tokenService.GenerateJwtToken(user);
var newRefreshToken = await tokenService.GenerateRefreshToken(user);
return Ok(new TokenDto(AccessToken: accessToken, RefreshToken: newRefreshToken));
}
}

View File

@@ -0,0 +1,456 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Security.Claims;
using System.Text.RegularExpressions;
using FileSignatures;
using FileSignatures.Formats;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using T120B165_ImgBoard.Dtos.Comment;
using T120B165_ImgBoard.Dtos.Post;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Services;
using T120B165_ImgBoard.Utils;
namespace T120B165_ImgBoard.Controllers;
[ApiController]
[Route("api/posts")]
public class PostController(
IPostService postService,
IUserService userService,
ITagService tagService,
ICommentService commentService,
IFileService fileService,
IWebHostEnvironment env
): ControllerBase
{
private static async Task<string> InspectFileContent(string filePath)
{
// Define the types we support
var inspector = new FileFormatInspector(new List<FileFormat>
{
new Jpeg(),
new Png(),
});
// Open the file and inspect
await using var stream = System.IO.File.OpenRead(filePath);
var format = inspector.DetermineFileFormat(stream);
// If the file matches a known format, return its MIME type
if (format != null) return format.MediaType;
// If no signature matches, assume generic binary type
return "application/octet-stream";
}
private string GetFinalFilePath(Models.File fileRecord)
{
var baseDir = Path.Combine(env.ContentRootPath, "Storage", "Attachments");
// Create a clean, organized subdirectory (e.g., /Storage/Attachments/2025/10/)
var dateDir = Path.Combine(baseDir, DateTime.UtcNow.Year.ToString(), DateTime.UtcNow.Month.ToString("D2"));
// Ensure the directory exists before attempting to save the file
if (!Directory.Exists(dateDir))
{
Directory.CreateDirectory(dateDir);
}
// Create a unique file name to avoid collisions
// Use the file ID or a GUID + a sanitized version of the original file name
var uniqueFileName = $"{Guid.NewGuid()}-{Path.GetFileName(fileRecord.OriginalFileName)}";
return Path.Combine(dateDir, uniqueFileName);
}
private static bool TryParseContentRange(string header, out long start, out long end, out long totalSize)
{
start = end = totalSize = 0;
if (string.IsNullOrWhiteSpace(header))
return false;
// We expect a format like "bytes 0-1048575/5242880"
// Regex pattern to capture the parts
var match = Regex.Match(header, @"bytes\s+(?<start>\d+)-(?<end>\d+)/(?<total>\d+)");
if (!match.Success)
return false;
// Safely parse the captured groups
if (!long.TryParse(match.Groups["start"].Value, out start) ||
!long.TryParse(match.Groups["end"].Value, out end) ||
!long.TryParse(match.Groups["total"].Value, out totalSize))
{
return false;
}
// The length of the chunk must match the difference between end and start
// Note: 'end' is inclusive, so the number of bytes is (end - start + 1)
return start >= 0 && end >= start && totalSize > 0 && end < totalSize;
}
private async Task<ActionResult<List<Tag>>> TagNamesToTags(List<string> tagNames)
{
var tags = new List<Tag>();
foreach (var tagName in tagNames)
{
var tag = await tagService.GetByName(tagName);
if (tag == null)
return Problem(
detail: $"'{tagName}' is not a valid tag",
statusCode: StatusCodes.Status400BadRequest
);
tags.Add(tag);
}
return tags;
}
[HttpPost]
[Authorize]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PostDto>> Create(CreatePostDto dto)
{
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var user = await userService.GetUserById(userId);
if (user == null) return Unauthorized();
var maybeTags = await TagNamesToTags(dto.Tags);
List<Tag> tags;
if (maybeTags.Value != null)
{
tags = maybeTags.Value;
}
else
{
Debug.Assert(maybeTags.Result != null);
return maybeTags.Result;
}
var created = await postService.Create(
dto.Title, dto.Description, tags, user,
dto.FileName, dto.FileMimeType, dto.FileSize.Value
);
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
new { postId = created.Post.Id, fileId = created.File.Id }, Request.Scheme
);
return CreatedAtAction(nameof(Get), new { id = created.Post.Id },
PostDto.FromPost(created.Post, fileUrl));
}
[HttpPatch("{postId:int}/files/{fileId:int}")]
public async Task<IActionResult> PatchFileContent(int postId, int fileId)
{
var post = await postService.GetById(postId, includeUnfinished: true);
if (post == null) return NotFound();
var fileRecord = await fileService.GetFileById(fileId);
if (fileRecord == null) return NotFound();
if (fileRecord.FinishedDate != null)
return Problem(statusCode: StatusCodes.Status400BadRequest,
detail: "File was already uploaded.");
// Parse the Content-Range Header
// Example: Content-Range: bytes 0-1048575/5242880
var rangeHeader = Request.Headers.ContentRange.FirstOrDefault();
if (string.IsNullOrEmpty(rangeHeader) || !TryParseContentRange(rangeHeader, out long start, out long end, out long totalSizeFromHeader))
{
return Problem(
statusCode: StatusCodes.Status400BadRequest,
detail: "Missing or invalid Content-Range header."
);
}
var totalSize = fileRecord.Size;
if (totalSizeFromHeader != totalSize)
{
return Problem(
statusCode: StatusCodes.Status400BadRequest,
detail: $"Total file size mismatch. Expected: {totalSize} bytes, Received: {totalSizeFromHeader} bytes."
);
}
// Append the chunk to the temporary file
var tempFilePath = fileRecord.FilePath;
await using (var stream = new FileStream(tempFilePath, FileMode.Append, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true))
{
stream.Seek(start, SeekOrigin.Begin);
await Request.Body.CopyToAsync(stream);
}
// Check if the upload is complete
// Return 202 Accepted for a successful intermediate chunk
if (end + 1 != totalSize) return Accepted();
// Otherwise, all chunks received. Finalize the file.
var finalFilePath = GetFinalFilePath(fileRecord);
System.IO.File.Move(tempFilePath, finalFilePath, overwrite: true);
// Compare the actual type to the expected type
var actualContentType = await InspectFileContent(finalFilePath);
if (!string.Equals(actualContentType, fileRecord.ContentType, StringComparison.OrdinalIgnoreCase))
{
// If the file's content doesn't match its declared MIME type, clean up
System.IO.File.Delete(finalFilePath);
await fileService.Delete(fileRecord);
//await postService.Delete(post);
return Problem(statusCode: StatusCodes.Status415UnsupportedMediaType,
detail: $"Uploaded file type '{actualContentType}' does not match declared type '{fileRecord.ContentType}'.");
}
// Update the database record with the final path
fileRecord.FilePath = finalFilePath;
fileRecord.FinishedDate = DateTime.Now;
await fileService.Update(fileRecord);
return NoContent();
}
[HttpGet("{postId:int}/files/{fileId:int}")]
public async Task<IActionResult> GetFileContent(int postId, int fileId)
{
var post = await postService.GetById(postId, includeUnfinished: true);
if (post == null) return NotFound();
var fileRecord = await fileService.GetFileById(fileId);
if (fileRecord == null) return NotFound();
// Ensure the file has been successfully uploaded and finalized
if (string.IsNullOrEmpty(fileRecord.FilePath) || fileRecord.FinishedDate == null)
{
return Problem(statusCode: StatusCodes.Status404NotFound,
detail: "File content is not yet available or was corrupted.");
}
var fullPath = Path.Combine(env.ContentRootPath, fileRecord.FilePath);
// Check if the file actually exists
if (!System.IO.File.Exists(fullPath))
{
return Problem(statusCode: StatusCodes.Status404NotFound,
detail: "The physical file could not be found.");
}
var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return File(stream, fileRecord.ContentType, fileRecord.OriginalFileName);
}
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PostDto>> Get(int id)
{
var entry = await postService.GetById(id);
if (entry == null) return NotFound();
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
new { postId = entry.Id, fileId = entry.File.Id }, Request.Scheme
);
return Ok(PostDto.FromPost(entry, fileUrl));
}
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PagedList<PostDto>>> GetAll([Range(1, int.MaxValue)] int pageNumber = 1)
{
var list = await postService.GetAll(pageNumber);
var newItems = list.Items.Select(i =>
{
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
new { postId = i.Id, fileId = i.File.Id }, Request.Scheme
);
return PostDto.FromPost(i, fileUrl);
}).ToList();
return Ok(new PagedList<PostDto>(newItems, list.CurrentPage, list.PageSize, list.TotalCount));
}
[Authorize]
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Delete(int id)
{
var post = await postService.GetById(id);
if (post == null) return NotFound();
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var isAdmin = HttpContext.User.IsInRole(UserRoles.Admin);
// If neither the admin nor the resource owner
if (!isAdmin && userId != post.Author.Id)
{
Forbid();
}
// Clean up the file record first
var fullPath = Path.Combine(env.ContentRootPath, post.File.FilePath);
if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath);
await fileService.Delete(post.File);
var deleted = await postService.Delete(post);
if (!deleted) return NotFound();
return NoContent();
}
[Authorize]
[HttpPatch("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PostDto>> Update(int id, EditPostDto dto)
{
var post = await postService.GetById(id);
if (post == null) return NotFound();
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var isAdmin = HttpContext.User.IsInRole(UserRoles.Admin);
// If neither the admin nor the resource owner
if (!isAdmin && userId != post.Author.Id)
{
Forbid();
}
if (!string.IsNullOrEmpty(dto.Title))
post.Title = dto.Title;
if (!string.IsNullOrEmpty(dto.Description))
post.Description = dto.Description;
if (dto.Tags != null)
{
var maybeTags = await TagNamesToTags(dto.Tags);
if (maybeTags.Value != null)
{
post.Tags = maybeTags.Value;
}
else
{
Debug.Assert(maybeTags.Result != null);
return maybeTags.Result;
}
}
var updated = await postService.Update(post);
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
new { postId = updated.Id, fileId = updated.File.Id }, Request.Scheme
);
return Ok(PostDto.FromPost(updated, fileUrl));
}
[HttpPost("{postId:int}/comments")]
[Authorize]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PostDto>> CreateComment(int postId, CreateCommentDto dto)
{
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var user = await userService.GetUserById(userId);
if (user == null) return Unauthorized();
var post = await postService.GetById(postId);
if (post == null) return NotFound();
var created = await commentService.Create(dto.Text, user, post);
return CreatedAtAction(nameof(GetComment), new {postId = postId, commentId = created.Id}, CommentDto.FromComment(created));
}
[HttpGet("{postId:int}/comments/{commentId:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CommentDto>> GetComment(int postId, int commentId)
{
var entry = await postService.GetById(postId);
if (entry == null) return NotFound();
var comment = await commentService.GetById(commentId);
if (comment == null) return NotFound();
return Ok(CommentDto.FromComment(comment));
}
[HttpGet("{postId:int}/comments")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PagedList<CommentDto>>> GetAllComments(
int postId,
[Range(1, int.MaxValue)] int pageNumber = 1
)
{
var post = await postService.GetById(postId);
if (post == null) return NotFound();
var list = await commentService.GetAll(pageNumber);
var newItems = list.Items.Select(CommentDto.FromComment).ToList();
return Ok(new PagedList<CommentDto>(newItems, list.CurrentPage, list.PageSize, list.TotalCount));
}
[Authorize]
[HttpDelete("{postId:int}/comments/{commentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteComment(int postId, int commentId)
{
var post = await postService.GetById(postId);
if (post == null) return NotFound();
var comment = await commentService.GetById(commentId);
if (comment == null) return NotFound();
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var isAdmin = HttpContext.User.IsInRole(UserRoles.Admin);
// If neither the admin nor the resource owner
if (!isAdmin && userId != comment.Author.Id)
{
Forbid();
}
var deleted = await commentService.Delete(comment);
if (!deleted) return NotFound();
return NoContent();
}
[Authorize]
[HttpPatch("{postId:int}/comments/{commentId:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CommentDto>> Update(int postId, int commentId, EditCommentDto dto)
{
var post = await postService.GetById(postId);
if (post == null) return NotFound();
var comment = await commentService.GetById(commentId);
if (comment == null) return NotFound();
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var isAdmin = HttpContext.User.IsInRole(UserRoles.Admin);
// If neither the admin nor the resource owner
if (!isAdmin && userId != comment.Author.Id)
{
Forbid();
}
comment.Text = dto.Text;
var updated = await commentService.Update(comment);
return Ok(CommentDto.FromComment(updated));
}
}

View File

@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Mvc;
using T120B165_ImgBoard.Dtos;
using T120B165_ImgBoard.Dtos.Tag;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Services;
using T120B165_ImgBoard.Utils;
namespace T120B165_ImgBoard.Controllers;
[ApiController]
[Route("api/tags")]
public class TagController(ITagService tagService) : ControllerBase
{
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<Tag>> Create(CreateTagDto dto)
{
// Check if tag exists, if it does, throw a conflict
var existingTag = await tagService.GetByName(dto.Name);
if (existingTag != null)
{
return Problem(
detail: "Tag with such name already exists.",
statusCode: StatusCodes.Status409Conflict
);
}
var createdTag = await tagService.Create(dto.Type, dto.Name);
return CreatedAtAction(nameof(Get), new { name = createdTag.Name }, createdTag);
}
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PagedList<Tag>>> GetAll(int pageNumber = 1)
{
return Ok(await tagService.GetAll(pageNumber));
}
[HttpGet("{name}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Tag>> Get(string name)
{
var tag = await tagService.GetByName(name);
if (tag == null)
{
return NotFound();
}
return Ok(tag);
}
[HttpDelete("{name}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Delete(string name)
{
var deleted = await tagService.DeleteByName(name);
if (!deleted) return NotFound();
return NoContent();
}
[HttpPatch("{name}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Tag>> Update(string name, EditTagDto dto)
{
var tag = await tagService.GetByName(name);
if (tag == null) return NotFound();
var updatedTag = await tagService.Update(tag, dto.Type);
return Ok(updatedTag);
}
}

View File

@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Identity;
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Data;
public static class DbInitializer
{
public static async Task SeedRolesAsync(IServiceProvider serviceProvider)
{
var roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();
string[] roleNames = [UserRoles.Admin, UserRoles.Regular];
foreach (var roleName in roleNames)
{
if (!await roleManager.RoleExistsAsync(roleName))
{
await roleManager.CreateAsync(new IdentityRole(roleName));
}
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Models;
using File = T120B165_ImgBoard.Models.File;
namespace T120B165_ImgBoard.Data;
public class ImgBoardContext(DbContextOptions<ImgBoardContext> options) : IdentityDbContext<User>(options)
{
public DbSet<RefreshToken> RefreshTokens { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<Post> Posts { get; set; }
public DbSet<Comment> Comments { get; set; }
public DbSet<File> Files { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<RefreshToken>().ToTable("RefreshTokens");
modelBuilder.Entity<Tag>().ToTable("Tags");
modelBuilder.Entity<Post>().ToTable("Posts");
modelBuilder.Entity<File>().ToTable("Files");
modelBuilder.Entity<Comment>().ToTable("Comments");
}
}

View File

@@ -0,0 +1,25 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["T120B165-ImgBoard/T120B165-ImgBoard.csproj", "T120B165-ImgBoard/"]
RUN dotnet restore "T120B165-ImgBoard/T120B165-ImgBoard.csproj"
COPY . .
WORKDIR "/src/T120B165-ImgBoard"
RUN dotnet build "./T120B165-ImgBoard.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./T120B165-ImgBoard.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN mkdir -p /app/Storage && chown -R app:app /app/Storage
VOLUME /app/Storage
ENTRYPOINT ["dotnet", "T120B165-ImgBoard.dll"]

View File

@@ -0,0 +1,13 @@
namespace T120B165_ImgBoard.Dtos.Comment;
public record CommentDto(int Id, string Text, SlimUserDto Author)
{
public static CommentDto FromComment(Models.Comment comment)
{
return new CommentDto(
Id: comment.Id,
Text: comment.Text,
Author: SlimUserDto.FromUser(comment.Author)
);
}
}

View File

@@ -0,0 +1,4 @@
namespace T120B165_ImgBoard.Dtos.Comment;
public record CreateCommentDto(string Text);
public record EditCommentDto(string Text);

View File

@@ -0,0 +1,3 @@
namespace T120B165_ImgBoard.Dtos;
public record LoginDto(string Email, string Password);

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
namespace T120B165_ImgBoard.Dtos.Post;
public record CreatePostDto(string Title,
[Required]
string Description,
[Required]
List<string> Tags,
[Required]
string FileName,
[Required]
string FileMimeType,
[Required]
[Range(0, long.MaxValue, ErrorMessage = "The {0} must be a valid number and at least {1}.")]
long? FileSize
);
public record EditPostDto(string? Title, string? Description, List<string>? Tags): IValidatableObject
{
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var allEmpty = string.IsNullOrWhiteSpace(Title) &&
string.IsNullOrWhiteSpace(Description) &&
Tags == null;
if (allEmpty)
{
yield return new ValidationResult(
"You must provide at least one value to edit: Title, Description, or Tags.",
[nameof(Title), nameof(Description), nameof(Tags)]
);
}
}
}

View File

@@ -0,0 +1,25 @@
using File = T120B165_ImgBoard.Models.File;
namespace T120B165_ImgBoard.Dtos.Post;
public record PostDto(
int Id,
string Title,
string Description,
SlimUserDto Author,
List<Models.Tag> Tags,
string? FileUrl
)
{
public static PostDto FromPost(Models.Post post, string? fileUrl)
{
return new PostDto(
Id: post.Id,
Title: post.Title,
Description: post.Description,
Author: SlimUserDto.FromUser(post.Author),
Tags: post.Tags,
FileUrl: fileUrl
);
}
}

View File

@@ -0,0 +1,3 @@
namespace T120B165_ImgBoard.Dtos;
public record RefreshDto(string RefreshToken);

View File

@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace T120B165_ImgBoard.Dtos;
public record RegisterDto(
string UserName,
[EmailAddress]
string Email,
string Password
);

View File

@@ -0,0 +1,11 @@
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Dtos;
public record SlimUserDto(string UserId, string UserName)
{
public static SlimUserDto FromUser(User user)
{
return new SlimUserDto(user.Id, user.UserName);
}
};

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Dtos.Tag;
public record CreateTagDto(
[Required]
TagType Type,
[Required]
[StringLength(64)]
string Name
);
public record EditTagDto([Required] TagType Type);

View File

@@ -0,0 +1,3 @@
namespace T120B165_ImgBoard.Dtos;
public record TokenDto(string AccessToken, string RefreshToken);

View File

@@ -0,0 +1,5 @@
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Dtos;
public record UserDto(int Id, string Name, string Email);

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace T120B165_ImgBoard.Models;
public class Comment
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public string Text { get; set; }
public User Author { get; set; }
public Post OriginalPost { get; set; }
public DateTime Created { get; set; }
public DateTime? Updated { get; set; }
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace T120B165_ImgBoard.Models;
public class File
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
// The physical path where the file is stored
public required string FilePath { get; set; }
// Original file name uploaded by the user
public required string OriginalFileName { get; set; }
// The size of the file in bytes
public required long Size { get; set; }
// The MIME type, will be validated at the end of the upload
public required string ContentType { get; set; }
// Tracks when the file metadata was created/upload process started
public required DateTime CreatedDate { get; set; }
// Tracks when the file was successfully finalized
// Null until the final PATCH request completes
public DateTime? FinishedDate { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace T120B165_ImgBoard.Models;
public class Post
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public required string Title { get; set; }
public required string Description { get; set; }
public User Author { get; set; }
public required DateTime Created { get; set; }
public required List<Tag> Tags { get; set; }
public required File File { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace T120B165_ImgBoard.Models;
public enum TagType
{
General,
Copyright,
}
public class Tag
{
[Key]
public required string Name { get; init; }
public required TagType Type { get; set; }
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Identity;
namespace T120B165_ImgBoard.Models;
public static class UserRoles
{
public const string Admin = "Admin";
public const string Regular = "Regular";
}
public class RefreshToken
{
[Key]
public string Token { get; set; }
public User User { get; set; }
public DateTime Expires { get; set; }
}
public class User : IdentityUser
{
//public List<Post> Posts { get; set; }
};

View File

@@ -0,0 +1,108 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Converters;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Services;
namespace T120B165_ImgBoard;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddControllers().AddNewtonsoftJson(
options => options.SerializerSettings.Converters.Add(new StringEnumConverter()));
builder.Services.AddOpenApiDocument();
builder.Services.AddOpenApi();
builder.Services.AddIdentity<User, IdentityRole>()
.AddEntityFrameworkStores<ImgBoardContext>()
.AddDefaultTokenProviders();
builder.Services.AddScoped<ITagService, TagService>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IPostService, PostService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ICommentService, CommentService>();
builder.Services.AddScoped<IFileService, FileService>();
// If we're in dev environment, use in-memory database
if (builder.Environment.IsDevelopment())
{
builder.Services.AddDbContext<ImgBoardContext>(options =>
options.UseInMemoryDatabase("T120B165_ImgBoard"));
}
else
{
Console.WriteLine("Running in production mode");
builder.Services.AddDbContext<ImgBoardContext>(options =>
options.UseMySql(
builder.Configuration.GetConnectionString("DbContext"),
new MySqlServerVersion(new Version(11, 8, 3))
)
);
}
var jwtSettings = builder.Configuration.GetSection("Jwt");
var key = Encoding.UTF8.GetBytes(jwtSettings["Key"]);
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(key)
};
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseOpenApi();
app.UseSwaggerUi();
}
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
var context = services.GetRequiredService<ImgBoardContext>();
context.Database.EnsureCreated();
DbInitializer.SeedRolesAsync(services).GetAwaiter().GetResult();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5259",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7261;http://localhost:5259",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Utils;
namespace T120B165_ImgBoard.Services;
public interface ICommentService
{
Task<Comment> Create(string text, User author, Post post);
Task<Comment?> GetById(int commentId);
Task<PagedList<Comment>> GetAll(int pageNumber = 1);
Task<bool> Delete(Comment comment);
Task<Comment> Update(Comment comment);
}
public class CommentService(ImgBoardContext context): ICommentService
{
private const int PageSize = 20;
public async Task<Comment> Create(string text, User author, Post post)
{
var entry = new Comment
{
Text = text,
Author = author,
OriginalPost = post,
Created = DateTime.Now,
};
await context.Comments.AddAsync(entry);
await context.SaveChangesAsync();
return entry;
}
public async Task<Comment?> GetById(int commentId)
{
return await context.Comments.Where(t => t.Id == commentId)
.Include(b => b.Author)
.Include(b => b.OriginalPost)
.FirstOrDefaultAsync();
}
public async Task<PagedList<Comment>> GetAll(int pageNumber = 1)
{
var totalCount = await context.Comments.CountAsync();
var items = await context.Comments
.Skip((pageNumber - 1) * PageSize)
.Take(PageSize)
.Include(b => b.Author)
.Include(b => b.OriginalPost)
.ToListAsync();
return new PagedList<Comment>(items, pageNumber, PageSize, totalCount);
}
public async Task<bool> Delete(Comment comment)
{
context.Comments.Remove(comment);
await context.SaveChangesAsync();
return true;
}
public async Task<Comment> Update(Comment comment)
{
context.Comments.Update(comment);
await context.SaveChangesAsync();
return comment;
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data;
using File = T120B165_ImgBoard.Models.File;
namespace T120B165_ImgBoard.Services;
public interface IFileService
{
public Task<File?> GetFileById(long id);
Task<File> Update(File file);
Task Delete(File file);
}
public class FileService(ImgBoardContext context): IFileService
{
public async Task<File?> GetFileById(long id)
{
return await context.Files.Where(f => f.Id == id).FirstOrDefaultAsync();
}
public async Task<File> Update(File file)
{
context.Files.Update(file);
await context.SaveChangesAsync();
return file;
}
public async Task Delete(File file)
{
var actualFile = await GetFileById(file.Id);
context.Files.Remove(actualFile);
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Utils;
using File = T120B165_ImgBoard.Models.File;
namespace T120B165_ImgBoard.Services;
public interface IPostService
{
Task<CreatedPost> Create(
string title, string description, List<Tag> tags, User author,
string fileName, string fileContentType, long fileSize);
Task<Post?> GetById(int postId, bool includeUnfinished = false);
Task<PagedList<Post>> GetAll(int pageNumber = 1);
Task<bool> Delete(Post post);
Task<Post> Update(Post post);
}
public record CreatedPost(Post Post, File File);
public class PostService(ImgBoardContext context): IPostService
{
private const int PageSize = 20;
public async Task<CreatedPost> Create(
string title,
string description,
List<Tag> tags,
User author,
string fileName,
string fileContentType,
long fileSize
)
{
var file = new File
{
OriginalFileName = fileName,
Size = fileSize,
ContentType = fileContentType,
FilePath = Path.GetTempFileName(),
CreatedDate = DateTime.Now,
};
await context.Files.AddAsync(file);
var entry = new Post
{
Title = title,
Description = description,
Author = author,
Created = DateTime.Now,
Tags = tags,
File = file
};
await context.Posts.AddAsync(entry);
await context.SaveChangesAsync();
return new CreatedPost(entry, file);
}
public async Task<Post?> GetById(int postId, bool includeUnfinished = false)
{
var query = context.Posts.Where(t => t.Id == postId);
if (!includeUnfinished)
{
query = query.Where(t => t.File.FinishedDate != null);
}
return await query.Include(b => b.Author)
.Include(b => b.Tags)
.Include(b => b.File)
.FirstOrDefaultAsync();
}
public async Task<PagedList<Post>> GetAll(int pageNumber = 1)
{
var totalCount = await context.Posts.Where(p => p.File.FinishedDate != null).CountAsync();
var items = await context.Posts
.Skip((pageNumber - 1) * PageSize)
.Take(PageSize)
.Where(p => p.File.FinishedDate != null)
.Include(b => b.Author)
.Include(b => b.Tags)
.Include(b => b.File)
.ToListAsync();
return new PagedList<Post>(items, pageNumber, PageSize, totalCount);
}
public async Task<bool> Delete(Post post)
{
context.Posts.Remove(post);
await context.SaveChangesAsync();
return true;
}
public async Task<Post> Update(Post post)
{
context.Posts.Update(post);
await context.SaveChangesAsync();
return post;
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Dtos;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Utils;
namespace T120B165_ImgBoard.Services;
public interface ITagService
{
Task<Tag?> GetByName(string name);
Task<bool> DeleteByName(string name);
Task<PagedList<Tag>> GetAll(int pageNumber = 1);
Task<Tag> Update(Tag tag, TagType type);
Task<Tag> Create(TagType type, string name);
}
public class TagService(ImgBoardContext context): ITagService
{
public async Task<Tag?> GetByName(string name)
{
return await context.Tags.Where(t => t.Name == name).FirstOrDefaultAsync();
}
public async Task<PagedList<Tag>> GetAll(int pageNumber)
{
int pageSize = 20;
var totalCount = await context.Tags.CountAsync();
var products = await context.Tags
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedList<Tag>(products, pageNumber, pageSize, totalCount);;
}
public async Task<Tag> Update(Tag tag, TagType type)
{
tag.Type = type;
context.Tags.Update(tag);
await context.SaveChangesAsync();
return tag;
}
public async Task<bool> DeleteByName(string name)
{
var tag = await context.Tags.FindAsync(name);
if (tag == null) return false;
context.Tags.Remove(tag);
await context.SaveChangesAsync();
return true;
}
public async Task<Tag> Create(TagType type, string name)
{
var tag = new Tag
{
Name = name,
Type = type,
};
await context.Tags.AddAsync(tag);
await context.SaveChangesAsync();
return tag;
}
}

View File

@@ -0,0 +1,74 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Services;
public interface ITokenService
{
Task<string> GenerateJwtToken(User user);
Task<string> GenerateRefreshToken(User user);
Task<RefreshToken?> GetRefreshTokenByValue(string refreshToken);
Task<bool> InvalidateRefreshToken(RefreshToken refreshToken);
}
public class TokenService(ImgBoardContext context, IConfiguration config, UserManager<User> userManager): ITokenService
{
public async Task<RefreshToken?> GetRefreshTokenByValue(string refreshToken)
{
return await context.RefreshTokens.Where(t => t.Token == refreshToken && t.Expires > DateTime.Now)
.Include(t => t.User)
.FirstOrDefaultAsync();
}
public async Task<bool> InvalidateRefreshToken(RefreshToken refreshToken)
{
context.RefreshTokens.Remove(refreshToken);
await context.SaveChangesAsync();
return true;
}
public async Task<string> GenerateJwtToken(User user)
{
var roles = await userManager.GetRolesAsync(user);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(JwtRegisteredClaimNames.Email, user.Email)
};
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: config["Jwt:Issuer"],
audience: config["Jwt:Audience"],
claims: claims,
expires: DateTime.Now.AddMinutes(Convert.ToDouble(config["Jwt:DurationInMinutes"])),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public async Task<string> GenerateRefreshToken(User user)
{
var token = new RefreshToken
{
Expires = DateTime.Now.AddDays(30),
Token = Guid.NewGuid().ToString(),
User = user
};
await context.RefreshTokens.AddAsync(token);
await context.SaveChangesAsync();
return token.Token;
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Services;
public interface IUserService
{
public Task<User?> GetUserById(string id);
}
public class UserService(ImgBoardContext context): IUserService
{
public async Task<User?> GetUserById(string id)
{
return await context.Users.Where(u => u.Id == id).FirstOrDefaultAsync();
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>T120B165_ImgBoard</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FileSignatures" Version="6.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.9" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NSwag.AspNetCore" Version="14.5.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
namespace T120B165_ImgBoard.Utils;
public class PagedList<T>(List<T> items, int pageNumber, int pageSize, int totalCount)
{
public int CurrentPage { get; } = pageNumber;
public int PageSize { get; } = pageSize;
public int TotalCount { get; } = totalCount;
public int TotalPages => (int) Math.Ceiling((double)TotalCount / PageSize);
public List<T> Items { get; } = items;
}

View File

@@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Jwt": {
"Key": "your_secret_key_here_asta_lavista_baby",
"Issuer": "your_app",
"Audience": "your_app_users",
"DurationInMinutes": 60
}
}

29
compose.yaml Normal file
View File

@@ -0,0 +1,29 @@
services:
t120b165-imgboard:
image: t120b165-imgboard
container_name: t120b165_app
restart: unless-stopped
build:
context: .
dockerfile: T120B165-ImgBoard/Dockerfile
depends_on:
- db
ports:
- "8080:8080"
volumes:
- app_data:/app/Storage
db:
image: mariadb:lts
container_name: t120b165_db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: supersecretpassword
MARIADB_DATABASE: imgboard_db
MARIADB_USER: imgboard
MARIADB_PASSWORD: supersecretpassword
volumes:
- ./db_data:/var/lib/mysql
volumes:
app_data:
driver: local

1482
swagger.json Normal file

File diff suppressed because it is too large Load Diff