Commit aa0cf840 authored by Krishna Reddy Tamatam's avatar Krishna Reddy Tamatam

added missed files to git

parent 1bea4709
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Threading.Tasks;
using FTP_Services.Core.Models;
using FTP_Services.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace FTP_Services.Services.Controllers
{
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private IUserService _userService;
protected readonly log4net.ILog log = log4net.LogManager.GetLogger("AuthAPIController");
public AuthController(IUserService userService)
{
_userService = userService;
log.Info("AuthAPIController : Constractor with userservice");
}
[AllowAnonymous]
[HttpGet("ping")]
public IActionResult ping(string msg = "pingx:")
{
log.Debug("Ping called - msg" + msg);
return Ok(System.DateTime.Now.ToShortDateString() + ": " + System.DateTime.Now.ToShortTimeString() + " - PONG :" + msg);
}
[AllowAnonymous]
[HttpPost("Authenticate")]
public IActionResult Authenticate([FromBody] AuthenticateRequest model)
{
log.Debug("Called Authenticate ");
log.Debug("Authenticate Model" + model.ToString());
var response = _userService.Authenticate(model);
if (response == null)
return BadRequest(new { message = "Username or password is incorrect" });
return Ok(response);
}
}
}
\ No newline at end of file
using System;
using System.Text.Json.Serialization;
using PetaPoco;
namespace FTP_Services.Core.Models
{
public class AuthUser
{
public AuthUser()
{
Id=-1;
UserSession="";
UserName="";
UserFullName ="";
Email ="";
}
[Column ("user_id")]
public int Id { get; set; }
[Column ("user_session")]
public string UserSession { get; set; }
public string UserName { get; set; }
public string UserFullName { get; set; }
public string Email { get; set; }
}
}
\ No newline at end of file
namespace FTP_Services.Core.Models
{
public class AuthUserResponse
{
//[Column ("UserId")]
public int Id { get; set; }
public string UserName { get; set; }
public string Token { get; set; }
public string UserSession { get; set; }
public int RoleId {get;set;}
public string RoleName{get;set;}
public string UserFullName{get;set;}
public string Email{get;set;}
public AuthUserResponse()
{
Id = -1;
UserName = "";
RoleId = -1;
RoleName = "";
Token = "";
UserSession="";
UserFullName ="";
Email ="";
}
}
}
\ No newline at end of file
using System.ComponentModel.DataAnnotations;
namespace FTP_Services.Core.Models
{
public class AuthenticateRequest
{
[Required]
public string UserName { get; set; }
[Required]
public string UserToken { get; set; }
[Required]
public string APIKey { get; set; }
[Required]
public string Timestamp {get;set;}
public AuthenticateRequest()
{
UserName="";
UserToken="";
APIKey="";
Timestamp="";
}
}
}
\ No newline at end of file
namespace FTP_Services.Core.Models
{
public class EmailModel
{
public string StatusCode { get; set; }
public string Subject { get; set; }
public string Body { get; set; }
public string UserName { get; set; }
public string ApprovedBy { get; set; }
public string Email { get; set; }
public EmailModel()
{
StatusCode = "";
Subject = "";
Body = "";
UserName = "";
ApprovedBy = "";
Email = "";
}
}
public class MrdAcknowledgementModel
{
public int MrdAcknowledgementId { get; set; }
public int MrdDocumentAccessId { get; set; }
public string UniqueId { get; set; }
public string AcknowledgedBy { get; set; }
public string ActionType { get; set; }
public string ApprovedEmailBody { get; set; }
public string RejectedEmailBody { get; set; }
public DateTime? RequestedOn { get; set; }
public DateTime? AcknowledgedOn { get; set; }
public int RequestType { get; set; }
public string RequestedEmail { get; set; }
public string AcknowledgedUserName { get; set; }
}
}
\ No newline at end of file
// Services/FtpService.cs
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using FluentFTP;
using FTP_Services.Core.Models;
public class FluentFtpService
{
private readonly Action<string> _log;
public FluentFtpService(Action<string> logAction)
{
_log = logAction ?? (_ => { });
}
public async Task<
List<(string FileName, string? Base64, string? Error)>
> DownloadAndGetBase64FilesAsync(
FTPDocumentResponseModel payload,
string localTempDir,
int Height,
int Width
)
{
var results = new List<(string FileName, string? Base64, string? Error)>();
string remotePath =
$"/{payload.WebRemotePath.TrimEnd('/')}/{payload.FolderPath.TrimStart('/')}";
string fullLocalPath = Path.Combine(localTempDir, Guid.NewGuid().ToString());
try
{
if (Directory.Exists(localTempDir))
{
Directory.Delete(localTempDir, recursive: true);
Directory.CreateDirectory(localTempDir);
}
}
catch (Exception ex)
{
_log($"Failed to delete temp folder: {ex.Message}");
}
using var ftpClient = new AsyncFtpClient(
host: payload.RemoteHost,
port: payload.RemotePort,
credentials: new NetworkCredential(payload.RemoteUser, payload.RemotePassword)
)
{
Config =
{
DataConnectionType = FtpDataConnectionType.AutoPassive
// other config if needed
}
};
// Track timings
DateTime overallStart = DateTime.Now;
DateTime downloadStart = DateTime.Now;
DateTime downloadEnd = DateTime.Now;
DateTime processStart = DateTime.Now;
DateTime processEnd = DateTime.Now;
_log("----- FtpService START -----");
try
{
await ftpClient.Connect();
_log($"Connected to FTP: {payload.RemoteHost}");
// ======== DOWNLOAD PHASE ========
downloadStart = DateTime.Now;
_log($"Download started at {downloadStart:HH:mm:ss}");
await ftpClient.DownloadDirectory(fullLocalPath, remotePath, FtpFolderSyncMode.Update);
downloadEnd = DateTime.Now;
TimeSpan downloadDuration = downloadEnd - downloadStart;
_log(
$"Download ended at {downloadEnd:HH:mm:ss} (Duration: {downloadDuration.Hours}h {downloadDuration.Minutes}m {downloadDuration.Seconds}s)"
);
// ======== PROCESS PHASE ========
processStart = DateTime.Now;
_log($"Processing started at {processStart:HH:mm:ss}");
string encryptionKey = "12345678909876543210123456789098";
foreach (var fileName in payload.FilePaths)
{
string localFilePath = Path.Combine(fullLocalPath, fileName);
if (!File.Exists(localFilePath))
{
results.Add((fileName, null, "File not found after FTP download"));
continue;
}
try
{
byte[] encryptedData = await System.IO.File.ReadAllBytesAsync(localFilePath);
byte[] decryptedBytes = DecryptFTPFile(encryptedData, encryptionKey);
using var msDecrypted = new MemoryStream(decryptedBytes);
using var originalImage = Image.FromStream(msDecrypted);
using var resizedImage = ResizeFTPImage(originalImage, Width, Height);
using var msResized = new MemoryStream();
resizedImage.Save(msResized, ImageFormat.Jpeg);
string base64 = Convert.ToBase64String(msResized.ToArray());
// byte[] fileBytes = await File.ReadAllBytesAsync(localFilePath);
// string base64 = Convert.ToBase64String(fileBytes);
results.Add((fileName, base64, null));
}
catch (Exception ex)
{
results.Add((fileName, null, $"Failed to read or encode file: {ex.Message}"));
}
}
processEnd = DateTime.Now;
TimeSpan processDuration = processEnd - processStart;
_log(
$"Processing ended at {processEnd:HH:mm:ss} (Duration: {processDuration.Hours}h {processDuration.Minutes}m {processDuration.Seconds}s)"
);
}
catch (Exception ex)
{
results.Add(("Connection", null, $"FTP Connection failed: {ex.Message}"));
_log($"FTP Connection failed: {ex.Message}");
}
finally
{
try
{
await ftpClient.Disconnect();
}
catch { }
}
DateTime overallEnd = DateTime.Now;
TimeSpan totalDuration = overallEnd - overallStart;
_log(
$"Total Service Execution Time: {totalDuration.Hours}h {totalDuration.Minutes}m {totalDuration.Seconds}s"
);
_log("----- FtpService END -----\n");
return results;
}
public async Task<
List<(string FileName, string? Base64, string? Error)>
> DownloadSelectedFilesAndConvertToBase64Async(
FTPDocumentResponseModel payload,
string localTempDir,
int height,
int width
)
{
var results = new List<(string FileName, string? Base64, string? Error)>();
string remoteFolderPath =
$"/{payload.WebRemotePath.TrimEnd('/')}/{payload.FolderPath.TrimStart('/')}";
try
{
if (Directory.Exists(localTempDir))
{
Directory.Delete(localTempDir, recursive: true);
Directory.CreateDirectory(localTempDir);
}
}
catch (Exception ex)
{
_log($"Failed to delete temp folder: {ex.Message}");
}
const string encryptionKey = "12345678909876543210123456789098";
using var ftpClient = new AsyncFtpClient(
host: payload.RemoteHost,
port: payload.RemotePort,
credentials: new NetworkCredential(payload.RemoteUser, payload.RemotePassword)
)
{
Config = { DataConnectionType = FtpDataConnectionType.AutoPassive }
};
var overallStart = DateTime.Now;
_log($"===== FtpService START [{overallStart:yyyy-MM-dd HH:mm:ss}] =====");
try
{
await ftpClient.Connect();
_log($"Connected to FTP server: {payload.RemoteHost}:{payload.RemotePort}");
var downloadStart = DateTime.Now;
_log($"Download started at {downloadStart:HH:mm:ss}");
foreach (var fileName in payload.FilePaths)
{
string remoteFilePath = $"{remoteFolderPath}/{fileName}";
string localFilePath = Path.Combine(localTempDir, fileName);
try
{
var status = await ftpClient.DownloadFile(
localFilePath,
remoteFilePath,
FtpLocalExists.Overwrite,
FtpVerify.Retry
);
if (status == FtpStatus.Failed)
results.Add((fileName, null, "FTP download failed"));
}
catch (Exception ex)
{
results.Add((fileName, null, $"FTP download error: {ex.Message}"));
}
}
var downloadEnd = DateTime.Now;
var downloadDuration = downloadEnd - downloadStart;
_log(
$"Download ended at {downloadEnd:HH:mm:ss} "
+ $"(Duration: {downloadDuration.Hours}h {downloadDuration.Minutes}m {downloadDuration.Seconds}s)"
);
var processStart = DateTime.Now;
_log($"Processing started at {processStart:HH:mm:ss}");
foreach (var fileName in payload.FilePaths)
{
string localFilePath = Path.Combine(localTempDir, fileName);
if (!File.Exists(localFilePath))
{
results.Add((fileName, null, "File not found after download"));
continue;
}
try
{
byte[] encryptedData = await File.ReadAllBytesAsync(localFilePath);
byte[] decryptedBytes = DecryptFTPFile(encryptedData, encryptionKey);
using var msDecrypted = new MemoryStream(decryptedBytes);
using var originalImage = Image.FromStream(msDecrypted);
using var resizedImage = ResizeFTPImage(originalImage, width, height);
using var msResized = new MemoryStream();
resizedImage.Save(msResized, ImageFormat.Jpeg);
string base64 = Convert.ToBase64String(msResized.ToArray());
results.Add((fileName, base64, null));
}
catch (Exception ex)
{
results.Add((fileName, null, $"Processing failed: {ex.Message}"));
}
}
var processEnd = DateTime.Now;
var processDuration = processEnd - processStart;
_log(
$"Processing ended at {processEnd:HH:mm:ss} "
+ $"(Duration: {processDuration.Hours}h {processDuration.Minutes}m {processDuration.Seconds}s)"
);
// await ftpClient.Disconnect();
}
catch (Exception ex)
{
results.Add(("Connection", null, $"FTP Connection failed: {ex.Message}"));
_log($"FTP Connection failed: {ex.Message}");
}
finally
{
try
{
await ftpClient.Disconnect();
_log("FTP client disconnected successfully.");
}
catch (Exception ex)
{
_log($"Failed to disconnect FTP client: {ex.Message}");
}
}
var overallEnd = DateTime.Now;
var totalDuration = overallEnd - overallStart;
_log(
$"Total Execution Time: {totalDuration.Hours}h {totalDuration.Minutes}m {totalDuration.Seconds}s"
);
_log($"===== FtpService END [{overallEnd:yyyy-MM-dd HH:mm:ss}] =====");
return results;
}
private byte[] DecryptFTPFile(byte[] encryptedData, string encryptionKey)
{
byte[] keyBytes = Encoding.UTF8.GetBytes(encryptionKey);
byte[] decryptedBytes;
using (MemoryStream encryptedStream = new MemoryStream(encryptedData))
using (MemoryStream decryptedStream = new MemoryStream())
using (Aes aes = Aes.Create())
{
aes.Key = keyBytes;
aes.IV = keyBytes.Take(16).ToArray();
using (ICryptoTransform decryptor = aes.CreateDecryptor())
using (
CryptoStream cryptoStream = new CryptoStream(
encryptedStream,
decryptor,
CryptoStreamMode.Read
)
)
{
cryptoStream.CopyTo(decryptedStream);
}
decryptedBytes = decryptedStream.ToArray();
}
return decryptedBytes;
}
private Image ResizeFTPImage(Image img, int width, int height)
{
var bmp = new Bitmap(width, height);
using (Graphics g = Graphics.FromImage(bmp))
{
g.CompositingQuality = CompositingQuality.HighQuality;
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.SmoothingMode = SmoothingMode.HighQuality;
g.DrawImage(img, 0, 0, width, height);
}
img.Dispose();
return bmp;
}
// public async Task<
// List<(string FileName, string? Base64, string? Error)>
// > DownloadAndGetBase64FilesAsync(FTPDocumentResponseModel payload, string localTempDir)
// {
// var results = new List<(string FileName, string? Base64, string? Error)>();
// string remotePath =
// $"/{payload.WebRemotePath.TrimEnd('/')}/{payload.FolderPath.TrimStart('/')}";
// string fullLocalPath = Path.Combine(localTempDir, Guid.NewGuid().ToString());
// Directory.CreateDirectory(fullLocalPath);
// // Use AsyncFtpClient
// using var ftpClient = new AsyncFtpClient(
// host: payload.RemoteHost,
// port: payload.RemotePort,
// credentials: new NetworkCredential(payload.RemoteUser, payload.RemotePassword)
// )
// {
// Config =
// {
// DataConnectionType = FtpDataConnectionType.AutoPassive
// // other config if needed
// }
// };
// try
// {
// await ftpClient.Connect();
// // Download the folder
// await ftpClient.DownloadDirectory(fullLocalPath, remotePath, FtpFolderSyncMode.Update);
// foreach (var fileName in payload.FilePaths)
// {
// string localFilePath = Path.Combine(fullLocalPath, fileName);
// if (!File.Exists(localFilePath))
// {
// results.Add((fileName, null, "File not found after FTP download"));
// continue;
// }
// try
// {
// byte[] fileBytes = await File.ReadAllBytesAsync(localFilePath);
// string base64 = Convert.ToBase64String(fileBytes);
// results.Add((fileName, base64, null));
// }
// catch (Exception ex)
// {
// results.Add((fileName, null, $"Failed to read or encode file: {ex.Message}"));
// }
// }
// }
// catch (Exception ex)
// {
// results.Add(("Connection", null, $"FTP Connection failed: {ex.Message}"));
// }
// finally
// {
// try
// {
// await ftpClient.Disconnect();
// }
// catch { }
// }
// return results;
// }
}
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
//using System.Web.Helpers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using PetaPoco;
using FTP_Services.Core.Models;
namespace FTP_Services.Services
{
public class AuthenticationAdapter: BaseDataAdapter
{
#region Variables
#endregion
public AuthenticationAdapter(AppSettings appSettings):base(appSettings)
{
log.Debug("AuthenticationAdapter() Called");
}
public AuthUser? GetAuthUser(int id)
{
AuthUser authUser = new AuthUser();
try
{
//repository = new PetaPocoRepository(ConnectionString);
//_repository.OpenSharedConnection();
using (var tx = _repository.GetTransaction())
{
authUser = _repository.Single<AuthUser>("SELECT StaffID as user_id,'' as user_session, StaffName as \"UserName\" FROM Staff WHERE StaffID=@0",id );
GC.Collect();
tx.Complete();
}
log.Debug("GetAuthUser - Authuser - " + authUser.ToString());
return authUser;
}
catch (Exception ex)
{
log.Error("GetAuthUser->Failed to get user info from db - "+ ex.Message);
return null;
}
}
public AuthUser? ValidateUser(string userName,string passwordHash,string apiKey)
{
AuthUser userData = null;//new AuthUser();
try
{
//repository = new PetaPocoRepository(ConnectionString);
//_repository.OpenSharedConnection();
// using (var tx = _repository.GetTransaction())
//{
//userData = _repository.SingleOrDefault<AuthUser>("EXEC sp_validate_auth_user @@iv_user_name=@user_name,@@iv_user_token=@user_token",new {user_name=userName,user_token=passwordHash});
var list = _repository.Fetch<AuthUser>(";EXEC sp_validate_auth_user @@iv_user_name=@user_name,@@iv_user_token=@user_token",new {user_name=userName,user_token=passwordHash});
if(list !=null)
userData=list[0];
GC.Collect();
// tx.Complete();
//}
log.Debug("ValidateUser - userdata - " + userData.ToString());
return userData;
}
catch (Exception ex)
{
log.Error("Failed to get user info from db - "+ ex.Message);
return null;
}
}
}
}
\ No newline at end of file
using FTP_Services.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using FTP_Services.Core.Models;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute : Attribute, IAuthorizationFilter
{
protected readonly log4net.ILog log = log4net.LogManager.GetLogger("AuthorizeAttribute");
public void OnAuthorization(AuthorizationFilterContext context)
{
AuthUser user = (AuthUser)context.HttpContext.Items["AuthUser"];
log.Debug("Got request - headers - " + context.HttpContext.Request.Headers.ToString());
if (user == null)
{
// not logged in
context.Result = new JsonResult(new { message = "Unauthorized" }) { StatusCode = StatusCodes.Status401Unauthorized };
}
}
}
\ No newline at end of file
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using FTP_Services.Core.Models;
namespace FTP_Services.Services
{
public class JwtMiddleware
{
private readonly RequestDelegate _next;
private readonly AppSettings _appSettings;
protected readonly log4net.ILog log = log4net.LogManager.GetLogger("JWTMiddleWare");
public JwtMiddleware(RequestDelegate next, IOptions<AppSettings> appSettings)
{
_next = next;
_appSettings = appSettings.Value;
}
public async Task Invoke(HttpContext context, IUserService userService)
{
var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last();
if (token != null)
attachUserToContext(context, userService, token);
await _next(context);
}
private void attachUserToContext(HttpContext context, IUserService userService, string token)
{
try
{
log.Debug("Token =>" + token);
token = token.Replace("Bearer ", string.Empty);
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false,
ValidateAudience = false,
// set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later)
ClockSkew = TimeSpan.Zero
}, out SecurityToken validatedToken);
var jwtToken = (JwtSecurityToken)validatedToken;
var userId = int.Parse(jwtToken.Claims.First(x => x.Type == "AuthUserID").Value);
// attach user to context on successful jwt validation
context.Items["AuthUser"] = userService.GetAuthUser(userId);
}
catch(Exception ex)
{
log.Error("Fail to validate the token ->" + ex.Message);
// do nothing if jwt validation fails
// user is not attached to context so request won't have access to secure routes
}
}
}
}
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using FTP_Services.Core.Models;
namespace FTP_Services.Services
{
public interface IUserService
{
AuthUserResponse? Authenticate(AuthenticateRequest model);
//IEnumerable<AuthUser> GetAll();
AuthUser? GetAuthUser(int id);
}
public class UserService : IUserService
{
protected readonly log4net.ILog log = log4net.LogManager.GetLogger("UserService");
private readonly AppSettings _appSettings;
public UserService(IOptions<AppSettings> appSettings)
{
log.Debug("UserService: Called UserService");
_appSettings = appSettings.Value;
}
public AuthUser? GetAuthUser(int id)
{
AuthUser? authUser = null;
AuthenticationAdapter adapter = new AuthenticationAdapter(_appSettings);
authUser = adapter.GetAuthUser(id);
return authUser;
}
public AuthUserResponse? Authenticate(AuthenticateRequest model)
{
log.Debug("UserService: Authenticate Called");
AuthenticationAdapter adapter = new AuthenticationAdapter(_appSettings);
AuthUser? authUser = new AuthUser();
//User userInfo = new User();
log.Debug("Called UserService -> Called ValidateUser " + model.UserName+", " +model.UserToken);
try
{
if (String.IsNullOrEmpty(model.UserName))
{
throw new Exception("No User Name");
}
if(string.IsNullOrEmpty(model.UserToken))
{
throw new Exception("No user token");
}
if(string.IsNullOrEmpty(model.APIKey))
{
throw new Exception("No API Key");
}
//validate the time stamp //ISO 8601 i.e "2007-04-05T14:30Z" or "2007-04-05T12:30−02:00"
authUser = adapter.ValidateUser(model.UserName, model.UserToken,model.APIKey);
if(authUser==null)
{
throw new Exception("User not athenticated");
}
if(authUser.Id <= 0)
{
// login fail
return new AuthUserResponse();
}
// login success
authUser.UserName = model.UserName;
var token = generateJwtToken(authUser);
//log.Debug("ValidateUser token:" + token);
AuthUserResponse authUserResponse = new AuthUserResponse();//= adapter.CreateUserSession(authUser, token);
authUserResponse.UserName = model.UserName;
authUserResponse.Id = authUser.Id;
authUserResponse.UserSession = authUser.UserSession;
authUserResponse.RoleId = 0;//authUser.RoleId;
authUserResponse.Token = token;
authUserResponse.UserFullName = authUser.UserFullName;//authUser.RoleId;
authUserResponse.Email = authUser.Email;
return authUserResponse;
}
catch (Exception ex)
{
log.Error("ValidateUser - Error" + ex.Message);
//return new HttpResponseMessage(HttpStatusCode.Unauthorized);
return null;
}
}
private string generateJwtToken(AuthUser user)
{
// generate token that is valid for 7 days
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new Claim[]
{
new Claim(ClaimTypes.Name, user.UserName),
new Claim("AuthUserID", user.Id.ToString())
}
),
Expires = DateTime.UtcNow.AddDays(1),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment