diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 512700ac24..f359ee44c3 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -56,6 +56,7 @@ using MediaBrowser.Common.Updates; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.ClientEvent; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dlna; @@ -665,7 +666,7 @@ namespace Emby.Server.Implementations serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); - + serviceCollection.AddScoped(); serviceCollection.AddSingleton(); } diff --git a/Jellyfin.Api/Controllers/ClientLogController.cs b/Jellyfin.Api/Controllers/ClientLogController.cs new file mode 100644 index 0000000000..95d07c9307 --- /dev/null +++ b/Jellyfin.Api/Controllers/ClientLogController.cs @@ -0,0 +1,149 @@ +using System; +using System.Net.Mime; +using System.Threading.Tasks; +using Jellyfin.Api.Attributes; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.ClientLogDtos; +using MediaBrowser.Controller.ClientEvent; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.ClientLog; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// + /// Client log controller. + /// + [Authorize(Policy = Policies.DefaultAuthorization)] + public class ClientLogController : BaseJellyfinApiController + { + private const int MaxDocumentSize = 1_000_000; + private readonly IClientEventLogger _clientEventLogger; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public ClientLogController( + IClientEventLogger clientEventLogger, + IServerConfigurationManager serverConfigurationManager) + { + _clientEventLogger = clientEventLogger; + _serverConfigurationManager = serverConfigurationManager; + } + + /// + /// Post event from client. + /// + /// The client log dto. + /// Event logged. + /// Event logging disabled. + /// Submission status. + [HttpPost] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult LogEvent([FromBody] ClientLogEventDto clientLogEventDto) + { + if (!_serverConfigurationManager.Configuration.AllowClientLogUpload) + { + return Forbid(); + } + + var (clientName, clientVersion, userId, deviceId) = GetRequestInformation(); + Log(clientLogEventDto, userId, clientName, clientVersion, deviceId); + return NoContent(); + } + + /// + /// Bulk post events from client. + /// + /// The list of client log dtos. + /// All events logged. + /// Event logging disabled. + /// Submission status. + [HttpPost("Bulk")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult LogEvents([FromBody] ClientLogEventDto[] clientLogEventDtos) + { + if (!_serverConfigurationManager.Configuration.AllowClientLogUpload) + { + return Forbid(); + } + + var (clientName, clientVersion, userId, deviceId) = GetRequestInformation(); + foreach (var dto in clientLogEventDtos) + { + Log(dto, userId, clientName, clientVersion, deviceId); + } + + return NoContent(); + } + + /// + /// Upload a document. + /// + /// Document saved. + /// Event logging disabled. + /// Upload size too large. + /// Create response. + [HttpPost("Document")] + [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + [AcceptsFile(MediaTypeNames.Text.Plain)] + [RequestSizeLimit(MaxDocumentSize)] + public async Task> LogFile() + { + if (!_serverConfigurationManager.Configuration.AllowClientLogUpload) + { + return Forbid(); + } + + if (Request.ContentLength > MaxDocumentSize) + { + // Manually validate to return proper status code. + return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes"); + } + + var (clientName, clientVersion, _, _) = GetRequestInformation(); + var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body) + .ConfigureAwait(false); + return Ok(new ClientLogDocumentResponseDto(fileName)); + } + + private void Log( + ClientLogEventDto dto, + Guid userId, + string clientName, + string clientVersion, + string deviceId) + { + _clientEventLogger.Log(new ClientLogEvent( + dto.Timestamp, + dto.Level, + userId, + clientName, + clientVersion, + deviceId, + dto.Message)); + } + + private (string ClientName, string ClientVersion, Guid UserId, string DeviceId) GetRequestInformation() + { + var clientName = ClaimHelpers.GetClient(HttpContext.User) ?? "unknown-client"; + var clientVersion = ClaimHelpers.GetIsApiKey(HttpContext.User) + ? "apikey" + : ClaimHelpers.GetVersion(HttpContext.User) ?? "unknown-version"; + var userId = ClaimHelpers.GetUserId(HttpContext.User) ?? Guid.Empty; + var deviceId = ClaimHelpers.GetDeviceId(HttpContext.User) ?? "unknown-device-id"; + + return (clientName, clientVersion, userId, deviceId); + } + } +} diff --git a/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs b/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs new file mode 100644 index 0000000000..44509a9c04 --- /dev/null +++ b/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Api.Models.ClientLogDtos +{ + /// + /// Client log document response dto. + /// + public class ClientLogDocumentResponseDto + { + /// + /// Initializes a new instance of the class. + /// + /// The file name. + public ClientLogDocumentResponseDto(string fileName) + { + FileName = fileName; + } + + /// + /// Gets the resulting filename. + /// + public string FileName { get; } + } +} diff --git a/Jellyfin.Api/Models/ClientLogDtos/ClientLogEventDto.cs b/Jellyfin.Api/Models/ClientLogDtos/ClientLogEventDto.cs new file mode 100644 index 0000000000..9bf9be0a46 --- /dev/null +++ b/Jellyfin.Api/Models/ClientLogDtos/ClientLogEventDto.cs @@ -0,0 +1,30 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Models.ClientLogDtos +{ + /// + /// The client log dto. + /// + public class ClientLogEventDto + { + /// + /// Gets or sets the event timestamp. + /// + [Required] + public DateTime Timestamp { get; set; } + + /// + /// Gets or sets the log level. + /// + [Required] + public LogLevel Level { get; set; } + + /// + /// Gets or sets the log message. + /// + [Required] + public string Message { get; set; } = string.Empty; + } +} diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 49d979e115..a75d747be4 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -44,6 +44,7 @@ + diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 5f848be9e1..5c7012d581 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -13,6 +13,7 @@ using Emby.Server.Implementations; using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; +using MediaBrowser.Controller.ClientEvent; using MediaBrowser.Controller.Extensions; using MediaBrowser.Model.IO; using Microsoft.AspNetCore.Hosting; @@ -25,6 +26,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Serilog.Extensions.Logging; +using Serilog.Filters; using SQLitePCL; using ConfigurationExtensions = MediaBrowser.Controller.Extensions.ConfigurationExtensions; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -596,22 +598,46 @@ namespace Jellyfin.Server { // Serilog.Log is used by SerilogLoggerFactory when no logger is specified Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .Enrich.FromLogContext() - .Enrich.WithThreadId() + .WriteTo.Logger(lc => + lc.ReadFrom.Configuration(configuration) + .Enrich.FromLogContext() + .Enrich.WithThreadId() + .Filter.ByExcluding(Matching.FromSource())) + .WriteTo.Logger(lc => + lc.WriteTo.Map( + "ClientName", + (clientName, wt) + => wt.File( + Path.Combine(appPaths.LogDirectoryPath, "log_" + clientName + "_.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: "{Message:l}{NewLine}{Exception}", + encoding: Encoding.UTF8)) + .Filter.ByIncludingOnly(Matching.FromSource())) .CreateLogger(); } catch (Exception ex) { Log.Logger = new LoggerConfiguration() - .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}") - .WriteTo.Async(x => x.File( - Path.Combine(appPaths.LogDirectoryPath, "log_.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}", - encoding: Encoding.UTF8)) - .Enrich.FromLogContext() - .Enrich.WithThreadId() + .WriteTo.Logger(lc => + lc.WriteTo.Async(x => x.File( + Path.Combine(appPaths.LogDirectoryPath, "log_.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: "{Message:l}{NewLine}{Exception}", + encoding: Encoding.UTF8)) + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}") + .Enrich.FromLogContext() + .Enrich.WithThreadId()) + .WriteTo.Logger(lc => + lc + .WriteTo.Map( + "ClientName", + (clientName, wt) + => wt.File( + Path.Combine(appPaths.LogDirectoryPath, "log_" + clientName + "_.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: "{Message:l}{NewLine}{Exception}", + encoding: Encoding.UTF8)) + .Filter.ByIncludingOnly(Matching.FromSource())) .CreateLogger(); Log.Logger.Fatal(ex, "Failed to create/read logger configuration"); diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs new file mode 100644 index 0000000000..82b5b4593c --- /dev/null +++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs @@ -0,0 +1,55 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using MediaBrowser.Model.ClientLog; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.ClientEvent +{ + /// + public class ClientEventLogger : IClientEventLogger + { + private const string LogString = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level}] [{ClientName}:{ClientVersion}]: UserId: {UserId} DeviceId: {DeviceId}{NewLine}{Message}"; + private readonly ILogger _logger; + private readonly IServerApplicationPaths _applicationPaths; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public ClientEventLogger( + ILogger logger, + IServerApplicationPaths applicationPaths) + { + _logger = logger; + _applicationPaths = applicationPaths; + } + + /// + public void Log(ClientLogEvent clientLogEvent) + { + _logger.Log( + LogLevel.Critical, + LogString, + clientLogEvent.Timestamp, + clientLogEvent.Level.ToString(), + clientLogEvent.ClientName, + clientLogEvent.ClientVersion, + clientLogEvent.UserId ?? Guid.Empty, + clientLogEvent.DeviceId, + Environment.NewLine, + clientLogEvent.Message); + } + + /// + public async Task WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents) + { + var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; + var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName); + await using var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await fileContents.CopyToAsync(fileStream).ConfigureAwait(false); + return fileName; + } + } +} diff --git a/MediaBrowser.Controller/ClientEvent/IClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/IClientEventLogger.cs new file mode 100644 index 0000000000..34968d493e --- /dev/null +++ b/MediaBrowser.Controller/ClientEvent/IClientEventLogger.cs @@ -0,0 +1,31 @@ +using System.IO; +using System.Threading.Tasks; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.ClientLog; + +namespace MediaBrowser.Controller.ClientEvent +{ + /// + /// The client event logger. + /// + public interface IClientEventLogger + { + /// + /// Logs the event from the client. + /// + /// The client log event. + void Log(ClientLogEvent clientLogEvent); + + /// + /// Writes a file to the log directory. + /// + /// The client name writing the document. + /// The client version writing the document. + /// The file contents to write. + /// The created file name. + Task WriteDocumentAsync( + string clientName, + string clientVersion, + Stream fileContents); + } +} diff --git a/MediaBrowser.Model/ClientLog/ClientLogEvent.cs b/MediaBrowser.Model/ClientLog/ClientLogEvent.cs new file mode 100644 index 0000000000..21087b5647 --- /dev/null +++ b/MediaBrowser.Model/ClientLog/ClientLogEvent.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Model.ClientLog +{ + /// + /// The client log event. + /// + public class ClientLogEvent + { + /// + /// Initializes a new instance of the class. + /// + /// The log timestamp. + /// The log level. + /// The user id. + /// The client name. + /// The client version. + /// The device id. + /// The message. + public ClientLogEvent( + DateTime timestamp, + LogLevel level, + Guid? userId, + string clientName, + string clientVersion, + string deviceId, + string message) + { + Timestamp = timestamp; + UserId = userId; + ClientName = clientName; + ClientVersion = clientVersion; + DeviceId = deviceId; + Message = message; + Level = level; + } + + /// + /// Gets the event timestamp. + /// + public DateTime Timestamp { get; } + + /// + /// Gets the log level. + /// + public LogLevel Level { get; } + + /// + /// Gets the user id. + /// + public Guid? UserId { get; } + + /// + /// Gets the client name. + /// + public string ClientName { get; } + + /// + /// Gets the client version. + /// + public string ClientVersion { get; } + + /// + /// + /// Gets the device id. + /// + public string DeviceId { get; } + + /// + /// Gets the log message. + /// + public string Message { get; } + } +} diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index d1e9996665..b79d18abd8 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -459,5 +459,10 @@ namespace MediaBrowser.Model.Configuration /// Gets or sets a value indicating whether older plugins should automatically be deleted from the plugin folder. /// public bool RemoveOldPlugins { get; set; } + + /// + /// Gets or sets a value indicating whether clients should be allowed to upload logs. + /// + public bool AllowClientLogUpload { get; set; } = true; } }