Initial commit

This commit is contained in:
MarcUs7i 2025-04-18 19:13:47 +02:00
commit ea82926500
58 changed files with 9323 additions and 0 deletions

25
Server/.dockerignore Normal file
View file

@ -0,0 +1,25 @@
**/.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
LICENSE
README.md

22
Server/Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 9500
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Server/Server.csproj", "Server/"]
RUN dotnet restore "Server/Server.csproj"
COPY . .
WORKDIR "/src/Server"
RUN dotnet build "Server.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "Server.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Server.dll"]

21
Server/Server.sln Normal file
View file

@ -0,0 +1,21 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server\Server.csproj", "{89ED2F06-A378-47E5-8DF8-A44335C01BBA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B5BEF36F-A30C-4690-BE85-5B97D83ED7F2}"
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
{89ED2F06-A378-47E5-8DF8-A44335C01BBA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{89ED2F06-A378-47E5-8DF8-A44335C01BBA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{89ED2F06-A378-47E5-8DF8-A44335C01BBA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{89ED2F06-A378-47E5-8DF8-A44335C01BBA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,3 @@
MONGODB_URI=mongodb://user:password@localhost:27017/database
ADMIN_LOGIN_ID=1B3z
DISCORD_WEBHOOK=https://discord.com/api/webhooks/...

View file

@ -0,0 +1,479 @@
using Microsoft.AspNetCore.Mvc;
using WritingServer.Models;
using WritingServer.Services;
using WritingServer.Utils;
namespace WritingServer.Controllers;
[ApiController]
[Route("api/admin")]
public class AdminController : ControllerBase
{
private readonly IConfiguration _configuration;
private readonly IUserManagementService _userManagementService;
private readonly IAdminTokenService _tokenService;
private readonly IQuestionManagementService _questionManagementService;
public AdminController(IConfiguration configuration, IUserManagementService userManagementService,
IAdminTokenService tokenService, IQuestionManagementService questionManagementService)
{
_configuration = configuration;
_userManagementService = userManagementService;
_tokenService = tokenService;
_questionManagementService = questionManagementService;
}
[HttpGet("test-exception")]
public IActionResult TestException()
{
throw new Exception("This is a test exception");
}
[HttpPost("login")]
public ActionResult<AdminLoginResponse> Login([FromBody] AdminLoginRequest request)
{
var adminLoginId = _configuration["ADMIN_LOGIN_ID"];
if (string.IsNullOrEmpty(adminLoginId) || request.LoginId != adminLoginId)
{
return Ok(new AdminLoginResponse
{
Success = false,
ErrorMessage = "Invalid login credentials"
});
}
var token = TokenGenerator.GenerateToken(32);
_tokenService.StoreToken(token);
return Ok(new AdminLoginResponse
{
Success = true,
AccessToken = token
});
}
private string? GetBearerToken()
{
if (!HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
return null;
}
string auth = authHeader.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return auth.Substring(7).Trim();
}
private bool ValidateAdminToken(string? token)
{
if (string.IsNullOrEmpty(token))
{
return false;
}
return _tokenService.ValidateToken(token);
}
#region User Management
[HttpGet("users")]
public async Task<ActionResult<AdminUserListResponse>> GetUsers()
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminUserListResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var users = await _userManagementService.GetAllUsersAsync();
return Ok(new AdminUserListResponse { Success = true, Users = users });
}
[HttpPost("users")]
public async Task<ActionResult<AdminUserResponse>> AddUser([FromBody] AdminAddUserRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminUserResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.UserName))
{
return BadRequest(new AdminUserResponse
{
Success = false,
ErrorMessage = "Username cannot be empty"
});
}
await _userManagementService.AddUserAsync(request.UserName);
return Ok(new AdminUserResponse { Success = true });
}
[HttpPut("users")]
public async Task<ActionResult<AdminUserResponse>> UpdateUser([FromBody] AdminEditUserRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminUserResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.NewUserName))
{
return BadRequest(new AdminUserResponse
{
Success = false,
ErrorMessage = "Username cannot be empty"
});
}
var success = await _userManagementService.UpdateUserNameAsync(request.UserName, request.NewUserName);
return Ok(new AdminUserResponse
{
Success = success,
ErrorMessage = success ? null : "Failed to update user - username may already exist"
});
}
[HttpPut("users/reset")]
public async Task<ActionResult<AdminUserResponse>> ResetUser([FromBody] AdminUserResetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminUserResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var user = await _userManagementService.GetUserByNameAsync(request.UserName);
if (user == null)
{
return NotFound(new AdminUserResponse
{
Success = false,
ErrorMessage = "User not found"
});
}
var success = await _userManagementService.ResetUserAsync(request.UserName);
return Ok(new AdminUserResponse { Success = success });
}
[HttpDelete("users")]
public async Task<ActionResult<AdminUserResponse>> DeleteUser([FromBody] AdminDeleteUserRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminUserResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var success = await _userManagementService.DeleteUserAsync(request.UserName);
return Ok(new AdminUserResponse
{
Success = success,
ErrorMessage = success ? null : "User not found"
});
}
#endregion
#region Question Set Management
[HttpGet("questionsets")]
public async Task<ActionResult<AdminListQuestionSetsResponse>> GetQuestionSets()
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminListQuestionSetsResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var questionSets = await _questionManagementService.GetAllQuestionSetsAsync();
return Ok(new AdminListQuestionSetsResponse { Success = true, QuestionSets = questionSets });
}
[HttpPost("questionsets")]
public async Task<ActionResult<AdminQuestionSetResponse>> CreateQuestionSet([FromBody] AdminCreateQuestionSetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.QuestionSetName))
{
return BadRequest(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Question set name cannot be empty"
});
}
var questionSet = await _questionManagementService.CreateQuestionSetAsync(request.QuestionSetName);
return Ok(new AdminQuestionSetResponse { Success = true, QuestionSet = questionSet });
}
[HttpPut("questionsets/order")]
public async Task<ActionResult<AdminQuestionSetResponse>> UpdateQuestionSetOrder([FromBody] AdminUpdateOrderQuestionSetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var success = await _questionManagementService.UpdateQuestionSetOrderAsync(request.QuestionSetId, request.QuestionSetOrder);
if (!success)
{
return NotFound(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Question set not found"
});
}
var questionSet = await _questionManagementService.GetQuestionSetByIdAsync(request.QuestionSetId);
return Ok(new AdminQuestionSetResponse { Success = true, QuestionSet = questionSet });
}
[HttpPut("questionsets/lock")]
public async Task<ActionResult<AdminQuestionSetResponse>> UpdateQuestionSetLock([FromBody] AdminUpdateLockQuestionSetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var success = await _questionManagementService.UpdateQuestionSetLockStatusAsync(request.QuestionSetId, request.Locked);
if (!success)
{
return NotFound(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Question set not found"
});
}
var questionSet = await _questionManagementService.GetQuestionSetByIdAsync(request.QuestionSetId);
return Ok(new AdminQuestionSetResponse { Success = true, QuestionSet = questionSet });
}
[HttpPut("questionsets/name")]
public async Task<ActionResult<AdminQuestionSetResponse>> UpdateQuestionSetName([FromBody] AdminUpdateNameQuestionSetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.NewQuestionSetName))
{
return BadRequest(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Question set name cannot be empty"
});
}
var success = await _questionManagementService.UpdateQuestionSetNameAsync(request.QuestionSetId, request.NewQuestionSetName);
if (!success)
{
return NotFound(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Question set not found"
});
}
var questionSet = await _questionManagementService.GetQuestionSetByIdAsync(request.QuestionSetId);
return Ok(new AdminQuestionSetResponse { Success = true, QuestionSet = questionSet });
}
[HttpDelete("questionsets")]
public async Task<ActionResult<AdminDeleteQuestionSetResponse>> DeleteQuestionSet([FromBody] AdminDeleteQuestionSetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminDeleteQuestionSetResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var success = await _questionManagementService.DeleteQuestionSetAsync(request.QuestionSetId);
return Ok(new AdminDeleteQuestionSetResponse { Success = success });
}
#endregion
#region Question Management
[HttpGet("questions")]
public async Task<ActionResult<AdminListQuestionsResponse>> GetQuestions([FromQuery] string questionSetId)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminListQuestionsResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var questions = await _questionManagementService.GetQuestionsInSetAsync(questionSetId);
return Ok(new AdminListQuestionsResponse { Success = true, Questions = questions });
}
[HttpPost("questions")]
public async Task<ActionResult<AdminQuestionResponse>> CreateQuestion([FromBody] AdminCreateQuestionRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.QuestionText))
{
return BadRequest(new AdminQuestionResponse
{
Success = false,
ErrorMessage = "Question text cannot be empty"
});
}
var question = await _questionManagementService.CreateQuestionAsync(
request.QuestionSetId,
request.QuestionText,
request.ExpectedResultText,
request.QuestionOrder,
request.MinWordLength,
request.MaxWordLength);
return Ok(new AdminQuestionResponse { Success = true, QuestionModels = question });
}
[HttpPut("questions")]
public async Task<ActionResult<AdminQuestionResponse>> UpdateQuestion([FromBody] AdminUpdateQuestionRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.QuestionText))
{
return BadRequest(new AdminQuestionResponse
{
Success = false,
ErrorMessage = "Question text cannot be empty"
});
}
var question = await _questionManagementService.UpdateQuestionAsync(
request.QuestionId,
request.QuestionText,
request.ExpectedResultText,
request.QuestionOrder,
request.MinWordLength,
request.MaxWordLength);
return Ok(new AdminQuestionResponse { Success = true, QuestionModels = question });
}
[HttpDelete("questions")]
public async Task<ActionResult<AdminDeleteQuestionResponse>> DeleteQuestion([FromBody] AdminDeleteQuestionRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminDeleteQuestionResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var success = await _questionManagementService.DeleteQuestionAsync(request.QuestionId);
return Ok(new AdminDeleteQuestionResponse { Success = success });
}
#endregion
#region Response Management
[HttpGet("responses")]
public async Task<ActionResult<AdminGetResponsesResponse>> GetResponses([FromQuery] string questionId)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminGetResponsesResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var responses = await _questionManagementService.GetResponsesForQuestionAsync(questionId);
return Ok(new AdminGetResponsesResponse { Success = true, Responses = responses });
}
#endregion
}

View file

@ -0,0 +1,283 @@
using Microsoft.AspNetCore.Mvc;
using WritingServer.Models;
using WritingServer.Services;
namespace WritingServer.Controllers;
[ApiController]
[Route("api/user")]
public class UserController : ControllerBase
{
private readonly IUserManagementService _userManagementService;
private readonly IQuestionManagementService _questionManagementService;
public UserController(IUserManagementService userManagementService, IQuestionManagementService questionManagementService)
{
_userManagementService = userManagementService;
_questionManagementService = questionManagementService;
}
[HttpPost("login")]
public async Task<ActionResult<UserLoginResponse>> Login([FromBody] UserLoginRequest request)
{
var user = await _userManagementService.GetUserByPinAsync(request.Pin);
if (user == null)
{
return Ok(new UserLoginResponse
{
Success = false,
ErrorMessage = "Invalid PIN"
});
}
var accessToken = _userManagementService.GenerateAccessToken(user.Username);
if (user.ResetState)
{
_userManagementService.ResetLoginState(user.Username);
}
return Ok(new UserLoginResponse
{
Success = true,
AccessToken = accessToken
});
}
[HttpGet("username")]
public async Task<ActionResult<UserGetUsernameResponse>> GetUsername()
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetUsernameResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserGetUsernameResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
return Ok(new UserGetUsernameResponse
{
Success = true,
Username = user.Username
});
}
[HttpGet("state")]
public async Task<ActionResult<UserGetStateResponse>> GetState()
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
return Ok(new UserGetStateResponse
{
Success = true,
ResetState = user.ResetState
});
}
[HttpGet("questionsets")]
public async Task<ActionResult<UserGetQuestionSetsResponse>> GetQuestionSets()
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserGetQuestionSetsResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var questionSets = await _questionManagementService.GetAllQuestionSetsAsync();
// Only return unlocked question sets to users
questionSets.QuestionSets = questionSets.QuestionSets.Where(qs => !qs.Locked).ToList();
return Ok(new UserGetQuestionSetsResponse
{
Success = true,
QuestionSets = questionSets
});
}
[HttpGet("questions")]
public async Task<ActionResult<UserGetQuestionsResponse>> GetQuestions([FromQuery] string questionSetId)
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserGetQuestionsResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var questionSet = await _questionManagementService.GetQuestionSetByIdAsync(questionSetId);
if (questionSet.Locked)
{
return BadRequest(new UserGetQuestionsResponse
{
Success = false,
ErrorMessage = "This question set is locked"
});
}
var questions = await _questionManagementService.GetQuestionsInSetAsync(questionSetId);
return Ok(new UserGetQuestionsResponse
{
Success = true,
Questions = questions
});
}
[HttpPost("responses")]
public async Task<ActionResult<UserSubmitResponseResponse>> SubmitResponse([FromBody] UserSubmitResponseRequest request)
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserSubmitResponseResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.ResponseText))
{
return BadRequest(new UserSubmitResponseResponse
{
Success = false,
ErrorMessage = "Response text cannot be empty"
});
}
var question = await _questionManagementService.GetQuestionByIdAsync(request.QuestionId);
var wordCount = request.ResponseText.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
if (wordCount < question.MinWordLength || wordCount > question.MaxWordLength)
{
return BadRequest(new UserSubmitResponseResponse
{
Success = false,
ErrorMessage = $"Response must be between {question.MinWordLength} and {question.MaxWordLength} words"
});
}
await _questionManagementService.SubmitResponseAsync(request.QuestionId, user.Username, request.ResponseText);
return Ok(new UserSubmitResponseResponse { Success = true });
}
[HttpGet("responses")]
public async Task<ActionResult<UserGetResponsesResponse>> GetResponses([FromQuery] string questionId)
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserGetResponsesResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var responses = await _questionManagementService.GetResponsesForUserAsync(user.Username, questionId);
return Ok(new UserGetResponsesResponse
{
Success = true,
Responses = responses
});
}
private string? GetBearerToken()
{
if (!HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
return null;
}
string auth = authHeader.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return auth.Substring(7).Trim();
}
}

View file

@ -0,0 +1,191 @@
namespace WritingServer.Models;
#region Admin Login
public class AdminLoginRequest
{
public string LoginId { get; set; } = string.Empty;
}
public class AdminLoginResponse
{
public bool Success { get; set; }
public string AccessToken { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
}
#endregion
#region User Administration
public class AdminAddUserRequest
{
public string AccessToken { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
}
public class AdminEditUserRequest
{
public string AccessToken { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string NewUserName { get; set; } = string.Empty;
}
public class AdminDeleteUserRequest
{
public string AccessToken { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
}
public class AdminUserResetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
}
public class AdminUserResponse
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
public class AdminUserListRequest
{
public string AccessToken { get; set; } = string.Empty;
}
public class AdminUserListResponse
{
public bool Success { get; set; }
public UserModelList Users { get; set; } = new();
public string? ErrorMessage { get; set; }
}
#endregion
#region Question Set Management
public class AdminCreateQuestionSetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetName { get; set; } = string.Empty;
}
public class AdminUpdateOrderQuestionSetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
public int QuestionSetOrder { get; set; }
}
public class AdminUpdateLockQuestionSetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
public bool Locked { get; set; }
}
public class AdminUpdateNameQuestionSetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
public string NewQuestionSetName { get; set; } = string.Empty;
}
public class AdminDeleteQuestionSetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
}
public class AdminQuestionSetResponse
{
public bool Success { get; set; }
public QuestionSet QuestionSet { get; set; } = new();
public string? ErrorMessage { get; set; }
}
public class AdminDeleteQuestionSetResponse
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
public class AdminListQuestionSetsRequest
{
public string AccessToken { get; set; } = string.Empty;
}
public class AdminListQuestionSetsResponse
{
public bool Success { get; set; }
public QuestionSetList QuestionSets { get; set; } = new();
public string? ErrorMessage { get; set; }
}
#endregion
#region Question Management
public class AdminCreateQuestionRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
public string QuestionText { get; set; } = string.Empty;
public string ExpectedResultText { get; set; } = string.Empty;
public int QuestionOrder { get; set; }
public int MinWordLength { get; set; }
public int MaxWordLength { get; set; }
}
public class AdminUpdateQuestionRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
public string QuestionText { get; set; } = string.Empty;
public string ExpectedResultText { get; set; } = string.Empty;
public int QuestionOrder { get; set; }
public int MinWordLength { get; set; }
public int MaxWordLength { get; set; }
}
public class AdminDeleteQuestionRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
}
public class AdminQuestionResponse
{
public bool Success { get; set; }
public QuestionModels QuestionModels { get; set; } = new();
public string? ErrorMessage { get; set; }
}
public class AdminDeleteQuestionResponse
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
public class AdminListQuestionsRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
}
public class AdminListQuestionsResponse
{
public bool Success { get; set; }
public QuestionModelList Questions { get; set; } = new();
public string? ErrorMessage { get; set; }
}
#endregion
#region Reponses
public class AdminGetResponsesRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
}
public class AdminGetResponsesResponse
{
public bool Success { get; set; }
public ResponseList Responses { get; set; } = new();
public string? ErrorMessage { get; set; }
}
#endregion

View file

@ -0,0 +1,54 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace WritingServer.Models;
public class QuestionSet
{
[BsonId]
[BsonRepresentation(BsonType.String)]
public string QuestionSetId { get; set; } = string.Empty;
public string QuestionSetName { get; set; } = string.Empty;
public int QuestionSetOrder { get; set; } = 0;
public bool Locked { get; set; } = true;
public QuestionModels Questions { get; set; } = new();
}
public class QuestionSetList
{
public List<QuestionSet> QuestionSets { get; set; } = [];
}
public class QuestionModels
{
[BsonId]
[BsonRepresentation(BsonType.String)]
public string QuestionId { get; set; } = string.Empty;
public string QuestionText { get; set; } = string.Empty;
public string ExpectedResultText { get; set; } = string.Empty;
public int QuestionOrder { get; set; } = 0;
public int MinWordLength { get; set; }
public int MaxWordLength { get; set; }
public ResponseList ResponseList { get; set; } = new();
}
public class QuestionModelList
{
public List<QuestionModels> Questions { get; set; } = [];
}
public class ResponseModels
{
[BsonId]
[BsonRepresentation(BsonType.String)]
public string ResponseId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
public string ResponseText { get; set; } = string.Empty;
public DateTime ResponseTime { get; set; }
}
public class ResponseList
{
public List<ResponseModels> Response { get; set; } = [];
}

View file

@ -0,0 +1,94 @@
namespace WritingServer.Models;
#region User Login
public class UserLoginRequest
{
public string Pin { get; set; } = string.Empty;
}
public class UserLoginResponse
{
public bool Success { get; set; }
public string AccessToken { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
}
#endregion
#region User States
public class UserGetUsernameRequest
{
public string AccessToken { get; set; } = string.Empty;
}
public class UserGetUsernameResponse
{
public bool Success { get; set; }
public string Username { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
}
public class UserGetResetState
{
public string AccessToken { get; set; } = string.Empty;
}
public class UserGetStateResponse
{
public bool Success { get; set; }
public bool ResetState { get; set; }
public string? ErrorMessage { get; set; }
}
#endregion
#region Questions
public class UserGetQuestionSetsRequest
{
public string AccessToken { get; set; } = string.Empty;
}
public class UserGetQuestionSetsResponse
{
public bool Success { get; set; }
public QuestionSetList QuestionSets { get; set; } = new();
public string? ErrorMessage { get; set; }
}
public class UserGetQuestionsRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
}
public class UserGetQuestionsResponse
{
public bool Success { get; set; }
public QuestionModelList Questions { get; set; } = new();
public string? ErrorMessage { get; set; }
}
public class UserSubmitResponseRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
public string ResponseText { get; set; } = string.Empty;
}
public class UserSubmitResponseResponse
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
public class UserGetResponsesRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
}
public class UserGetResponsesResponse
{
public bool Success { get; set; }
public ResponseList Responses { get; set; } = new();
public string? ErrorMessage { get; set; }
}
#endregion

View file

@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace WritingServer.Models;
public class UserModel
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
[JsonIgnore]
public string Id { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Pin { get; set; } = string.Empty;
public bool ResetState { get; set; } = false;
}
public class UserModelList
{
public List<UserModel> Users { get; set; } = [];
}

87
Server/Server/Program.cs Normal file
View file

@ -0,0 +1,87 @@
using System.Diagnostics;
using MongoDB.Driver;
using WritingServer.Services;
using WritingServer.Utils;
var startTime = DateTime.UtcNow;
var builder = WebApplication.CreateBuilder(args);
// Load .env file
DotNetEnv.Env.Load();
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddControllersWithViews();
builder.Services.AddHealthChecks();
// Add CORS configuration
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowClientApp", policy =>
{
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowAnyOrigin();
});
});
// Add MongoDB client
builder.Services.AddSingleton<IMongoClient>(sp =>
{
var connectionString = Environment.GetEnvironmentVariable("MONGODB_URI");
if (string.IsNullOrEmpty(connectionString))
{
throw new InvalidOperationException("MongoDB connection string not found in environment variables");
}
return new MongoClient(connectionString);
});
// Add MongoDB database
builder.Services.AddSingleton(sp =>
{
var client = sp.GetRequiredService<IMongoClient>();
var url = new MongoUrl(Environment.GetEnvironmentVariable("MONGODB_URI"));
return client.GetDatabase(url.DatabaseName);
});
// Add our services
builder.Services.AddSingleton<IDiscordNotificationService, DiscordNotificationService>();
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
builder.Services.AddSingleton<IQuestionManagementService, QuestionManagementService>();
builder.Services.AddSingleton<IAdminTokenService, AdminTokenService>();
builder.Services.AddSingleton<IUserTokenService, UserTokenService>();
// Configuration
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true);
builder.Configuration.AddEnvironmentVariables();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapHealthChecks("/health");
// Use CORS before routing
app.UseCors("AllowClientApp");
var startupTime = DateTime.UtcNow - startTime;
var process = Process.GetCurrentProcess();
var memoryUsageMb = process.WorkingSet64 / (1024 * 1024);
var discordService = app.Services.GetRequiredService<IDiscordNotificationService>();
_ = discordService.SendStartupNotification(startupTime, memoryUsageMb);
app.UseGlobalExceptionHandler();
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://*:9500",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7232;http://localhost:9500",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<RootNamespace>WritingServer</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0"/>
<PackageReference Include="MongoDB.Driver" Version="3.3.0" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

224
Server/Server/Server.http Normal file
View file

@ -0,0 +1,224 @@
@baseUrl = http://localhost:9500/api
@questionId = {{createQuestion.response.body.questionModels.id}}
### Exception testing
# @name testException
GET {{baseUrl}}/admin/test-exception
### Admin Login
# @name loginAdmin
POST {{baseUrl}}/admin/login
Content-Type: application/json
{
"loginId": "{{loginAdmin}}"
}
> {%
const accessToken = response.body.accessToken;
client.global.set("adminToken", accessToken);
%}
### Admin - User Management ###
### List Users
GET {{baseUrl}}/admin/users
Authorization: Bearer {{adminToken}}
### Add User
# @name addUser
POST {{baseUrl}}/admin/users
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"userName": "TestUser123"
}
### Edit User
PUT {{baseUrl}}/admin/users
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"userName": "TestUser123",
"newUserName": "UpdatedUser123"
}
### Reset User
PUT {{baseUrl}}/admin/users/reset
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"userName": "UpdatedUser123"
}
### Delete User
DELETE {{baseUrl}}/admin/users
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"userName": "UpdatedUser123"
}
### Admin - Question Set Management ###
### List Question Sets
GET {{baseUrl}}/admin/questionsets
Authorization: Bearer {{adminToken}}
### Create Question Set
# @name createQuestionSet
POST {{baseUrl}}/admin/questionsets
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetName": "Test Question Set"
}
> {%
const questionSetId = response.body.questionSet.questionSetId;
client.global.set("questionSetId", questionSetId);
%}
### Update Question Set Name
PUT {{baseUrl}}/admin/questionsets/name
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetId": "{{questionSetId}}",
"newQuestionSetName": "Updated Question Set"
}
### Update Question Set Order
PUT {{baseUrl}}/admin/questionsets/order
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetId": "{{questionSetId}}",
"questionSetOrder": 2
}
### Lock/Unlock Question Set
PUT {{baseUrl}}/admin/questionsets/lock
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetId": "{{questionSetId}}",
"locked": true
}
### Delete Question Set
DELETE {{baseUrl}}/admin/questionsets
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetId": "{{questionSetId}}"
}
### Admin - Question Management ###
### List Questions in a Set
GET {{baseUrl}}/admin/questions?questionSetId={{questionSetId}}
Authorization: Bearer {{adminToken}}
### Create Question
# @name createQuestion
POST {{baseUrl}}/admin/questions
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetId": "{{questionSetId}}",
"questionText": "What is your favorite color?",
"expectedResultText": "A color name and explanation",
"questionOrder": 1,
"minWordLength": 10,
"maxWordLength": 100
}
### Update Question
PUT {{baseUrl}}/admin/questions
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionId": "{{questionId}}",
"questionText": "What is your favorite programming language?",
"expectedResultText": "A programming language and why",
"questionOrder": 1,
"minWordLength": 20,
"maxWordLength": 200
}
### Delete Question
DELETE {{baseUrl}}/admin/questions
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionId": "{{questionId}}"
}
### Admin - Responses ###
### Get Responses for a Question
GET {{baseUrl}}/admin/responses?questionId={{questionId}}
Authorization: Bearer {{adminToken}}
### User APIs ###
### User Login
# @name loginUser
POST {{baseUrl}}/user/login
Content-Type: application/json
{
"pin": "2D21"
}
> {%
const userToken = response.body.accessToken;
client.global.set("userToken", userToken);
%}
### Get Username
GET {{baseUrl}}/user/username
Authorization: Bearer {{userToken}}
Content-Type: application/json
### Get Reset State
GET {{baseUrl}}/user/state
Authorization: Bearer {{userToken}}
Content-Type: application/json
### User - Questions ###
### Get Question Sets
GET {{baseUrl}}/user/questionsets
Authorization: Bearer {{userToken}}
Content-Type: application/json
### Get Questions from Set
# @name getQuestions
GET {{baseUrl}}/user/questions?questionSetId={{questionSetId}}
Authorization: Bearer {{userToken}}
### Submit Response
POST {{baseUrl}}/user/responses
Authorization: Bearer {{userToken}}
Content-Type: application/json
{
"questionId": "{{questionId}}",
"responseText": "This is my response to the question. Probably, what do you say?"
}
### Get User Responses
GET {{baseUrl}}/user/responses?questionId={{questionId}}
Authorization: Bearer {{userToken}}

24
Server/Server/Server.sln Normal file
View file

@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server.csproj", "{F3EE6B2D-0B19-306C-2C68-AB272C4E49C6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F3EE6B2D-0B19-306C-2C68-AB272C4E49C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F3EE6B2D-0B19-306C-2C68-AB272C4E49C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3EE6B2D-0B19-306C-2C68-AB272C4E49C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3EE6B2D-0B19-306C-2C68-AB272C4E49C6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A18AA024-5978-4B91-8279-6928359C3DFD}
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,22 @@
namespace WritingServer.Services;
public interface IAdminTokenService
{
void StoreToken(string token);
bool ValidateToken(string token);
}
public class AdminTokenService : IAdminTokenService
{
private readonly Dictionary<string, bool> _adminTokens = new();
public void StoreToken(string token)
{
_adminTokens[token] = true;
}
public bool ValidateToken(string token)
{
return _adminTokens.ContainsKey(token) && _adminTokens[token];
}
}

View file

@ -0,0 +1,62 @@
using System.Text;
using System.Text.Json;
namespace WritingServer.Services;
public interface IDiscordNotificationService
{
Task SendStartupNotification(TimeSpan startupTime, long memoryUsageMb);
Task SendResponseNotification(string username, string questionSetName, string questionText, string responseText);
Task SendExceptionNotification(string path);
Task SendDiscordMessage(string content);
}
public class DiscordNotificationService : IDiscordNotificationService
{
private readonly HttpClient _httpClient;
private readonly string _webhookUrl;
public DiscordNotificationService()
{
_httpClient = new HttpClient();
_webhookUrl = Environment.GetEnvironmentVariable("DISCORD_WEBHOOK") ?? "";
}
public async Task SendStartupNotification(TimeSpan startupTime, long memoryUsageMb)
{
await SendDiscordMessage($"Backend warmed up!\nTook {startupTime.TotalSeconds:F2} seconds.\nMemory usage: {memoryUsageMb} MB");
}
public async Task SendResponseNotification(string username, string questionSetName, string questionText, string responseText)
{
var message = $"New Response:\n**User:** {username}\n**Question Set:** {questionSetName}\n**Question:** {questionText}\n**Response:** {responseText}";
await SendDiscordMessage(message);
}
public async Task SendExceptionNotification(string path)
{
var message = $"An unhandled exception occurred at path: {path}\nMore details can be in found in `stdout`";
await SendDiscordMessage(message);
}
public async Task SendDiscordMessage(string content)
{
if (string.IsNullOrEmpty(_webhookUrl))
{
return;
}
var payload = new { content };
var json = JsonSerializer.Serialize(payload);
var stringContent = new StringContent(json, Encoding.UTF8, "application/json");
try
{
await _httpClient.PostAsync(_webhookUrl, stringContent);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send Discord notification: {ex.Message}");
}
}
}

View file

@ -0,0 +1,246 @@
using MongoDB.Driver;
using WritingServer.Models;
using WritingServer.Utils;
namespace WritingServer.Services;
public interface IQuestionManagementService
{
// Question Set operations
Task<QuestionSet> CreateQuestionSetAsync(string name);
Task<bool> UpdateQuestionSetOrderAsync(string questionSetId, int order);
Task<bool> UpdateQuestionSetLockStatusAsync(string questionSetId, bool locked);
Task<bool> UpdateQuestionSetNameAsync(string questionSetId, string newName);
Task<bool> DeleteQuestionSetAsync(string questionSetId);
Task<QuestionSetList> GetAllQuestionSetsAsync();
Task<QuestionSet> GetQuestionSetByIdAsync(string questionSetId);
// Question operations
Task<QuestionModels> CreateQuestionAsync(string questionSetId, string questionText, string expectedResultText,
int questionOrder, int minWordLength, int maxWordLength);
Task<QuestionModels> UpdateQuestionAsync(string questionId, string questionText, string expectedResultText,
int questionOrder, int minWordLength, int maxWordLength);
Task<bool> DeleteQuestionAsync(string questionId);
Task<QuestionModelList> GetQuestionsInSetAsync(string questionSetId);
Task<QuestionModels> GetQuestionByIdAsync(string questionId);
// Response operations
Task<bool> SubmitResponseAsync(string questionId, string userName, string responseText);
Task<ResponseList> GetResponsesForQuestionAsync(string questionId);
Task<ResponseList> GetResponsesForUserAsync(string userName, string questionId);
}
public class QuestionManagementService : IQuestionManagementService
{
private readonly IMongoCollection<QuestionSet> _questionSetsCollection;
private readonly IMongoCollection<QuestionModels> _questionsCollection;
private readonly IMongoCollection<ResponseModels> _responsesCollection;
private readonly IDiscordNotificationService _discordNotificationService;
public QuestionManagementService(IMongoDatabase database, IDiscordNotificationService discordNotificationService)
{
_questionSetsCollection = database.GetCollection<QuestionSet>("QuestionSets");
_questionsCollection = database.GetCollection<QuestionModels>("Questions");
_responsesCollection = database.GetCollection<ResponseModels>("Responses");
_discordNotificationService = discordNotificationService;
}
#region Question Set operations
public async Task<QuestionSet> CreateQuestionSetAsync(string name)
{
var questionSet = new QuestionSet
{
QuestionSetId = TokenGenerator.GenerateToken(16),
QuestionSetName = name,
QuestionSetOrder = await GetNextQuestionSetOrderAsync(),
Locked = true
};
await _questionSetsCollection.InsertOneAsync(questionSet);
return questionSet;
}
private async Task<int> GetNextQuestionSetOrderAsync()
{
var maxOrder = await _questionSetsCollection
.Find(_ => true)
.SortByDescending(qs => qs.QuestionSetOrder)
.Limit(1)
.Project(qs => qs.QuestionSetOrder)
.FirstOrDefaultAsync();
return maxOrder + 1;
}
public async Task<bool> UpdateQuestionSetOrderAsync(string questionSetId, int order)
{
var result = await _questionSetsCollection.UpdateOneAsync(
qs => qs.QuestionSetId == questionSetId,
Builders<QuestionSet>.Update.Set(qs => qs.QuestionSetOrder, order));
return result.ModifiedCount > 0;
}
public async Task<bool> UpdateQuestionSetLockStatusAsync(string questionSetId, bool locked)
{
var result = await _questionSetsCollection.UpdateOneAsync(
qs => qs.QuestionSetId == questionSetId,
Builders<QuestionSet>.Update.Set(qs => qs.Locked, locked));
return result.ModifiedCount > 0;
}
public async Task<bool> UpdateQuestionSetNameAsync(string questionSetId, string newName)
{
var result = await _questionSetsCollection.UpdateOneAsync(
qs => qs.QuestionSetId == questionSetId,
Builders<QuestionSet>.Update.Set(qs => qs.QuestionSetName, newName));
return result.ModifiedCount > 0;
}
public async Task<bool> DeleteQuestionSetAsync(string questionSetId)
{
// First, find all questions in this set
var questions = await _questionsCollection
.Find(q => q.QuestionId.StartsWith(questionSetId + "_"))
.ToListAsync();
// Delete all responses for questions in this set
foreach (var question in questions)
{
await _responsesCollection.DeleteManyAsync(r => r.QuestionId == question.QuestionId);
}
// Delete all questions in this set
await _questionsCollection.DeleteManyAsync(q => q.QuestionId.StartsWith(questionSetId + "_"));
// Delete the question set
var result = await _questionSetsCollection.DeleteOneAsync(qs => qs.QuestionSetId == questionSetId);
return result.DeletedCount > 0;
}
public async Task<QuestionSetList> GetAllQuestionSetsAsync()
{
var questionSets = await _questionSetsCollection
.Find(_ => true)
.SortBy(qs => qs.QuestionSetOrder)
.ToListAsync();
return new QuestionSetList { QuestionSets = questionSets };
}
public async Task<QuestionSet> GetQuestionSetByIdAsync(string questionSetId)
{
return await _questionSetsCollection
.Find(qs => qs.QuestionSetId == questionSetId)
.FirstOrDefaultAsync();
}
#endregion
#region Question operations
public async Task<QuestionModels> CreateQuestionAsync(string questionSetId, string questionText, string expectedResultText,
int questionOrder, int minWordLength, int maxWordLength)
{
var question = new QuestionModels
{
QuestionId = $"{questionSetId}_{TokenGenerator.GenerateToken(8)}",
QuestionText = questionText,
ExpectedResultText = expectedResultText,
QuestionOrder = questionOrder,
MinWordLength = minWordLength,
MaxWordLength = maxWordLength
};
await _questionsCollection.InsertOneAsync(question);
return question;
}
public async Task<QuestionModels> UpdateQuestionAsync(string questionId, string questionText, string expectedResultText,
int questionOrder, int minWordLength, int maxWordLength)
{
var update = Builders<QuestionModels>.Update
.Set(q => q.QuestionText, questionText)
.Set(q => q.ExpectedResultText, expectedResultText)
.Set(q => q.QuestionOrder, questionOrder)
.Set(q => q.MinWordLength, minWordLength)
.Set(q => q.MaxWordLength, maxWordLength);
await _questionsCollection.UpdateOneAsync(q => q.QuestionId == questionId, update);
return await GetQuestionByIdAsync(questionId);
}
public async Task<bool> DeleteQuestionAsync(string questionId)
{
await _responsesCollection.DeleteManyAsync(r => r.QuestionId == questionId);
var result = await _questionsCollection.DeleteOneAsync(q => q.QuestionId == questionId);
return result.DeletedCount > 0;
}
public async Task<QuestionModelList> GetQuestionsInSetAsync(string questionSetId)
{
var questions = await _questionsCollection
.Find(q => q.QuestionId.StartsWith(questionSetId + "_"))
.SortBy(q => q.QuestionOrder)
.ToListAsync();
return new QuestionModelList { Questions = questions };
}
public async Task<QuestionModels> GetQuestionByIdAsync(string questionId)
{
return await _questionsCollection
.Find(q => q.QuestionId == questionId)
.FirstOrDefaultAsync();
}
#endregion
#region Response operations
public async Task<bool> SubmitResponseAsync(string questionId, string userName, string responseText)
{
var response = new ResponseModels
{
ResponseId = TokenGenerator.GenerateToken(16),
UserName = userName,
QuestionId = questionId,
ResponseText = responseText,
ResponseTime = DateTime.UtcNow
};
await _responsesCollection.InsertOneAsync(response);
// Discord webhook
var question = await GetQuestionByIdAsync(questionId);
string questionSetId = questionId.Split('_')[0];
var questionSet = await GetQuestionSetByIdAsync(questionSetId);
await _discordNotificationService.SendResponseNotification(
userName,
questionSet?.QuestionSetName ?? "Unknown Set",
question?.QuestionText ?? "Unknown Question",
responseText);
return true;
}
public async Task<ResponseList> GetResponsesForQuestionAsync(string questionId)
{
var responses = await _responsesCollection
.Find(r => r.QuestionId == questionId)
.SortByDescending(r => r.ResponseTime)
.ToListAsync();
return new ResponseList { Response = responses };
}
public async Task<ResponseList> GetResponsesForUserAsync(string userName, string questionId)
{
var responses = await _responsesCollection
.Find(r => r.QuestionId == questionId && r.UserName == userName)
.SortByDescending(r => r.ResponseTime)
.ToListAsync();
return new ResponseList { Response = responses };
}
#endregion
}

View file

@ -0,0 +1,138 @@
using MongoDB.Driver;
using WritingServer.Models;
using WritingServer.Utils;
namespace WritingServer.Services;
public interface IUserManagementService
{
Task<UserModel> AddUserAsync(string userName);
Task<bool> DeleteUserAsync(string userName);
Task<bool> UpdateUserNameAsync(string currentUserName, string newUserName);
Task<bool> ResetUserAsync(string userName);
Task<UserModelList> GetAllUsersAsync();
Task<UserModel?> GetUserByPinAsync(string pin);
Task<UserModel?> GetUserByTokenAsync(string accessToken);
Task<UserModel?> GetUserByNameAsync(string userName);
string GenerateAccessToken(string username);
void ResetLoginState(string userName);
}
public class UserManagementService : IUserManagementService
{
private readonly IMongoDatabase _database;
private readonly IMongoCollection<UserModel> _usersCollection;
private readonly IMongoCollection<ResponseModels> _responsesCollection;
private readonly IUserTokenService _tokenService;
public UserManagementService(IMongoDatabase database, IUserTokenService tokenService)
{
_database = database;
_usersCollection = database.GetCollection<UserModel>("Users");
_responsesCollection = database.GetCollection<ResponseModels>("Responses");
_tokenService = tokenService;
}
public async Task<UserModel> AddUserAsync(string userName)
{
var existingUser = await _usersCollection.Find(u => u.Username == userName).FirstOrDefaultAsync();
if (existingUser != null)
{
return existingUser;
}
var pin = TokenGenerator.IdGenerator(4);
var user = new UserModel
{
Username = userName,
Pin = pin,
ResetState = false
};
await _usersCollection.InsertOneAsync(user);
return user;
}
public async Task<bool> DeleteUserAsync(string userName)
{
var deleteResponseResult = await _responsesCollection.DeleteManyAsync(r => r.UserName == userName);
var deleteUserResult = await _usersCollection.DeleteOneAsync(u => u.Username == userName);
_tokenService.RemoveTokensForUser(userName);
return deleteUserResult.DeletedCount > 0;
}
public async Task<bool> UpdateUserNameAsync(string currentUserName, string newUserName)
{
var existingUser = await _usersCollection.Find(u => u.Username == newUserName).FirstOrDefaultAsync();
if (existingUser != null)
{
return false;
}
// Update username
var updateResult = await _usersCollection.UpdateOneAsync(
u => u.Username == currentUserName,
Builders<UserModel>.Update.Set(u => u.Username, newUserName));
// Update references in responses
await _responsesCollection.UpdateManyAsync(
r => r.UserName == currentUserName,
Builders<ResponseModels>.Update.Set(r => r.UserName, newUserName));
_tokenService.UpdateUserInTokens(currentUserName, newUserName);
return updateResult.ModifiedCount > 0;
}
public async Task<bool> ResetUserAsync(string userName)
{
await _responsesCollection.DeleteManyAsync(r => r.UserName == userName);
// Set reset flag
var updateResult = await _usersCollection.UpdateOneAsync(
u => u.Username == userName,
Builders<UserModel>.Update.Set(u => u.ResetState, true));
return updateResult.ModifiedCount > 0;
}
public async Task<UserModelList> GetAllUsersAsync()
{
var users = await _usersCollection.Find(_ => true).ToListAsync();
return new UserModelList { Users = users };
}
public async Task<UserModel?> GetUserByPinAsync(string pin)
{
return await _usersCollection.Find(u => u.Pin == pin).FirstOrDefaultAsync();
}
public async Task<UserModel?> GetUserByNameAsync(string userName)
{
return await _usersCollection.Find(u => u.Username == userName).FirstOrDefaultAsync();
}
public async Task<UserModel?> GetUserByTokenAsync(string accessToken)
{
var username = _tokenService.GetUsernameFromToken(accessToken);
return username != null ? await GetUserByNameAsync(username) : null;
}
// Helper methods for token management
public string GenerateAccessToken(string userName)
{
var token = TokenGenerator.GenerateToken(32);
_tokenService.StoreToken(token, userName);
return token;
}
public void ResetLoginState(string userName)
{
_usersCollection.UpdateOne(
u => u.Username == userName,
Builders<UserModel>.Update.Set(u => u.ResetState, false));
}
}

View file

@ -0,0 +1,60 @@
namespace WritingServer.Services;
public interface IUserTokenService
{
void StoreToken(string token, string username);
string? GetUsernameFromToken(string token);
bool ValidateToken(string token);
void ClearToken(string token);
void RemoveTokensForUser(string username);
void UpdateUserInTokens(string oldUsername, string newUsername);
}
public class UserTokenService : IUserTokenService
{
private readonly Dictionary<string, string> _userTokens = new(); // token -> username
public void StoreToken(string token, string username)
{
_userTokens[token] = username;
}
public string? GetUsernameFromToken(string token)
{
return _userTokens.TryGetValue(token, out var username) ? username : null;
}
public bool ValidateToken(string token)
{
return _userTokens.ContainsKey(token);
}
public void ClearToken(string token)
{
_userTokens.Remove(token);
}
public void RemoveTokensForUser(string username)
{
var tokensToRemove = _userTokens
.Where(kvp => kvp.Value == username)
.Select(kvp => kvp.Key)
.ToList();
foreach (var token in tokensToRemove)
{
_userTokens.Remove(token);
}
}
public void UpdateUserInTokens(string oldUsername, string newUsername)
{
foreach (var key in _userTokens.Keys.ToList())
{
if (_userTokens[key] == oldUsername)
{
_userTokens[key] = newUsername;
}
}
}
}

View file

@ -0,0 +1,59 @@
using WritingServer.Services;
namespace WritingServer.Utils;
public class GlobalExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly IDiscordNotificationService _discordService;
private readonly ILogger<GlobalExceptionHandlerMiddleware> _logger;
private readonly IWebHostEnvironment _environment;
public GlobalExceptionHandlerMiddleware(
RequestDelegate next,
IDiscordNotificationService discordService,
ILogger<GlobalExceptionHandlerMiddleware> logger,
IWebHostEnvironment environment)
{
_next = next;
_discordService = discordService;
_logger = logger;
_environment = environment;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred while processing request to {Path}", context.Request.Path);
await _discordService.SendExceptionNotification(context.Request.Path);
await HandleExceptionAsync(context);
}
}
private static async Task HandleExceptionAsync(HttpContext context)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
var response = new
{
Success = false,
ErrorMessage = "An unexpected error occurred. Please try again later."
};
await context.Response.WriteAsJsonAsync(response);
}
}
public static class GlobalExceptionHandlerMiddlewareExtensions
{
public static IApplicationBuilder UseGlobalExceptionHandler(this IApplicationBuilder builder)
{
return builder.UseMiddleware<GlobalExceptionHandlerMiddleware>();
}
}

View file

@ -0,0 +1,32 @@
using System.Security.Cryptography;
namespace WritingServer.Utils;
public static class TokenGenerator
{
public static string IdGenerator(int length)
{
Random random = new Random();
string numbers = "0123456789ABCDEF";
string chars = string.Empty;
for (int i = 0; i < length; i++)
{
chars += numbers[random.Next(numbers.Length)];
}
return chars;
}
public static string GenerateToken(int length)
{
byte[] randomBytes = new byte[length / 2];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(randomBytes);
}
string hexString = Convert.ToHexString(randomBytes);
return hexString.Length > length ? hexString.Substring(0, length) : hexString;
}
}

594
Server/Server/apiDoc.md Normal file
View file

@ -0,0 +1,594 @@
# WritingServer API Documentation
This document outlines all available API endpoints in the WritingServer application.
## Table of Contents
- [Authentication](#authentication)
- [Admin API](#admin-api)
- [User Management](#admin-user-management)
- [Question Set Management](#admin-question-set-management)
- [Question Management](#admin-question-management)
- [Response Management](#admin-response-management)
- [User API](#user-api)
- [Profile Management](#user-profile-management)
- [Questions](#user-questions)
- [Responses](#user-responses)
## Authentication
### Admin Login
Authenticates an admin user and returns an access token.
**Endpoint:** `POST /api/admin/login`
**Request Body:**
```json
{
"loginId": "string"
}
```
**Response:**
```json
{
"success": true,
"accessToken": "string",
"errorMessage": null
}
```
### User Login
Authenticates a user with their PIN and returns an access token.
**Endpoint:** `POST /api/user/login`
**Request Body:**
```json
{
"pin": "string"
}
```
**Response:**
```json
{
"success": true,
"accessToken": "string",
"errorMessage": null
}
```
## Admin API
All admin endpoints require authentication using the `Authorization: Bearer {token}` header.
### Admin User Management
#### List Users
Returns a list of all users.
**Endpoint:** `GET /api/admin/users`
**Response:**
```json
{
"success": true,
"users": {
"users": [
{
"username": "string",
"pin": "string",
"resetState": false
}
]
},
"errorMessage": null
}
```
#### Add User
Creates a new user with the specified username.
**Endpoint:** `POST /api/admin/users`
**Request Body:**
```json
{
"userName": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
#### Edit User
Updates a user's username.
**Endpoint:** `PUT /api/admin/users`
**Request Body:**
```json
{
"userName": "string",
"newUserName": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
#### Reset User
Resets a user's responses and sets their reset state.
**Endpoint:** `PUT /api/admin/users/reset`
**Request Body:**
```json
{
"userName": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
#### Delete User
Deletes a user and all associated responses.
**Endpoint:** `DELETE /api/admin/users`
**Request Body:**
```json
{
"userName": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
### Admin Question Set Management
#### List Question Sets
Returns all question sets.
**Endpoint:** `GET /api/admin/questionsets`
**Response:**
```json
{
"success": true,
"questionSets": {
"questionSets": [
{
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": false
}
]
},
"errorMessage": null
}
```
#### Create Question Set
Creates a new question set.
**Endpoint:** `POST /api/admin/questionsets`
**Request Body:**
```json
{
"questionSetName": "string"
}
```
**Response:**
```json
{
"success": true,
"questionSet": {
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": false
},
"errorMessage": null
}
```
#### Update Question Set Name
Updates a question set's name.
**Endpoint:** `PUT /api/admin/questionsets/name`
**Request Body:**
```json
{
"questionSetId": "string",
"newQuestionSetName": "string"
}
```
**Response:**
```json
{
"success": true,
"questionSet": {
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": false
},
"errorMessage": null
}
```
#### Update Question Set Order
Updates a question set's display order.
**Endpoint:** `PUT /api/admin/questionsets/order`
**Request Body:**
```json
{
"questionSetId": "string",
"questionSetOrder": 0
}
```
**Response:**
```json
{
"success": true,
"questionSet": {
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": false
},
"errorMessage": null
}
```
#### Lock/Unlock Question Set
Toggles a question set's locked status.
**Endpoint:** `PUT /api/admin/questionsets/lock`
**Request Body:**
```json
{
"questionSetId": "string",
"locked": true
}
```
**Response:**
```json
{
"success": true,
"questionSet": {
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": true
},
"errorMessage": null
}
```
#### Delete Question Set
Deletes a question set and all associated questions.
**Endpoint:** `DELETE /api/admin/questionsets`
**Request Body:**
```json
{
"questionSetId": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
### Admin Question Management
#### List Questions in a Set
Returns all questions in a specified question set.
**Endpoint:** `GET /api/admin/questions?questionSetId={questionSetId}`
**Response:**
```json
{
"success": true,
"questions": {
"questions": [
{
"questionId": "string",
"questionSetId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
}
]
},
"errorMessage": null
}
```
#### Create Question
Creates a new question in a question set.
**Endpoint:** `POST /api/admin/questions`
**Request Body:**
```json
{
"questionSetId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
}
```
**Response:**
```json
{
"success": true,
"questionModels": {
"questionId": "string",
"questionSetId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
},
"errorMessage": null
}
```
#### Update Question
Updates an existing question.
**Endpoint:** `PUT /api/admin/questions`
**Request Body:**
```json
{
"questionId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
}
```
**Response:**
```json
{
"success": true,
"questionModels": {
"questionId": "string",
"questionSetId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
},
"errorMessage": null
}
```
#### Delete Question
Deletes a question and associated responses.
**Endpoint:** `DELETE /api/admin/questions`
**Request Body:**
```json
{
"questionId": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
### Admin Response Management
#### Get Responses for a Question
Returns all user responses for a specific question.
**Endpoint:** `GET /api/admin/responses?questionId={questionId}`
**Response:**
```json
{
"success": true,
"responses": {
"responses": [
{
"responseId": "string",
"questionId": "string",
"userName": "string",
"responseText": "string",
"timestamp": "string"
}
]
},
"errorMessage": null
}
```
## User API
All user endpoints require authentication using the `Authorization: Bearer {token}` header.
### User Profile Management
#### Get Username
Returns the current user's username.
**Endpoint:** `GET /api/user/username`
**Response:**
```json
{
"success": true,
"username": "string",
"errorMessage": null
}
```
#### Get Reset State
Returns whether the user's account has been reset.
**Endpoint:** `GET /api/user/state`
**Response:**
```json
{
"success": true,
"resetState": false,
"errorMessage": null
}
```
### User Questions
#### Get Question Sets
Returns all unlocked question sets.
**Endpoint:** `GET /api/user/questionsets`
**Response:**
```json
{
"success": true,
"questionSets": {
"questionSets": [
{
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": false
}
]
},
"errorMessage": null
}
```
#### Get Questions from Set
Returns all questions in a specified question set.
**Endpoint:** `GET /api/user/questions?questionSetId={questionSetId}`
**Response:**
```json
{
"success": true,
"questions": {
"questions": [
{
"questionId": "string",
"questionSetId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
}
]
},
"errorMessage": null
}
```
### User Responses
#### Submit Response
Submits a user's response to a question.
**Endpoint:** `POST /api/user/responses`
**Request Body:**
```json
{
"questionId": "string",
"responseText": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
#### Get User Responses
Returns all of the current user's responses to a specific question.
**Endpoint:** `GET /api/user/responses?questionId={questionId}`
**Response:**
```json
{
"success": true,
"responses": {
"responses": [
{
"responseId": "string",
"questionId": "string",
"userName": "string",
"responseText": "string",
"timestamp": "string"
}
]
},
"errorMessage": null
}
```

View file

@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"DefaultAdminUserName": "admin",
"DefaultAdminPassword": "password",
"IdLength": 8,
"TokenLength": 1024
}

View file

@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"DefaultAdminUserName": "admin",
"DefaultAdminPassword": "password",
"IdLength": 8,
"TokenLength": 1024
}

View file

@ -0,0 +1,8 @@
{
"dev": {
"loginAdmin": "admin123",
"loginUser": "value",
"createQuestionSet": "value",
"createQuestion": "value"
}
}

View file

@ -0,0 +1,12 @@
services:
quizconnectserver:
build:
context: .
dockerfile: Dockerfile
environment:
- APP_UID=1000
ports:
- "9500:9500"
volumes:
- ./Server/.env:/app/.env
restart: unless-stopped

View file

@ -0,0 +1,11 @@
services:
quizconnectserver:
image: ghcr.io/marcus7i/quizconnect:latest
container_name: quizconnectserver
environment:
- APP_UID=1000
ports:
- "9500:9500"
volumes:
- ./Server/.env:/app/.env
restart: unless-stopped