mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 02:57:36 +00:00
Silent merge fail
This commit is contained in:
@@ -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<LoginResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<LoginResponse>), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<ResponseResult<LoginResponse>>> Login([FromBody] LoginDto login)
|
||||||
|
{
|
||||||
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(login.Email) || string.IsNullOrWhiteSpace(login.Password))
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<LoginResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Email and password are required."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepo.GetByEmailAsync(login.Email);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Ok(new ResponseResult<LoginResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Invalid email or password."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.LockedUntil.HasValue && user.LockedUntil.Value > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
return Ok(new ResponseResult<LoginResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Account is temporarily locked. Please try again later."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.PasswordHash == null || user.PasswordSalt == null)
|
||||||
|
{
|
||||||
|
return Ok(new ResponseResult<LoginResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Password not set. Please contact an administrator."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PasswordHasher.VerifyPassword(login.Password, user.PasswordHash, user.PasswordSalt))
|
||||||
|
{
|
||||||
|
return Ok(new ResponseResult<LoginResponse>
|
||||||
|
{
|
||||||
|
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<LoginResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Failed to create refresh token."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full refresh token: {id}.{secret}
|
||||||
|
var fullRefreshToken = $"{refreshTokenId.Value}.{secretToken}";
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<LoginResponse>
|
||||||
|
{
|
||||||
|
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<TokenRefreshResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<TokenRefreshResponse>), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<TokenRefreshResponse>), StatusCodes.Status401Unauthorized)]
|
||||||
|
public async Task<ActionResult<ResponseResult<TokenRefreshResponse>>> RefreshToken([FromBody] RefreshTokenDto refreshTokenDto)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(refreshTokenDto.RefreshToken))
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<TokenRefreshResponse>
|
||||||
|
{
|
||||||
|
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<TokenRefreshResponse>
|
||||||
|
{
|
||||||
|
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<TokenRefreshResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Invalid refresh token ID."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedToken = await _authRepo.GetRefreshTokenByIdAsync(tokenId);
|
||||||
|
if (matchedToken == null)
|
||||||
|
{
|
||||||
|
return Unauthorized(new ResponseResult<TokenRefreshResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Invalid refresh token."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PasswordHasher.VerifyPassword(secretToken, matchedToken.TokenHash, matchedToken.TokenSalt))
|
||||||
|
{
|
||||||
|
return Unauthorized(new ResponseResult<TokenRefreshResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Invalid refresh token."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedToken.ExpiresAt < DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
return Unauthorized(new ResponseResult<TokenRefreshResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Refresh token has expired."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedToken.RevokedAt.HasValue)
|
||||||
|
{
|
||||||
|
return Unauthorized(new ResponseResult<TokenRefreshResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Refresh token has been revoked."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenUser = await _userRepo.GetByIdAsync(matchedToken.IdUser);
|
||||||
|
if (tokenUser == null)
|
||||||
|
{
|
||||||
|
return Unauthorized(new ResponseResult<TokenRefreshResponse>
|
||||||
|
{
|
||||||
|
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<TokenRefreshResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Failed to create new refresh token."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullNewRefreshToken = $"{newRefreshTokenId.Value}.{newSecretToken}";
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<TokenRefreshResponse>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Token refreshed successfully.",
|
||||||
|
Data = new TokenRefreshResponse
|
||||||
|
{
|
||||||
|
Jwt = newJwtToken,
|
||||||
|
NewRefreshToken = fullNewRefreshToken,
|
||||||
|
JwtExpiresIn = jwtExpiresIn
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("Logout")]
|
||||||
|
[Authorize]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<ResponseResult<object>>> Logout([FromBody] RefreshTokenDto logoutDto)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(logoutDto.RefreshToken))
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
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<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Invalid refresh token format."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenData = await _authRepo.GetRefreshTokenByIdAsync(tokenId);
|
||||||
|
if (tokenData == null || tokenData.IdUser != userId)
|
||||||
|
{
|
||||||
|
return Unauthorized(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Invalid refresh token."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenData.RevokedAt.HasValue)
|
||||||
|
{
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Already logged out."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _authRepo.RevokeRefreshTokenAsync(tokenId);
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Logged out successfully."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class LoginDto
|
||||||
|
{
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class RefreshTokenDto
|
||||||
|
{
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<Guid?> CreateRefreshTokenAsync(
|
||||||
|
Guid refreshTokenId,
|
||||||
|
Guid userId,
|
||||||
|
string tokenHash,
|
||||||
|
string tokenSalt,
|
||||||
|
int expiresInSeconds,
|
||||||
|
string? deviceInfo,
|
||||||
|
string? userAgent)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
var result = await db.QuerySingleOrDefaultAsync<string?>(
|
||||||
|
"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<dbRefreshToken?> GetRefreshTokenByIdAsync(Guid refreshTokenId)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
return await db.QuerySingleOrDefaultAsync<dbRefreshToken>(
|
||||||
|
"sp_RefreshToken_GetById",
|
||||||
|
new { p_id_refresh_token = refreshTokenId.ToString() },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RevokeRefreshTokenAsync(Guid refreshTokenId)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
var rowsAffected = await db.QuerySingleOrDefaultAsync<int>(
|
||||||
|
"sp_RefreshToken_Revoke",
|
||||||
|
new { p_id_refresh_token = refreshTokenId.ToString() },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
return rowsAffected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid?> 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<string?>(
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<dbUser?> GetByEmailAsync(string email)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
return await db.QuerySingleOrDefaultAsync<dbUser>(
|
||||||
|
"sp_User_GetByEmail",
|
||||||
|
new { p_email = email },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<dbUser?> GetByIdAsync(Guid idUser)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
return await db.QuerySingleOrDefaultAsync<dbUser>(
|
||||||
|
"sp_User_GetById",
|
||||||
|
new { p_id_user = idUser.ToString() },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace WinStudentGoalTracker.Models.ResponseTypes;
|
||||||
|
|
||||||
|
public class GoalBreakdownResponse
|
||||||
|
{
|
||||||
|
public string Goal { get; set; } = string.Empty;
|
||||||
|
public List<string> Subgoals { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<GoalBreakdownResponse> 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<OllamaChatResponse>(cancellationToken)
|
||||||
|
?? throw new JsonException("Ollama returned an empty response.");
|
||||||
|
|
||||||
|
var parsed = JsonSerializer.Deserialize<OllamaSubgoalsResult>(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<string> NormalizeSubgoals(List<JsonElement> rawSubgoals)
|
||||||
|
{
|
||||||
|
var normalized = new List<string>();
|
||||||
|
|
||||||
|
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<OllamaChatMessage> 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<JsonElement> Subgoals { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Claim>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> 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<TranscriptionResult>(cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("Transcription service returned an empty response.");
|
||||||
|
|
||||||
|
return result.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TranscriptionResult(string Text);
|
||||||
|
}
|
||||||
@@ -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: TriggerName<TAB>sql_mode<TAB>CreateStatement<TAB>...
|
||||||
|
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: FuncName<TAB>sql_mode<TAB>CreateStatement<TAB>...
|
||||||
|
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: ProcName<TAB>sql_mode<TAB>CreateStatement<TAB>...
|
||||||
|
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 "=========================================="
|
||||||
Reference in New Issue
Block a user