diff --git a/api/src/Controllers/AuthController.cs b/api/src/Controllers/AuthController.cs new file mode 100644 index 0000000..66b2a08 --- /dev/null +++ b/api/src/Controllers/AuthController.cs @@ -0,0 +1,314 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WinStudentGoalTracker.BaseClasses; +using WinStudentGoalTracker.DataAccess; +using WinStudentGoalTracker.Models; +using WinStudentGoalTracker.Services; + +namespace WinStudentGoalTracker.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : BaseController +{ + private readonly UserRepository _userRepo = new(); + private readonly AuthRepository _authRepo = new(); + private readonly TokenService _tokenService; + + public AuthController(TokenService tokenService) + { + _tokenService = tokenService; + } + + [HttpPost("Login")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] + public async Task>> Login([FromBody] LoginDto login) + { + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); + + if (string.IsNullOrWhiteSpace(login.Email) || string.IsNullOrWhiteSpace(login.Password)) + { + return BadRequest(new ResponseResult + { + Success = false, + Message = "Email and password are required." + }); + } + + var user = await _userRepo.GetByEmailAsync(login.Email); + if (user == null) + { + return Ok(new ResponseResult + { + Success = false, + Message = "Invalid email or password." + }); + } + + if (user.LockedUntil.HasValue && user.LockedUntil.Value > DateTime.UtcNow) + { + return Ok(new ResponseResult + { + Success = false, + Message = "Account is temporarily locked. Please try again later." + }); + } + + if (user.PasswordHash == null || user.PasswordSalt == null) + { + return Ok(new ResponseResult + { + Success = false, + Message = "Password not set. Please contact an administrator." + }); + } + + if (!PasswordHasher.VerifyPassword(login.Password, user.PasswordHash, user.PasswordSalt)) + { + return Ok(new ResponseResult + { + Success = false, + Message = "Invalid email or password." + }); + } + + // Generate JWT access token + var accessToken = _tokenService.GenerateToken(user.IdUser, user.Email!, user.RoleInternalName); + + // Generate refresh token secret + var secretToken = Guid.NewGuid().ToString(); + var (refreshTokenHash, refreshTokenSalt) = PasswordHasher.HashPassword(secretToken); + + // Get device info + var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); + var deviceInfo = JsonSerializer.Serialize(new { ip_address = ipAddress }); + + // Store refresh token in database (30 days expiration) + var refreshTokenId = await _authRepo.CreateRefreshTokenAsync( + Guid.NewGuid(), + user.IdUser, + refreshTokenHash, + refreshTokenSalt, + expiresInSeconds: 2592000, // 30 days + deviceInfo: deviceInfo, + userAgent: userAgent + ); + + if (!refreshTokenId.HasValue) + { + return Ok(new ResponseResult + { + Success = false, + Message = "Failed to create refresh token." + }); + } + + // Build full refresh token: {id}.{secret} + var fullRefreshToken = $"{refreshTokenId.Value}.{secretToken}"; + + return Ok(new ResponseResult + { + Success = true, + Message = "Login successful.", + Data = new LoginResponse + { + UserId = user.IdUser, + Email = user.Email!, + Jwt = accessToken, + RefreshToken = fullRefreshToken, + Role = user.RoleInternalName, + RoleDisplayName = user.RoleDisplayName + } + }); + } + + [HttpPost("RefreshToken")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status401Unauthorized)] + public async Task>> RefreshToken([FromBody] RefreshTokenDto refreshTokenDto) + { + if (string.IsNullOrWhiteSpace(refreshTokenDto.RefreshToken)) + { + return BadRequest(new ResponseResult + { + Success = false, + Message = "Refresh token is required." + }); + } + + // Split token into ID and secret: {id}.{secret} + var dotIndex = refreshTokenDto.RefreshToken.IndexOf('.'); + if (dotIndex < 1) + { + return BadRequest(new ResponseResult + { + Success = false, + Message = "Invalid refresh token format." + }); + } + + var tokenIdStr = refreshTokenDto.RefreshToken[..dotIndex]; + var secretToken = refreshTokenDto.RefreshToken[(dotIndex + 1)..]; + + if (!Guid.TryParse(tokenIdStr, out Guid tokenId)) + { + return BadRequest(new ResponseResult + { + Success = false, + Message = "Invalid refresh token ID." + }); + } + + var matchedToken = await _authRepo.GetRefreshTokenByIdAsync(tokenId); + if (matchedToken == null) + { + return Unauthorized(new ResponseResult + { + Success = false, + Message = "Invalid refresh token." + }); + } + + if (!PasswordHasher.VerifyPassword(secretToken, matchedToken.TokenHash, matchedToken.TokenSalt)) + { + return Unauthorized(new ResponseResult + { + Success = false, + Message = "Invalid refresh token." + }); + } + + if (matchedToken.ExpiresAt < DateTime.UtcNow) + { + return Unauthorized(new ResponseResult + { + Success = false, + Message = "Refresh token has expired." + }); + } + + if (matchedToken.RevokedAt.HasValue) + { + return Unauthorized(new ResponseResult + { + Success = false, + Message = "Refresh token has been revoked." + }); + } + + var tokenUser = await _userRepo.GetByIdAsync(matchedToken.IdUser); + if (tokenUser == null) + { + return Unauthorized(new ResponseResult + { + Success = false, + Message = "User not found." + }); + } + + // Generate new JWT + var newJwtToken = _tokenService.GenerateToken(tokenUser.IdUser, tokenUser.Email!, tokenUser.RoleInternalName); + var jwtExpiresIn = _tokenService.GetTokenExpiryInSeconds(newJwtToken); + + // Generate new refresh token (rotation) + var newSecretToken = Guid.NewGuid().ToString(); + var (newRefreshTokenHash, newRefreshTokenSalt) = PasswordHasher.HashPassword(newSecretToken); + + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); + var deviceInfo = JsonSerializer.Serialize(new { ip_address = ipAddress }); + + var newRefreshTokenId = await _authRepo.ReplaceRefreshTokenAsync( + matchedToken.IdRefreshToken, + Guid.NewGuid(), + tokenUser.IdUser, + newRefreshTokenHash, + newRefreshTokenSalt, + expiresInSeconds: 2592000, + deviceInfo: deviceInfo, + userAgent: userAgent + ); + + if (!newRefreshTokenId.HasValue) + { + return Ok(new ResponseResult + { + Success = false, + Message = "Failed to create new refresh token." + }); + } + + var fullNewRefreshToken = $"{newRefreshTokenId.Value}.{newSecretToken}"; + + return Ok(new ResponseResult + { + Success = true, + Message = "Token refreshed successfully.", + Data = new TokenRefreshResponse + { + Jwt = newJwtToken, + NewRefreshToken = fullNewRefreshToken, + JwtExpiresIn = jwtExpiresIn + } + }); + } + + [HttpPost("Logout")] + [Authorize] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] + public async Task>> Logout([FromBody] RefreshTokenDto logoutDto) + { + if (string.IsNullOrWhiteSpace(logoutDto.RefreshToken)) + { + return BadRequest(new ResponseResult + { + Success = false, + Message = "Refresh token is required." + }); + } + + var (userId, error) = GetUserIdFromClaims(); + if (error != null) return error; + + var dotIndex = logoutDto.RefreshToken.IndexOf('.'); + if (dotIndex < 1 || !Guid.TryParse(logoutDto.RefreshToken[..dotIndex], out Guid tokenId)) + { + return BadRequest(new ResponseResult + { + Success = false, + Message = "Invalid refresh token format." + }); + } + + var tokenData = await _authRepo.GetRefreshTokenByIdAsync(tokenId); + if (tokenData == null || tokenData.IdUser != userId) + { + return Unauthorized(new ResponseResult + { + Success = false, + Message = "Invalid refresh token." + }); + } + + if (tokenData.RevokedAt.HasValue) + { + return Ok(new ResponseResult + { + Success = true, + Message = "Already logged out." + }); + } + + await _authRepo.RevokeRefreshTokenAsync(tokenId); + + return Ok(new ResponseResult + { + Success = true, + Message = "Logged out successfully." + }); + } +} diff --git a/api/src/DataAccess/Models/DataTransferObjects/LoginDto.cs b/api/src/DataAccess/Models/DataTransferObjects/LoginDto.cs new file mode 100644 index 0000000..1cc1368 --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/LoginDto.cs @@ -0,0 +1,7 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class LoginDto +{ + public string? Email { get; set; } + public string? Password { get; set; } +} diff --git a/api/src/DataAccess/Models/DataTransferObjects/RefreshTokenDto.cs b/api/src/DataAccess/Models/DataTransferObjects/RefreshTokenDto.cs new file mode 100644 index 0000000..2066e67 --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/RefreshTokenDto.cs @@ -0,0 +1,6 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class RefreshTokenDto +{ + public string? RefreshToken { get; set; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbRefreshToken.cs b/api/src/DataAccess/Models/DatabaseObjects/dbRefreshToken.cs new file mode 100644 index 0000000..c54c65e --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbRefreshToken.cs @@ -0,0 +1,17 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class dbRefreshToken +{ + public Guid IdRefreshToken { get; set; } + public Guid IdUser { get; set; } + public required string TokenHash { get; set; } + public required string TokenSalt { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime LastUsedAt { get; set; } + public DateTime? RevokedAt { get; set; } + public string? DeviceInfo { get; set; } + public string? UserAgent { get; set; } + public Guid? ReplacedByTokenId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs b/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs new file mode 100644 index 0000000..aae070c --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs @@ -0,0 +1,16 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class dbUser +{ + public required Guid IdUser { get; set; } + public Guid? IdRole { get; set; } + public string? Email { get; set; } + public string? Name { get; set; } + public string? PasswordHash { get; set; } + public string? PasswordSalt { get; set; } + public int FailedLoginAttempts { get; set; } + public DateTime? LockedUntil { get; set; } + public DateTime? CreatedAt { get; set; } + public string? RoleInternalName { get; set; } + public string? RoleDisplayName { get; set; } +} diff --git a/api/src/DataAccess/Repositories/AuthRepository.cs b/api/src/DataAccess/Repositories/AuthRepository.cs new file mode 100644 index 0000000..cad3d48 --- /dev/null +++ b/api/src/DataAccess/Repositories/AuthRepository.cs @@ -0,0 +1,83 @@ +using System.Data; +using Dapper; +using MySql.Data.MySqlClient; + +namespace WinStudentGoalTracker.DataAccess; + +public class AuthRepository +{ + private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString); + + public async Task CreateRefreshTokenAsync( + Guid refreshTokenId, + Guid userId, + string tokenHash, + string tokenSalt, + int expiresInSeconds, + string? deviceInfo, + string? userAgent) + { + using var db = Connection; + var result = await db.QuerySingleOrDefaultAsync( + "sp_RefreshToken_Create", + new + { + p_id_refresh_token = refreshTokenId.ToString(), + p_id_user = userId.ToString(), + p_token_hash = tokenHash, + p_token_salt = tokenSalt, + p_expires_in_seconds = expiresInSeconds, + p_device_info = deviceInfo, + p_user_agent = userAgent + }, + commandType: CommandType.StoredProcedure); + return result != null ? Guid.Parse(result) : null; + } + + public async Task GetRefreshTokenByIdAsync(Guid refreshTokenId) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_RefreshToken_GetById", + new { p_id_refresh_token = refreshTokenId.ToString() }, + commandType: CommandType.StoredProcedure); + } + + public async Task RevokeRefreshTokenAsync(Guid refreshTokenId) + { + using var db = Connection; + var rowsAffected = await db.QuerySingleOrDefaultAsync( + "sp_RefreshToken_Revoke", + new { p_id_refresh_token = refreshTokenId.ToString() }, + commandType: CommandType.StoredProcedure); + return rowsAffected > 0; + } + + public async Task ReplaceRefreshTokenAsync( + Guid oldTokenId, + Guid newTokenId, + Guid userId, + string tokenHash, + string tokenSalt, + int expiresInSeconds, + string? deviceInfo, + string? userAgent) + { + using var db = Connection; + var result = await db.QuerySingleOrDefaultAsync( + "sp_RefreshToken_Replace", + new + { + p_old_token_id = oldTokenId.ToString(), + p_id_refresh_token = newTokenId.ToString(), + p_id_user = userId.ToString(), + p_token_hash = tokenHash, + p_token_salt = tokenSalt, + p_expires_in_seconds = expiresInSeconds, + p_device_info = deviceInfo, + p_user_agent = userAgent + }, + commandType: CommandType.StoredProcedure); + return result != null ? Guid.Parse(result) : null; + } +} diff --git a/api/src/DataAccess/Repositories/UserRepository.cs b/api/src/DataAccess/Repositories/UserRepository.cs new file mode 100644 index 0000000..ff679a7 --- /dev/null +++ b/api/src/DataAccess/Repositories/UserRepository.cs @@ -0,0 +1,28 @@ +using System.Data; +using Dapper; +using MySql.Data.MySqlClient; + +namespace WinStudentGoalTracker.DataAccess; + +public class UserRepository +{ + private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString); + + public async Task GetByEmailAsync(string email) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_User_GetByEmail", + new { p_email = email }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetByIdAsync(Guid idUser) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_User_GetById", + new { p_id_user = idUser.ToString() }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/api/src/Models/ResponseTypes/GoalBreakdownResponse.cs b/api/src/Models/ResponseTypes/GoalBreakdownResponse.cs new file mode 100644 index 0000000..cdf62ba --- /dev/null +++ b/api/src/Models/ResponseTypes/GoalBreakdownResponse.cs @@ -0,0 +1,7 @@ +namespace WinStudentGoalTracker.Models.ResponseTypes; + +public class GoalBreakdownResponse +{ + public string Goal { get; set; } = string.Empty; + public List Subgoals { get; set; } = []; +} diff --git a/api/src/Models/ResponseTypes/LoginResponse.cs b/api/src/Models/ResponseTypes/LoginResponse.cs new file mode 100644 index 0000000..7480e25 --- /dev/null +++ b/api/src/Models/ResponseTypes/LoginResponse.cs @@ -0,0 +1,11 @@ +namespace WinStudentGoalTracker.Models; + +public class LoginResponse +{ + public Guid UserId { get; set; } + public required string Email { get; set; } + public required string Jwt { get; set; } + public required string RefreshToken { get; set; } + public string? Role { get; set; } + public string? RoleDisplayName { get; set; } +} diff --git a/api/src/Models/ResponseTypes/TokenRefreshResponse.cs b/api/src/Models/ResponseTypes/TokenRefreshResponse.cs new file mode 100644 index 0000000..024e327 --- /dev/null +++ b/api/src/Models/ResponseTypes/TokenRefreshResponse.cs @@ -0,0 +1,8 @@ +namespace WinStudentGoalTracker.Models; + +public class TokenRefreshResponse +{ + public required string Jwt { get; set; } + public required string NewRefreshToken { get; set; } + public int JwtExpiresIn { get; set; } +} diff --git a/api/src/Services/OllamaService.cs b/api/src/Services/OllamaService.cs new file mode 100644 index 0000000..078feca --- /dev/null +++ b/api/src/Services/OllamaService.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using WinStudentGoalTracker.Models.ResponseTypes; + +namespace WinStudentGoalTracker.Services; + +public class OllamaService +{ + private readonly HttpClient _httpClient; + private readonly string _model; + private readonly int _maxRetries = 3; + + public OllamaService(HttpClient httpClient, IConfiguration config) + { + _httpClient = httpClient; + _model = config["Ollama:Model"] ?? "gpt-oss:20b"; + } + + public async Task BreakdownGoalAsync(string goal, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(goal)) + throw new ArgumentException("Goal cannot be empty.", nameof(goal)); + + var prompt = "You are a task planning assistant. Given a goal, break it down into smaller, actionable subgoals.\n\n" + + $"Goal: {goal}\n\n" + + "Respond with ONLY a JSON object in this exact format, no other text:\n" + + "{\"subgoals\": [\"subgoal 1\", \"subgoal 2\", \"subgoal 3\"]}\n\n" + + "Be specific and practical. Generate 3-7 subgoals depending on complexity."; + + var requestBody = new OllamaChatRequest + { + Model = _model, + Messages = [new OllamaChatMessage { Role = "user", Content = prompt }], + Format = "json", + Stream = false + }; + + for (var attempt = 0; attempt < _maxRetries; attempt++) + { + var response = await _httpClient.PostAsJsonAsync("/api/chat", requestBody, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"Ollama service returned {(int)response.StatusCode}: {errorBody}", + null, + response.StatusCode); + } + + try + { + var chatResponse = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new JsonException("Ollama returned an empty response."); + + var parsed = JsonSerializer.Deserialize(chatResponse.Message.Content) + ?? throw new JsonException("LLM response deserialized to null."); + + var subgoals = NormalizeSubgoals(parsed.Subgoals); + + if (subgoals.Count == 0) + throw new JsonException("LLM response contained no valid subgoals."); + + return new GoalBreakdownResponse + { + Goal = goal, + Subgoals = subgoals + }; + } + catch (JsonException) when (attempt < _maxRetries - 1) + { + // Malformed response from the model — retry + } + } + + throw new InvalidOperationException( + $"Failed to get a valid response from the LLM after {_maxRetries} attempts."); + } + + private static List NormalizeSubgoals(List rawSubgoals) + { + var normalized = new List(); + + foreach (var item in rawSubgoals) + { + switch (item.ValueKind) + { + case JsonValueKind.String: + normalized.Add(item.GetString()!); + break; + + case JsonValueKind.Object: + string[] preferredKeys = ["description", "task", "subtask", "subgoal", "name", "action"]; + var added = false; + + foreach (var key in preferredKeys) + { + if (item.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) + { + normalized.Add(value.GetString()!); + added = true; + break; + } + } + + if (!added) + { + foreach (var prop in item.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.String) + { + normalized.Add(prop.Value.GetString()!); + break; + } + } + } + break; + } + } + + return normalized; + } + + #region Ollama API DTOs + + private class OllamaChatRequest + { + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; + + [JsonPropertyName("format")] + public string Format { get; set; } = string.Empty; + + [JsonPropertyName("stream")] + public bool Stream { get; set; } + } + + private class OllamaChatMessage + { + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + } + + private class OllamaChatResponse + { + [JsonPropertyName("message")] + public OllamaChatMessage Message { get; set; } = new(); + } + + private class OllamaSubgoalsResult + { + [JsonPropertyName("subgoals")] + public List Subgoals { get; set; } = []; + } + + #endregion +} diff --git a/api/src/Services/PasswordHasher.cs b/api/src/Services/PasswordHasher.cs new file mode 100644 index 0000000..70d47aa --- /dev/null +++ b/api/src/Services/PasswordHasher.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; + +namespace WinStudentGoalTracker.Services; + +public class PasswordHasher +{ + private const int SaltSize = 16; // 128 bit + private const int HashSize = 32; // 256 bit + private const int Iterations = 100_000; + + public static (string Hash, string Salt) HashPassword(string password) + { + using var rng = RandomNumberGenerator.Create(); + byte[] saltBytes = new byte[SaltSize]; + rng.GetBytes(saltBytes); + + using var pbkdf2 = new Rfc2898DeriveBytes(password, saltBytes, Iterations, HashAlgorithmName.SHA256); + byte[] hashBytes = pbkdf2.GetBytes(HashSize); + + return (Convert.ToBase64String(hashBytes), Convert.ToBase64String(saltBytes)); + } + + public static bool VerifyPassword(string password, string storedHash, string storedSalt) + { + byte[] saltBytes = Convert.FromBase64String(storedSalt); + + using var pbkdf2 = new Rfc2898DeriveBytes(password, saltBytes, Iterations, HashAlgorithmName.SHA256); + byte[] hashBytes = pbkdf2.GetBytes(HashSize); + + string incomingHash = Convert.ToBase64String(hashBytes); + + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(incomingHash), + Encoding.UTF8.GetBytes(storedHash)); + } +} diff --git a/api/src/Services/TokenService.cs b/api/src/Services/TokenService.cs new file mode 100644 index 0000000..9824a48 --- /dev/null +++ b/api/src/Services/TokenService.cs @@ -0,0 +1,66 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace WinStudentGoalTracker.Services; + +public class TokenService +{ + private readonly IConfiguration _config; + private readonly int _tokenExpiryInSeconds = 60 * 15; // 15 minutes + + public TokenService(IConfiguration config) + { + _config = config; + } + + public string GenerateToken(Guid userId, string email, string? roleName) + { + var claims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), + new Claim(JwtRegisteredClaimNames.Email, email), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim("user_id", userId.ToString()) + }; + + if (!string.IsNullOrWhiteSpace(roleName)) + { + claims.Add(new Claim(ClaimTypes.Role, roleName)); + } + + 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: null, + claims: claims, + expires: DateTime.UtcNow.AddSeconds(_tokenExpiryInSeconds), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public int GetTokenExpiryInSeconds(string token) + { + try + { + var tokenHandler = new JwtSecurityTokenHandler(); + var jwtToken = tokenHandler.ReadJwtToken(token); + + var expiryTime = jwtToken.ValidTo; + var currentTime = DateTime.UtcNow; + + var timeUntilExpiry = expiryTime - currentTime; + + return timeUntilExpiry.TotalSeconds > 0 ? (int)timeUntilExpiry.TotalSeconds : 0; + } + catch + { + return 0; + } + } +} diff --git a/api/src/Services/TranscriptionService.cs b/api/src/Services/TranscriptionService.cs new file mode 100644 index 0000000..26b49e2 --- /dev/null +++ b/api/src/Services/TranscriptionService.cs @@ -0,0 +1,42 @@ +using System.Net.Http.Headers; + +namespace WinStudentGoalTracker.Services; + +public class TranscriptionService +{ + private readonly HttpClient _httpClient; + + public TranscriptionService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task TranscribeAsync(Stream audioStream, string fileName, CancellationToken cancellationToken = default) + { + using var content = new MultipartFormDataContent(); + + var fileContent = new StreamContent(audioStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + content.Add(fileContent, "file", fileName); + content.Add(new StringContent("whisper-1"), "model"); + content.Add(new StringContent("json"), "response_format"); + + var response = await _httpClient.PostAsync("/v1/audio/transcriptions", content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"Transcription service returned {(int)response.StatusCode}: {errorBody}", + null, + response.StatusCode); + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new InvalidOperationException("Transcription service returned an empty response."); + + return result.Text; + } + + private record TranscriptionResult(string Text); +} diff --git a/db/dump-objects.sh b/db/dump-objects.sh new file mode 100644 index 0000000..458ceb9 --- /dev/null +++ b/db/dump-objects.sh @@ -0,0 +1,194 @@ +#!/bin/bash +# dump-objects.sh + +MYSQL="mysql" +BASE_OUTPUT_DIR="$(cd "$(dirname "$0")" && pwd)/Objects" +DATABASE="winstudentgoaltracker" + +# Get password once +read -s -p "Enter MySQL password: " PASS +echo + +# Connection parameters +CONN_PARAMS=(-h 10.66.66.1 -P 3309 -u root -p"$PASS") + +# ============================================================================= +# CONNECTION TEST +# ============================================================================= +echo "Testing connection to MySQL..." +if ! $MYSQL "${CONN_PARAMS[@]}" -N -B --raw -e "SELECT 1" &>/dev/null; then + echo "" + echo "ERROR: Could not connect to MySQL." + echo "Check your password, host (10.66.66.1), port (3309), and that the MySQL server is reachable." + exit 1 +fi +echo "Connection OK." + +# Helper function to initialize output directory +initialize_output_dir() { + local path="$1" + if [ -d "$path" ]; then + rm -f "$path"/*.sql 2>/dev/null + else + mkdir -p "$path" + fi + echo "$path" +} + +# Helper function to run mysql and clean output +invoke_mysql_query() { + local query="$1" + $MYSQL "${CONN_PARAMS[@]}" -N -B --raw -e "$query" 2>/dev/null | tr -d '\r' | sed '/^$/d' +} + +# ============================================================================= +# TABLES (includes indexes; triggers handled separately) +# ============================================================================= +TABLE_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/tables") + +echo "" +echo "Fetching table list..." +TABLES=() +while IFS= read -r line; do + [ -n "$line" ] && TABLES+=("$line") +done < <(invoke_mysql_query "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '$DATABASE' AND TABLE_TYPE = 'BASE TABLE'") +echo "Found ${#TABLES[@]} tables" + +for table in "${TABLES[@]}"; do + table=$(echo "$table" | xargs) + [ -z "$table" ] && continue + echo " Dumping: $table" + + create_stmt=$(invoke_mysql_query "SHOW CREATE TABLE \`$DATABASE\`.\`$table\`" | cut -f2-) + if [ -z "$create_stmt" ]; then + echo "WARNING: Failed to dump table: $table" >&2 + continue + fi + + # Get triggers for this table + TRIGGERS=() + while IFS= read -r line; do + [ -n "$line" ] && TRIGGERS+=("$line") + done < <(invoke_mysql_query "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE EVENT_OBJECT_SCHEMA = '$DATABASE' AND EVENT_OBJECT_TABLE = '$table'") + + trigger_sql="" + for trigger in "${TRIGGERS[@]}"; do + trigger=$(echo "$trigger" | xargs) + [ -z "$trigger" ] && continue + + trigger_def=$(invoke_mysql_query "SHOW CREATE TRIGGER \`$DATABASE\`.\`$trigger\`") + if [ -n "$trigger_def" ]; then + # SHOW CREATE TRIGGER: TriggerNamesql_modeCreateStatement... + trigger_create=$(echo "$trigger_def" | cut -f3 | sed 's/\t.*$//') + trigger_sql+=$'\n\nDELIMITER ;;\n'"$trigger_create"$';;\nDELIMITER ;' + fi + done + + printf '%s;%s\n' "$create_stmt" "$trigger_sql" > "$TABLE_DIR/$table.sql" +done + +# ============================================================================= +# VIEWS +# ============================================================================= +VIEW_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/views") + +echo "" +echo "Fetching view list..." +VIEWS=() +while IFS= read -r line; do + [ -n "$line" ] && VIEWS+=("$line") +done < <(invoke_mysql_query "SELECT TABLE_NAME FROM information_schema.VIEWS WHERE TABLE_SCHEMA = '$DATABASE'") +echo "Found ${#VIEWS[@]} views" + +for view in "${VIEWS[@]}"; do + view=$(echo "$view" | xargs) + [ -z "$view" ] && continue + echo " Dumping: $view" + + create_stmt=$(invoke_mysql_query "SHOW CREATE VIEW \`$DATABASE\`.\`$view\`" | cut -f2 | sed 's/\t[^\t]*\t[^\t]*$//') + if [ -z "$create_stmt" ]; then + echo "WARNING: Failed to dump view: $view" >&2 + continue + fi + + # Basic formatting to break long lines + create_stmt=$(echo "$create_stmt" | sed \ + -e 's/ select /\nselect /g' \ + -e 's/ from /\nfrom /g' \ + -e 's/ left join /\nleft join /g' \ + -e 's/ inner join /\ninner join /g' \ + -e 's/ join /\njoin /g' \ + -e 's/ where /\nwhere /g' \ + -e 's/ and /\n and /g' \ + -e 's/ or /\n or /g') + + printf '%s;\n' "$create_stmt" > "$VIEW_DIR/$view.sql" +done + +# ============================================================================= +# FUNCTIONS +# ============================================================================= +FUNCTION_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/functions") + +echo "" +echo "Fetching function list..." +FUNCTIONS=() +while IFS= read -r line; do + [ -n "$line" ] && FUNCTIONS+=("$line") +done < <(invoke_mysql_query "SELECT ROUTINE_NAME FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '$DATABASE' AND ROUTINE_TYPE = 'FUNCTION'") +echo "Found ${#FUNCTIONS[@]} functions" + +for func in "${FUNCTIONS[@]}"; do + func=$(echo "$func" | xargs) + [ -z "$func" ] && continue + echo " Dumping: $func" + + # SHOW CREATE FUNCTION: FuncNamesql_modeCreateStatement... + create_stmt=$(invoke_mysql_query "SHOW CREATE FUNCTION \`$DATABASE\`.\`$func\`" | cut -f3 | sed 's/END\t.*/END/') + if [ -z "$create_stmt" ]; then + echo "WARNING: Failed to dump function: $func" >&2 + continue + fi + + printf 'DELIMITER ;;\n%s;;\nDELIMITER ;\n' "$create_stmt" > "$FUNCTION_DIR/$func.sql" +done + +# ============================================================================= +# PROCEDURES +# ============================================================================= +PROCEDURE_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/procedures") + +echo "" +echo "Fetching procedure list..." +PROCS=() +while IFS= read -r line; do + [ -n "$line" ] && PROCS+=("$line") +done < <(invoke_mysql_query "SELECT ROUTINE_NAME FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '$DATABASE' AND ROUTINE_TYPE = 'PROCEDURE'") +echo "Found ${#PROCS[@]} procedures" + +for proc in "${PROCS[@]}"; do + proc=$(echo "$proc" | xargs) + [ -z "$proc" ] && continue + echo " Dumping: $proc" + + # SHOW CREATE PROCEDURE: ProcNamesql_modeCreateStatement... + create_stmt=$(invoke_mysql_query "SHOW CREATE PROCEDURE \`$DATABASE\`.\`$proc\`" | cut -f3 | sed 's/END\t.*/END/') + if [ -z "$create_stmt" ]; then + echo "WARNING: Failed to dump procedure: $proc" >&2 + continue + fi + + printf 'DELIMITER ;;\n%s;;\nDELIMITER ;\n' "$create_stmt" > "$PROCEDURE_DIR/$proc.sql" +done + +# ============================================================================= +# SUMMARY +# ============================================================================= +echo "" +echo "==========================================" +echo "Done! Schema exported to: $BASE_OUTPUT_DIR" +echo " Tables: ${#TABLES[@]}" +echo " Views: ${#VIEWS[@]}" +echo " Functions: ${#FUNCTIONS[@]}" +echo " Procedures: ${#PROCS[@]}" +echo "=========================================="