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 }