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 }