using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Net;
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.Components;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Z.EntityFramework.Plus;
namespace Tgstation.Server.Host.Controllers
{
///
/// for managing s.
///
[Route(Routes.Chat)]
#pragma warning disable CA1506 // TODO: Decomplexify
public sealed class ChatController : InstanceRequiredController
{
///
/// Initializes a new instance of the class.
///
/// The for the .
/// The for the .
/// The for the .
/// The for the .
public ChatController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IInstanceManager instanceManager,
ILogger logger)
: base(
instanceManager,
databaseContext,
authenticationContextFactory,
logger)
{
}
///
/// 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
{
#pragma warning disable CS0618
DiscordChannelId = api.DiscordChannelId,
IrcChannel = api.IrcChannel,
#pragma warning restore CS0618
IsAdminChannel = api.IsAdminChannel ?? false,
IsWatchdogChannel = api.IsWatchdogChannel ?? false,
IsUpdatesChannel = api.IsUpdatesChannel ?? false,
Tag = api.Tag,
};
if (api.ChannelData != null)
{
switch (chatProvider)
{
case ChatProvider.Discord:
result.DiscordChannelId = ulong.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]
[TgsAuthorize(ChatBotRights.Create)]
[ProducesResponseType(typeof(ChatBotResponse), 201)]
public async Task Create([FromBody] ChatBotCreateRequest model, CancellationToken cancellationToken)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
var earlyOut = StandardModelChecks(model, true);
if (earlyOut != null)
return earlyOut;
var countOfExistingBotsInInstance = await DatabaseContext
.ChatBots
.AsQueryable()
.Where(x => x.InstanceId == Instance.Id)
.CountAsync(cancellationToken)
;
if (countOfExistingBotsInInstance >= Instance.ChatBotLimit.Value)
return Conflict(new ErrorMessageResponse(ErrorCode.ChatBotMax));
model.Enabled ??= false;
model.ReconnectionInterval ??= 1;
// try to update das db first
var dbModel = new ChatBot
{
Name = model.Name,
ConnectionString = model.ConnectionString,
Enabled = model.Enabled,
Channels = model.Channels?.Select(x => ConvertApiChatChannel(x, model.Provider.Value)).ToList() ?? new List(), // important that this isn't null
InstanceId = Instance.Id.Value,
Provider = model.Provider,
ReconnectionInterval = model.ReconnectionInterval,
ChannelLimit = model.ChannelLimit,
};
DatabaseContext.ChatBots.Add(dbModel);
await DatabaseContext.Save(cancellationToken);
return await WithComponentInstance(
async instance =>
{
try
{
// try to create it
await instance.Chat.ChangeSettings(dbModel, cancellationToken);
if (dbModel.Channels.Count > 0)
await instance.Chat.ChangeChannels(dbModel.Id.Value, dbModel.Channels, cancellationToken);
}
catch
{
// undo the add
DatabaseContext.ChatBots.Remove(dbModel);
// DCTx2: Operations must always run
await DatabaseContext.Save(default);
await instance.Chat.DeleteConnection(dbModel.Id.Value, default);
throw;
}
return null;
})
?? StatusCode(HttpStatusCode.Created, dbModel.ToApi());
}
///
/// 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 Task Delete(long id, CancellationToken cancellationToken)
=> await WithComponentInstance(
async instance =>
{
await Task.WhenAll(
instance.Chat.DeleteConnection(id, cancellationToken),
DatabaseContext
.ChatBots
.AsQueryable()
.Where(x => x.Id == id)
.DeleteAsync(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 Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
{
var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0;
return Paginated(
() => Task.FromResult(
new PaginatableResult(
DatabaseContext
.ChatBots
.AsQueryable()
.Where(x => x.InstanceId == Instance.Id)
.Include(x => x.Channels)
.OrderBy(x => x.Id))),
chatBot =>
{
if (connectionStrings)
chatBot.ConnectionString = null;
return Task.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 Task GetId(long id, CancellationToken cancellationToken)
{
var query = DatabaseContext.ChatBots
.AsQueryable()
.Where(x => x.Id == id && x.InstanceId == Instance.Id)
.Include(x => x.Channels);
var results = await query.FirstOrDefaultAsync(cancellationToken);
if (results == default)
return 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 Task Update([FromBody] ChatBotUpdateRequest model, CancellationToken cancellationToken)
#pragma warning restore CA1502, CA1506
{
if (model == null)
throw new ArgumentNullException(nameof(model));
var earlyOut = StandardModelChecks(model, false);
if (earlyOut != null)
return earlyOut;
var query = DatabaseContext
.ChatBots
.AsQueryable()
.Where(x => x.InstanceId == Instance.Id && x.Id == model.Id)
.Include(x => x.Channels);
var current = await query.FirstOrDefaultAsync(cancellationToken);
if (current == default)
return 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 WithComponentInstance(
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 .
IActionResult 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
}