using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Controllers
{
///
/// for managing s.
///
[Route(Routes.Chat)]
#pragma warning disable CA1506 // TODO: Decomplexify
public sealed class ChatController : InstanceRequiredController
{
///
/// The for the .
///
readonly IRestAuthorityInvoker chatAuthority;
///
/// Initializes a new instance of the class.
///
/// The value of .
/// The for the .
/// The for the .
/// The for the .
/// The for the .
/// The for the .
public ChatController(
IRestAuthorityInvoker chatAuthority,
IDatabaseContext databaseContext,
IAuthenticationContext authenticationContext,
ILogger logger,
IInstanceManager instanceManager,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContext,
logger,
instanceManager,
apiHeaders)
{
this.chatAuthority = chatAuthority ?? throw new ArgumentNullException(nameof(chatAuthority));
}
///
/// Converts to a .
///
/// The .
/// The channel's .
/// A based on .
static Models.ChatChannel ConvertApiChatChannel(Api.Models.ChatChannel api, ChatProvider chatProvider)
{
var result = new Models.ChatChannel
{
IsAdminChannel = api.IsAdminChannel ?? false,
IsWatchdogChannel = api.IsWatchdogChannel ?? false,
IsUpdatesChannel = api.IsUpdatesChannel ?? false,
IsSystemChannel = api.IsSystemChannel ?? false,
Tag = api.Tag,
};
if (api.ChannelData != null)
{
switch (chatProvider)
{
case ChatProvider.Discord:
result.DiscordChannelId = UInt64.Parse(api.ChannelData, CultureInfo.InvariantCulture);
break;
case ChatProvider.Irc:
result.IrcChannel = api.ChannelData;
break;
default:
throw new InvalidOperationException($"Invalid chat provider: {chatProvider}");
}
}
return result;
}
///
/// Create a new chat bot .
///
/// The .
/// The for the operation.
/// A resulting in the for the operation.
/// Created successfully.
[HttpPut]
[ProducesResponseType(typeof(ChatBotResponse), 201)]
public async ValueTask Create([FromBody] ChatBotCreateRequest model, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(model);
if (!model.Provider.HasValue)
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotProviderMissing));
if (String.IsNullOrWhiteSpace(model.Name))
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWhitespaceName));
if (String.IsNullOrWhiteSpace(model.ConnectionString))
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWhitespaceConnectionString));
if (!model.ValidateProviderChannelTypes())
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWrongChannelType));
var newChannels = model.Channels?.Select(x => ConvertApiChatChannel(x, model.Provider!.Value)).ToList() ?? new List(); // important that this isn't null
return await chatAuthority.InvokeTransformable(
this,
authority => authority.Create(
newChannels,
model.Name,
model.ConnectionString,
model.Provider.Value,
Instance.Require(x => x.Id),
model.ReconnectionInterval,
model.ChannelLimit,
model.Enabled ?? false,
cancellationToken));
}
///
/// Delete a .
///
/// The to delete.
/// The for the operation.
/// A resulting in the for the operation.
/// Chat bot deleted or does not exist.
[HttpDelete("{id}")]
[TgsAuthorize(ChatBotRights.Delete)]
[ProducesResponseType(204)]
public async ValueTask Delete(long id, CancellationToken cancellationToken)
=> await WithComponentInstanceNullable(
async instance =>
{
await Task.WhenAll(
instance.Chat.DeleteConnection(id, cancellationToken),
DatabaseContext
.ChatBots
.Where(x => x.Id == id)
.ExecuteDeleteAsync(cancellationToken));
return null;
})
?? NoContent();
///
/// List s.
///
/// The current page.
/// The page size.
/// The for the operation.
/// A resulting in the for the operation.
/// Listed chat bots successfully.
[HttpGet(Routes.List)]
[TgsAuthorize(ChatBotRights.Read)]
[ProducesResponseType(typeof(PaginatedResponse), 200)]
public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
{
var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0;
return Paginated(
() => ValueTask.FromResult?>(
new PaginatableResult(
DatabaseContext
.ChatBots
.Where(x => x.InstanceId == Instance.Id)
.Include(x => x.Channels)
.OrderBy(x => x.Id))),
chatBot =>
{
if (!connectionStrings)
chatBot.ConnectionString = null;
return ValueTask.CompletedTask;
},
page,
pageSize,
cancellationToken);
}
///
/// Get a specific .
///
/// The to retrieve.
/// The for the operation.
/// A resulting in the for the operation.
/// Retrieved successfully.
/// The with the given ID does not exist in this instance.
[HttpGet("{id}")]
[TgsAuthorize(ChatBotRights.Read)]
[ProducesResponseType(typeof(ChatBotResponse), 200)]
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
public async ValueTask GetId(long id, CancellationToken cancellationToken)
{
var query = DatabaseContext
.ChatBots
.Where(x => x.Id == id && x.InstanceId == Instance.Id)
.Include(x => x.Channels);
var results = await query.FirstOrDefaultAsync(cancellationToken);
if (results == default)
return this.Gone();
var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0;
if (!connectionStrings)
results.ConnectionString = null;
return Json(results.ToApi());
}
///
/// Updates a chat bot .
///
/// The .
/// The for the operation.
/// A resulting in the for the operation.
/// Update applied successfully.
/// Update applied successfully. not returned based on user permissions.
/// The with the given ID does not exist in this instance.
[HttpPost]
[TgsAuthorize(ChatBotRights.WriteChannels | ChatBotRights.WriteConnectionString | ChatBotRights.WriteEnabled | ChatBotRights.WriteName | ChatBotRights.WriteProvider)]
[ProducesResponseType(typeof(ChatBotResponse), 200)]
[ProducesResponseType(204)]
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
#pragma warning disable CA1502, CA1506 // TODO: Decomplexify
public async ValueTask Update([FromBody] ChatBotUpdateRequest model, CancellationToken cancellationToken)
#pragma warning restore CA1502, CA1506
{
ArgumentNullException.ThrowIfNull(model);
IActionResult? earlyOut = StandardModelChecks(model, false);
if (earlyOut != null)
return earlyOut;
var query = DatabaseContext
.ChatBots
.Where(x => x.InstanceId == Instance.Id && x.Id == model.Id)
.Include(x => x.Channels);
var current = await query.FirstOrDefaultAsync(cancellationToken);
if (current == default)
return this.Gone();
if ((model.Channels?.Count ?? current.Channels!.Count) > (model.ChannelLimit ?? current.ChannelLimit!.Value))
{
// 400 or 409 depends on if the client sent both
var errorMessage = new ErrorMessageResponse(ErrorCode.ChatBotMaxChannels);
if (model.Channels != null && model.ChannelLimit.HasValue)
return BadRequest(errorMessage);
return Conflict(errorMessage);
}
var userRights = (ChatBotRights)AuthenticationContext.GetRight(RightsType.ChatBots);
bool anySettingsModified = false;
bool CheckModified(Expression> expression, ChatBotRights requiredRight)
{
var memberSelectorExpression = (MemberExpression)expression.Body;
var property = (PropertyInfo)memberSelectorExpression.Member;
var newVal = property.GetValue(model);
if (newVal == null)
return false;
if (!userRights.HasFlag(requiredRight) && property.GetValue(current) != newVal)
return true;
property.SetValue(current, newVal);
anySettingsModified = true;
return false;
}
var oldProvider = current.Provider;
if (CheckModified(x => x.ConnectionString, ChatBotRights.WriteConnectionString)
|| CheckModified(x => x.Enabled, ChatBotRights.WriteEnabled)
|| CheckModified(x => x.Name, ChatBotRights.WriteName)
|| CheckModified(x => x.Provider, ChatBotRights.WriteProvider)
|| CheckModified(x => x.ReconnectionInterval, ChatBotRights.WriteReconnectionInterval)
|| CheckModified(x => x.ChannelLimit, ChatBotRights.WriteChannelLimit)
|| (model.Channels != null && !userRights.HasFlag(ChatBotRights.WriteChannels)))
return Forbid();
var hasChannels = model.Channels != null;
if (hasChannels || (model.Provider.HasValue && model.Provider != oldProvider))
{
DatabaseContext.ChatChannels.RemoveRange(current.Channels!);
if (hasChannels)
{
var dbChannels = model.Channels!.Select(x => ConvertApiChatChannel(x, model.Provider ?? current.Provider!.Value)).ToList();
DatabaseContext.ChatChannels.AddRange(dbChannels);
current.Channels = dbChannels;
}
else
current.Channels!.Clear();
}
await DatabaseContext.Save(cancellationToken);
earlyOut = await WithComponentInstanceNullable(
async instance =>
{
var chat = instance.Chat;
if (anySettingsModified)
await chat.ChangeSettings(current, cancellationToken); // have to rebuild the thing first
if ((model.Channels != null || anySettingsModified) && current.Enabled!.Value)
await chat.ChangeChannels(current.Id!.Value, current.Channels, cancellationToken);
return null;
});
if (earlyOut != null)
return earlyOut;
if (userRights.HasFlag(ChatBotRights.Read))
{
if (!userRights.HasFlag(ChatBotRights.ReadConnectionString))
current.ConnectionString = null;
return Json(current.ToApi());
}
return NoContent();
}
///
/// Perform some basic validation of a given .
///
/// The to validate.
/// If the is being created.
/// An to respond with or .
BadRequestObjectResult? StandardModelChecks(ChatBotApiBase model, bool forCreation)
{
if (model.ReconnectionInterval == 0)
throw new InvalidOperationException("RecconnectionInterval cannot be zero!");
if (forCreation && !model.Provider.HasValue)
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotProviderMissing));
if (model.Name != null && String.IsNullOrWhiteSpace(model.Name))
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWhitespaceName));
if (model.ConnectionString != null && String.IsNullOrWhiteSpace(model.ConnectionString))
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWhitespaceConnectionString));
if (!model.ValidateProviderChannelTypes())
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWrongChannelType));
var defaultMaxChannels = (ulong)Math.Max(ChatBot.DefaultChannelLimit, model.Channels?.Count ?? 0);
if (defaultMaxChannels > UInt16.MaxValue)
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotMaxChannels));
if (forCreation)
model.ChannelLimit ??= (ushort)defaultMaxChannels;
return null;
}
}
#pragma warning restore CA1506
}