Removed NioTSO client and server

- NioTSO client isn't needed because we're using RayLib
- Added FreeSO's API server to handle most backend operations
This commit is contained in:
Tony Bark 2024-05-01 02:55:43 -04:00
parent f12ba1502b
commit 22191ce648
591 changed files with 53264 additions and 3362 deletions

View file

@ -0,0 +1,157 @@
using System;
using System.Linq;
using System.Net;
using FSO.Server.Api.Core.Models;
using FSO.Server.Api.Core.Utils;
using FSO.Server.Database.DA.DbEvents;
using FSO.Server.Database.DA.Tuning;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
// For more information on enabling MVC for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860
namespace FSO.Server.Api.Core.Controllers.Admin
{
[EnableCors("AdminAppPolicy")]
[Route("admin/events")]
[ApiController]
public class AdminEventsController : ControllerBase
{
//List events
[HttpGet]
public IActionResult Get(int limit, int offset, string order)
{
if (limit == 0) limit = 20;
if (order == null) order = "start_day";
var api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
if (limit > 100)
{
limit = 100;
}
var result = da.Events.All((int)offset, (int)limit, order);
return ApiResponse.PagedList<DbEvent>(Request, HttpStatusCode.OK, result);
}
}
[HttpGet("presets")]
public IActionResult GetPresets()
{
var api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
return new JsonResult(da.Tuning.GetAllPresets().ToList());
}
}
[HttpPost("presets")]
public IActionResult CreatePreset([FromBody]PresetCreateModel request)
{
var api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
//make the preset first
var preset_id = da.Tuning.CreatePreset(
new DbTuningPreset()
{
name = request.name,
description = request.description,
flags = request.flags
});
foreach (var item in request.items)
{
da.Tuning.CreatePresetItem(new DbTuningPresetItem()
{
preset_id = preset_id,
tuning_type = item.tuning_type,
tuning_table = item.tuning_table,
tuning_index = item.tuning_index,
value = item.value
});
}
return new JsonResult(da.Tuning.GetAllPresets().ToList());
}
}
[HttpGet("presets/{preset_id}")]
public IActionResult GetPresetEntries(int preset_id)
{
var api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
return new JsonResult(da.Tuning.GetPresetItems(preset_id).ToList());
}
}
[HttpDelete("presets/{preset_id}")]
public IActionResult DeletePreset(int preset_id)
{
var api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
return da.Tuning.DeletePreset(preset_id) ? (IActionResult)Ok() : NotFound();
}
}
// POST admin/updates (start update generation)
[HttpPost]
public IActionResult Post([FromBody]EventCreateModel request)
{
var api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
DbEventType type;
try
{
type = Enum.Parse<DbEventType>(request.type);
}
catch
{
return BadRequest("Event type must be one of:" + string.Join(", ", Enum.GetNames(typeof(DbEventType))));
}
var model = new DbEvent()
{
title = request.title,
description = request.description,
start_day = request.start_day,
end_day = request.end_day,
type = type,
value = request.value,
value2 = request.value2,
mail_subject = request.mail_subject,
mail_message = request.mail_message,
mail_sender = request.mail_sender,
mail_sender_name = request.mail_sender_name
};
return new JsonResult(new { id = da.Events.Add(model) });
}
}
[HttpDelete]
[Route("{id}")]
public IActionResult Delete(int id)
{
var api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
if (!da.Events.Delete(id)) return NotFound();
}
return Ok();
}
}
}

View file

@ -0,0 +1,30 @@
using FSO.Server.Api.Core.Utils;
using System.Net;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Cors;
namespace FSO.Server.Api.Core.Controllers.Admin
{
[EnableCors("AdminAppPolicy")]
[Route("admin/hosts")]
[ApiController]
public class AdminHostsController : ControllerBase
{
public IActionResult Get()
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
var hosts = api.HostPool.GetAll();
return ApiResponse.Json(HttpStatusCode.OK, hosts.Select(x => new {
role = x.Role,
call_sign = x.CallSign,
internal_host = x.InternalHost,
public_host = x.PublicHost,
connected = x.Connected,
time_boot = x.BootTime
}));
}
}
}

View file

@ -0,0 +1,129 @@
using FSO.Server.Api.Core.Utils;
using FSO.Server.Common;
using FSO.Server.Servers.Api.JsonWebToken;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
namespace FSO.Server.Api.Core.Controllers.Admin
{
[EnableCors("AdminAppPolicy")]
[Route("admin/oauth/token")]
[ApiController]
public class AdminOAuthController : ControllerBase
{
[HttpPost]
public IActionResult Post([FromForm] AuthRequest auth)
{
if (auth == null) Ok();
if (auth.grant_type == "password")
{
var api = Api.INSTANCE;
using (var da = api.DAFactory.Get())
{
var user = da.Users.GetByUsername(auth.username);
if (user == null || user.is_banned || !(user.is_admin || user.is_moderator))
{
return ApiResponse.Json(System.Net.HttpStatusCode.OK, new OAuthError
{
error = "unauthorized_client",
error_description = "user_credentials_invalid"
});
}
var ip = ApiUtils.GetIP(Request);
var accLock = da.Users.GetRemainingAuth(user.user_id, ip);
if (accLock != null && (accLock.active || accLock.count >= AuthLoginController.LockAttempts) && accLock.expire_time > Epoch.Now)
{
return ApiResponse.Json(System.Net.HttpStatusCode.OK, new OAuthError
{
error = "unauthorized_client",
error_description = "account_locked"
});
}
var authSettings = da.Users.GetAuthenticationSettings(user.user_id);
var isPasswordCorrect = PasswordHasher.Verify(auth.password, new PasswordHash
{
data = authSettings.data,
scheme = authSettings.scheme_class
});
if (!isPasswordCorrect)
{
var durations = AuthLoginController.LockDuration;
var failDelay = 60 * durations[Math.Min(durations.Length - 1, da.Users.FailedConsecutive(user.user_id, ip))];
if (accLock == null)
{
da.Users.NewFailedAuth(user.user_id, ip, (uint)failDelay);
}
else
{
var remaining = da.Users.FailedAuth(accLock.attempt_id, (uint)failDelay, AuthLoginController.LockAttempts);
}
return ApiResponse.Json(System.Net.HttpStatusCode.OK, new OAuthError
{
error = "unauthorized_client",
error_description = "user_credentials_invalid"
});
}
da.Users.SuccessfulAuth(user.user_id, ip);
JWTUser identity = new JWTUser();
identity.UserName = user.username;
var claims = new List<string>();
if (user.is_admin || user.is_moderator)
{
claims.Add("moderator");
}
if (user.is_admin)
{
claims.Add("admin");
}
identity.Claims = claims;
identity.UserID = user.user_id;
var token = api.JWT.CreateToken(identity);
var response = ApiResponse.Json(System.Net.HttpStatusCode.OK, new OAuthSuccess
{
access_token = token.Token,
expires_in = token.ExpiresIn
});
return response;
}
}
return ApiResponse.Json(System.Net.HttpStatusCode.OK, new OAuthError
{
error = "invalid_request",
error_description = "unknown grant_type"
});
}
}
public class OAuthError
{
public string error_description { get; set; }
public string error { get; set; }
}
public class OAuthSuccess
{
public string access_token { get; set; }
public int expires_in { get; set; }
}
public class AuthRequest
{
public string grant_type { get; set; }
public string username { get; set; }
public string password { get; set; }
}
}

View file

@ -0,0 +1,69 @@
using FSO.Server.Api.Core.Utils;
using FSO.Server.Common;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using System.Net;
namespace FSO.Server.Api.Core.Controllers.Admin
{
[EnableCors("AdminAppPolicy")]
[Route("admin/shards")]
[ApiController]
public class AdminShardsController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
using (var db = api.DAFactory.Get())
{
var shards = db.Shards.All();
return ApiResponse.Json(HttpStatusCode.OK, shards);
}
}
[HttpPost("shutdown")]
public IActionResult shutdown(ShutdownModel sd)
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
ShutdownType type = ShutdownType.SHUTDOWN;
if (sd.update) type = ShutdownType.UPDATE;
else if (sd.restart) type = ShutdownType.RESTART;
api.RequestShutdown((uint)sd.timeout, type);
return ApiResponse.Json(HttpStatusCode.OK, true);
}
[HttpPost("announce")]
public IActionResult announce(AnnouncementModel an)
{
var api = Api.INSTANCE;
api.DemandModerator(Request);
api.BroadcastMessage(an.sender, an.subject, an.message);
return ApiResponse.Json(HttpStatusCode.OK, true);
}
}
public class AnnouncementModel
{
public string sender;
public string subject;
public string message;
public int[] shard_ids;
}
public class ShutdownModel
{
public int timeout;
public bool restart;
public bool update;
public int[] shard_ids;
}
}

View file

@ -0,0 +1,73 @@
using FSO.Server.Api.Core.Utils;
using FSO.Server.Database.DA.Tasks;
using FSO.Server.Protocol.Gluon.Packets;
using Newtonsoft.Json;
using System;
using System.Net;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Cors;
namespace FSO.Server.Api.Core.Controllers.Admin
{
[EnableCors("AdminAppPolicy")]
[Route("admin/tasks")]
[ApiController]
public class AdminTasksController : ControllerBase
{
[HttpGet]
public IActionResult Get(int limit, int offset)
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
using (var da = api.DAFactory.Get())
{
if (limit > 100)
{
limit = 100;
}
var result = da.Tasks.All((int)offset, (int)limit);
return ApiResponse.PagedList<DbTask>(Request, HttpStatusCode.OK, result);
}
}
[HttpPost("request")]
public IActionResult request([FromBody] TaskRequest task)
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
var taskServer = api.HostPool.GetByRole(Database.DA.Hosts.DbHostRole.task).FirstOrDefault();
if (taskServer == null)
{
return ApiResponse.Json(HttpStatusCode.OK, -1);
}
else
{
try
{
var id = taskServer.Call(new RequestTask()
{
TaskType = task.task_type.ToString(),
ParameterJson = JsonConvert.SerializeObject(task.parameter),
ShardId = (task.shard_id == null || !task.shard_id.HasValue) ? -1 : task.shard_id.Value
}).Result;
return ApiResponse.Json(HttpStatusCode.OK, id);
}
catch (Exception ex)
{
return ApiResponse.Json(HttpStatusCode.OK, -1);
}
}
}
}
public class TaskRequest
{
public DbTaskType task_type;
public int? shard_id;
public dynamic parameter;
}
}

View file

@ -0,0 +1,172 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using FSO.Server.Api.Core.Models;
using FSO.Server.Api.Core.Services;
using FSO.Server.Api.Core.Utils;
using FSO.Server.Database.DA.Updates;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
// For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860
namespace FSO.Server.Api.Core.Controllers.Admin
{
[EnableCors("AdminAppPolicy")]
[Route("admin/updates")]
public class AdminUpdatesController : ControllerBase
{
//List updates
[HttpGet]
public IActionResult Get(int limit, int offset, string order)
{
if (limit == 0) limit = 20;
if (order == null) order = "date";
var api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
if (limit > 100)
{
limit = 100;
}
var result = da.Updates.All((int)offset, (int)limit);
return ApiResponse.PagedList<DbUpdate>(Request, HttpStatusCode.OK, result);
}
}
// GET all branches
[HttpGet("branches")]
public IActionResult GetBranches()
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
using (var da = api.DAFactory.Get())
{
return new JsonResult(da.Updates.GetBranches().ToList());
}
}
// GET all addons
[HttpGet("addons")]
public IActionResult GetAddons()
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
using (var da = api.DAFactory.Get())
{
return new JsonResult(da.Updates.GetAddons(20).ToList());
}
}
// POST create a branch.
[HttpPost("branches")]
public IActionResult AddBranch(DbUpdateBranch branch)
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
using (var da = api.DAFactory.Get())
{
if (da.Updates.AddBranch(branch)) return Ok();
else return NotFound();
}
}
// POST update a branch.
[HttpPost("branches/{id}")]
public IActionResult UpdateBranch(DbUpdateBranch branch)
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
using (var da = api.DAFactory.Get())
{
if (da.Updates.UpdateBranchInfo(branch)) return Ok();
else return NotFound();
}
}
public class AddonUploadModel
{
public string name { get; set; }
public string description { get; set; }
public IFormFile clientAddon { get; set; }
public IFormFile serverAddon { get; set; }
}
static int AddonRequestID = 0;
[HttpPost("uploadaddon")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 500000000)]
public async Task<IActionResult> UploadAddon(AddonUploadModel upload)
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
var reqID = ++AddonRequestID;
var info = new DbUpdateAddon();
if (upload.name == null || upload.name.Length > 128) return BadRequest("Invalid name.");
if (upload.description == null || upload.description.Length > 1024) return BadRequest("Invalid description.");
info.name = upload.name;
info.description = upload.description;
info.date = DateTime.UtcNow;
if (upload.clientAddon == null && upload.serverAddon == null)
return BadRequest("client or server addon binary must be uploaded.");
var addonID = DateTime.UtcNow.Ticks;
Directory.CreateDirectory("updateTemp/addons/");
if (upload.clientAddon != null)
{
using (var file = System.IO.File.Open($"updateTemp/addons/client{reqID}.zip", FileMode.Create, FileAccess.Write, FileShare.None))
{
await upload.clientAddon.CopyToAsync(file);
}
info.addon_zip_url = await api.UpdateUploader.UploadFile($"addons/client{addonID}.zip", $"updateTemp/addons/client{reqID}.zip", $"addon-{addonID}");
System.IO.File.Delete($"updateTemp/addons/client{reqID}.zip");
}
if (upload.serverAddon != null)
{
using (var file = System.IO.File.Open($"updateTemp/addons/server{reqID}.zip", FileMode.Create, FileAccess.Write, FileShare.None))
{
await upload.serverAddon.CopyToAsync(file);
}
info.server_zip_url = await api.UpdateUploader.UploadFile($"addons/server{addonID}.zip", $"updateTemp/addons/server{reqID}.zip", $"addon-{addonID}");
System.IO.File.Delete($"updateTemp/addons/server{reqID}.zip");
}
using (var da = api.DAFactory.Get())
{
da.Updates.AddAddon(info);
return new JsonResult(info);
}
}
// GET status for ongoing update generation
[HttpGet("updateTask/{id}")]
public IActionResult GetTask(int id)
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
var task = GenerateUpdateService.INSTANCE.GetTask(id);
if (task == null) return NotFound();
else return new JsonResult(task);
}
// POST admin/updates (start update generation)
[HttpPost]
public IActionResult Post([FromBody]UpdateCreateModel request)
{
var api = Api.INSTANCE;
api.DemandAdmin(Request);
var task = GenerateUpdateService.INSTANCE.CreateTask(request);
return new JsonResult(task);
}
}
}

View file

@ -0,0 +1,334 @@
using FSO.Server.Api.Core.Utils;
using FSO.Server.Common;
using FSO.Server.Database.DA.Inbox;
using FSO.Server.Database.DA.Users;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Net;
namespace FSO.Server.Api.Core.Controllers.Admin
{
[EnableCors("AdminAppPolicy")]
[Route("admin/users")]
[ApiController]
public class AdminUsersController : ControllerBase
{
//Get information about me, useful for the admin user interface to disable UI based on who you login as
public IActionResult current()
{
var api = Api.INSTANCE;
var user = api.RequireAuthentication(Request);
using (var da = api.DAFactory.Get())
{
var userModel = da.Users.GetById(user.UserID);
if (userModel == null)
{
throw new Exception("Unable to find user");
}
return ApiResponse.Json(HttpStatusCode.OK, userModel);
}
}
//Get the attributes of a specific user
[HttpGet("{id}")]
public IActionResult Get(string id)
{
if (id == "current") return current();
var api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
var userModel = da.Users.GetById(uint.Parse(id));
if (userModel == null) { throw new Exception("Unable to find user"); }
return ApiResponse.Json(HttpStatusCode.OK, userModel);
}
}
/// <summary>
/// Unbans a user by IP and user.
/// </summary>
/// <param name="user_id">ID of user to unban.</param>
/// <returns></returns>
[HttpPost]
[Route("admin/unban")]
public IActionResult UnbanUser([FromBody] string user_id)
{
Api api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
User userModel = da.Users.GetById(uint.Parse(user_id));
if(userModel.is_banned)
{
da.Users.UpdateBanned(uint.Parse(user_id), false);
}
var ban = da.Bans.GetByIP(userModel.last_ip);
if (ban!=null)
{
da.Bans.Remove(userModel.user_id);
}
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "success"
});
}
}
/// <summary>
/// Sends an in-game email message to a player.
/// </summary>
/// <param name="mail"></param>
/// <returns></returns>
[HttpPost]
[Route("admin/mail")]
public IActionResult SendMail(MailCreateModel mail)
{
Api api = Api.INSTANCE;
api.DemandAdmin(Request);
using (var da = api.DAFactory.Get())
{
User recipient = da.Users.GetById(uint.Parse(mail.target_id));
if (recipient == null)
{
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "invalid_target_id"
});
}
if (mail.subject.Trim() == "")
{
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "subject_empty"
});
}
if (mail.body.Trim() == "")
{
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "body_empty"
});
}
// Save mail in db
int message_id = da.Inbox.CreateMessage(new DbInboxMsg
{
sender_id = 2147483648,
target_id = uint.Parse(mail.target_id),
subject = mail.subject,
body = mail.body,
sender_name = "FreeSO Staff",
time = DateTime.UtcNow,
msg_type = 4,
msg_subtype = 0,
read_state = 0,
});
// Try and notify the user ingame
api.RequestMailNotify(message_id, mail.subject, mail.body, uint.Parse(mail.target_id));
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "success"
});
}
}
/// <summary>
/// Kicks a user out the current session.
/// </summary>
/// <param name="kick"></param>
/// <returns></returns>
[HttpPost]
[Route("admin/kick")]
public IActionResult KickUser([FromBody] string user_id)
{
Api api = Api.INSTANCE;
api.DemandModerator(Request);
api.RequestUserDisconnect(uint.Parse(user_id));
return ApiResponse.Json(HttpStatusCode.OK, new {
status = "success"
});
}
/// <summary>
/// Bans a user and kicks them.
/// </summary>
/// <param name="ban"></param>
/// <returns></returns>
[HttpPost]
[Route("admin/ban")]
public IActionResult BanUser(BanCreateModel ban)
{
Api api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
User userModel = da.Users.GetById(uint.Parse(ban.user_id));
if (userModel == null)
{
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "invalid_id"
});
}
if (ban.ban_type == "ip")
{
if (da.Bans.GetByIP(userModel.last_ip) != null)
{
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "already_banned"
});
}
if (userModel.last_ip == "127.0.0.1")
{
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "invalid_ip"
});
}
da.Bans.Add(userModel.last_ip, userModel.user_id, ban.reason, int.Parse(ban.end_date), userModel.client_id);
api.RequestUserDisconnect(userModel.user_id);
api.SendBanMail(userModel.username, userModel.email, uint.Parse(ban.end_date));
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "success"
});
}
else if (ban.ban_type == "user")
{
if (userModel.is_banned)
{
return ApiResponse.Json(HttpStatusCode.NotFound, new
{
status = "already_banned"
});
}
da.Users.UpdateBanned(userModel.user_id, true);
api.RequestUserDisconnect(userModel.user_id);
api.SendBanMail(userModel.username, userModel.email, uint.Parse(ban.end_date));
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "success"
});
}
return ApiResponse.Json(HttpStatusCode.OK, new
{
status = "invalid_ban_type"
});
}
}
//List users
[HttpGet]
public IActionResult Get(int limit, int offset, string order)
{
if (limit == 0) limit = 20;
if (order == null) order = "register_date";
var api = Api.INSTANCE;
api.DemandModerator(Request);
using (var da = api.DAFactory.Get())
{
if (limit > 100)
{
limit = 100;
}
var result = da.Users.All((int)offset, (int)limit);
return ApiResponse.PagedList<User>(Request, HttpStatusCode.OK, result);
}
}
//Create a new user
[HttpPost]
public IActionResult Post(UserCreateModel user)
{
var api = Api.INSTANCE;
var nuser = api.RequireAuthentication(Request);
api.DemandModerator(nuser);
if (user.is_admin)
{
//I need admin claim to do this
api.DemandAdmin(nuser);
}
using (var da = api.DAFactory.Get())
{
var userModel = new User();
userModel.username = user.username;
userModel.email = user.email;
userModel.is_admin = user.is_admin;
userModel.is_moderator = user.is_moderator;
userModel.user_state = UserState.valid;
userModel.register_date = Epoch.Now;
userModel.is_banned = false;
var userId = da.Users.Create(userModel);
userModel = da.Users.GetById(userId);
if (userModel == null) { throw new Exception("Unable to find user"); }
return ApiResponse.Json(HttpStatusCode.OK, userModel);
}
}
}
public class UserCreateModel
{
public string username { get; set; }
public string email { get; set; }
public string password { get; set; }
public bool is_admin { get; set; }
public bool is_moderator { get; set; }
}
public class BanCreateModel
{
public string ban_type { get; set; }
public string user_id { get; set; }
public string reason { get; set; }
public string end_date { get; set; }
}
public class MailCreateModel
{
public string target_id { get; set; }
public string subject { get; set; }
public string body { get; set; }
public string sender_name { get; set; }
}
}