diff --git a/src/Tgstation.Server.Api/Models/ConfigurationFile.cs b/src/Tgstation.Server.Api/Models/ConfigurationFile.cs index 5703586515..83f952d9ca 100644 --- a/src/Tgstation.Server.Api/Models/ConfigurationFile.cs +++ b/src/Tgstation.Server.Api/Models/ConfigurationFile.cs @@ -1,11 +1,12 @@ using System.ComponentModel.DataAnnotations; +using Tgstation.Server.Api.Models.Internal; namespace Tgstation.Server.Api.Models { /// /// Represents a game configuration file. Create and delete actions uncerimonuously overwrite/delete files /// - public sealed class ConfigurationFile + public sealed class ConfigurationFile : RawData { /// /// The path to the file @@ -27,12 +28,5 @@ namespace Tgstation.Server.Api.Models /// The MD5 hash of the file when last read by the user. If this doesn't match during update actions, the write will be denied with /// public string? LastReadHash { get; set; } - - /// - /// The content of the . Will be if is or during listing and write operations - /// -#pragma warning disable CA1819, SA1011 // Properties should not return arrays, Closing square bracket should be followed by a space - public byte[]? Content { get; set; } -#pragma warning restore CA1819, SA1011 // Properties should not return arrays, Closing square bracket should be followed by a space } } diff --git a/src/Tgstation.Server.Api/Models/Internal/RawData.cs b/src/Tgstation.Server.Api/Models/Internal/RawData.cs new file mode 100644 index 0000000000..4414e9bba0 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/RawData.cs @@ -0,0 +1,15 @@ +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Represents raw bytes. + /// + public abstract class RawData + { + /// + /// The bytes of the . + /// +#pragma warning disable CA1819, SA1011 // Properties should not return arrays, Closing square bracket should be followed by a space + public byte[]? Content { get; set; } +#pragma warning restore CA1819, SA1011 // Properties should not return arrays, Closing square bracket should be followed by a space + } +} diff --git a/src/Tgstation.Server.Api/Models/LogFile.cs b/src/Tgstation.Server.Api/Models/LogFile.cs new file mode 100644 index 0000000000..cdb99832c0 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/LogFile.cs @@ -0,0 +1,21 @@ +using System; +using Tgstation.Server.Api.Models.Internal; + +namespace Tgstation.Server.Api.Models +{ + /// + /// Represents a server log file. + /// + public sealed class LogFile : RawData + { + /// + /// The name of the log file. + /// + public string? Name { get; set; } + + /// + /// The of when the log file was modified. + /// + public DateTimeOffset LastModified { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs index 1d175b25db..c3e24c063f 100644 --- a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs +++ b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs @@ -36,6 +36,11 @@ namespace Tgstation.Server.Api.Rights /// /// User can read info and rights of other users /// - ReadUsers = 16 + ReadUsers = 16, + + /// + /// User can list and download s. + /// + DownloadLogs = 32, } } diff --git a/src/Tgstation.Server.Api/Routes.cs b/src/Tgstation.Server.Api/Routes.cs index 2bb3f4e603..6c31609fba 100644 --- a/src/Tgstation.Server.Api/Routes.cs +++ b/src/Tgstation.Server.Api/Routes.cs @@ -18,6 +18,11 @@ namespace Tgstation.Server.Api /// public const string Administration = Root + nameof(Models.Administration); + /// + /// The controller + /// + public const string Logs = Administration + "/Logs"; + /// /// The controller /// @@ -102,5 +107,19 @@ namespace Tgstation.Server.Api /// The route /// The with /List appended public static string ListRoute(string route) => String.Format(CultureInfo.InvariantCulture, "{0}/{1}", route, List); + + /// + /// Sanitize a path for use in a GET . + /// + /// The path to sanitize. + /// The sanitized path. + public static string SanitizeGetPath(string path) + { + if (path == null) + path = String.Empty; + if (path.Length == 0 || path[0] != '/') + path = '/' + path; + return path; + } } } diff --git a/src/Tgstation.Server.Client/AdministrationClient.cs b/src/Tgstation.Server.Client/AdministrationClient.cs index 1e7b290168..798ee6cf31 100644 --- a/src/Tgstation.Server.Client/AdministrationClient.cs +++ b/src/Tgstation.Server.Client/AdministrationClient.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; @@ -31,5 +32,14 @@ namespace Tgstation.Server.Client /// public Task Restart(CancellationToken cancellationToken) => apiClient.Delete(Routes.Administration, cancellationToken); + + /// + public Task> ListLogs(CancellationToken cancellationToken) => apiClient.Read>(Routes.Logs, cancellationToken); + + /// + public Task GetLog(LogFile logFile, CancellationToken cancellationToken) => apiClient.Read( + Routes.Logs + Routes.SanitizeGetPath( + logFile?.Name ?? throw new ArgumentNullException(nameof(logFile))), + cancellationToken); } } \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/ConfigurationClient.cs b/src/Tgstation.Server.Client/Components/ConfigurationClient.cs index 31e0c2dd20..3134fbcf37 100644 --- a/src/Tgstation.Server.Client/Components/ConfigurationClient.cs +++ b/src/Tgstation.Server.Client/Components/ConfigurationClient.cs @@ -20,20 +20,6 @@ namespace Tgstation.Server.Client.Components /// readonly Instance instance; - /// - /// Sanitize a path for use in a GET - /// - /// The path to sanitize - /// The sanitized path - static string SanitizeGetPath(string path) - { - if (path == null) - path = String.Empty; - if (path.Length == 0 || path[0] != '/') - path = '/' + path; - return path; - } - /// /// Construct a /// @@ -52,7 +38,7 @@ namespace Tgstation.Server.Client.Components public Task CreateDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => apiClient.Create(Routes.Configuration, directory, instance.Id, cancellationToken); /// - public Task> List(string directory, CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Configuration) + SanitizeGetPath(directory), instance.Id, cancellationToken); + public Task> List(string directory, CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Configuration) + Routes.SanitizeGetPath(directory), instance.Id, cancellationToken); /// public Task Read(ConfigurationFile file, CancellationToken cancellationToken) @@ -60,7 +46,7 @@ namespace Tgstation.Server.Client.Components if (file == null) throw new ArgumentNullException(nameof(file)); return apiClient.Read( - Routes.ConfigurationFile + SanitizeGetPath(file.Path ?? throw new ArgumentException("file.Path should not be null!", nameof(file))), + Routes.ConfigurationFile + Routes.SanitizeGetPath(file.Path ?? throw new ArgumentException("file.Path should not be null!", nameof(file))), instance.Id, cancellationToken); } diff --git a/src/Tgstation.Server.Client/IAdministrationClient.cs b/src/Tgstation.Server.Client/IAdministrationClient.cs index a2a954656b..bbffab6f0f 100644 --- a/src/Tgstation.Server.Client/IAdministrationClient.cs +++ b/src/Tgstation.Server.Client/IAdministrationClient.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -30,5 +31,20 @@ namespace Tgstation.Server.Client /// The for the operation /// A representing the running operation Task Restart(CancellationToken cancellationToken); + + /// + /// Lists the log files available for download. + /// + /// The for the operation + /// A resulting in an of metadata. + Task> ListLogs(CancellationToken cancellationToken); + + /// + /// Download a given . + /// + /// The to download. + /// The for the operation + /// A resulting in the downloaded . + Task GetLog(LogFile logFile, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/StaticFiles/IConfiguration.cs b/src/Tgstation.Server.Host/Components/StaticFiles/IConfiguration.cs index 0cd29c0c5c..b57497a4d1 100644 --- a/src/Tgstation.Server.Host/Components/StaticFiles/IConfiguration.cs +++ b/src/Tgstation.Server.Host/Components/StaticFiles/IConfiguration.cs @@ -37,7 +37,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles /// The relative path in the Configuration directory /// The for the operation. If , the operation will be performed as the user of the /// The for the operation - /// A resulting in the s for the items in the directory. and will both be + /// A resulting in the s for the items in the directory. and will both be Task> ListDirectory(string configurationRelativePath, ISystemIdentity systemIdentity, CancellationToken cancellationToken); /// diff --git a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs index 0251efb803..f795ce521c 100644 --- a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs @@ -1,13 +1,16 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Converters; +using System; +using Tgstation.Server.Host.IO; +using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.Configuration { /// /// File logging configuration options /// - sealed class FileLoggingConfiguration + public sealed class FileLoggingConfiguration { /// /// The key for the the resides in @@ -45,5 +48,26 @@ namespace Tgstation.Server.Host.Configuration /// [JsonConverter(typeof(StringEnumConverter))] public LogLevel MicrosoftLogLevel { get; set; } = DefaultMicrosoftLogLevel; + + /// + /// Gets the evaluated log . + /// + /// The to use. + /// The to use. + /// The evaluated log . + public string GetFullLogDirectory(IIOManager ioManager, IAssemblyInformationProvider assemblyInformationProvider) + { + if (ioManager == null) + throw new ArgumentNullException(nameof(ioManager)); + if (assemblyInformationProvider == null) + throw new ArgumentNullException(nameof(assemblyInformationProvider)); + + return !String.IsNullOrEmpty(Directory) + ? Directory + : ioManager.ConcatPath( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), // common app data is C:/ProgramData on windows, else /usr/share + assemblyInformationProvider.VersionPrefix, + "Logs"); + } } } diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index d11c29ba24..8c470d4f59 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -6,6 +6,7 @@ using Octokit; using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Net; using System.Threading; @@ -66,6 +67,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly GeneralConfiguration generalConfiguration; + /// + /// The for the + /// + readonly FileLoggingConfiguration fileLoggingConfiguration; + /// /// Construct an /// @@ -79,6 +85,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the /// The containing value of /// The containing value of + /// The containing value of public AdministrationController( IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, @@ -89,7 +96,8 @@ namespace Tgstation.Server.Host.Controllers IPlatformIdentifier platformIdentifier, ILogger logger, IOptions updatesConfigurationOptions, - IOptions generalConfigurationOptions) + IOptions generalConfigurationOptions, + IOptions fileLoggingConfigurationOptions) : base( databaseContext, authenticationContextFactory, @@ -104,6 +112,7 @@ namespace Tgstation.Server.Host.Controllers this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); updatesConfiguration = updatesConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(updatesConfigurationOptions)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + fileLoggingConfiguration = fileLoggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions)); } ObjectResult RateLimit(RateLimitExceededException exception) @@ -258,7 +267,7 @@ namespace Tgstation.Server.Host.Controllers if (model.NewVersion.Major != assemblyInformationProvider.Version.Major) return BadRequest(new ErrorMessage(ErrorCode.CannotChangeServerSuite)); - if(!serverUpdater.WatchdogPresent) + if (!serverUpdater.WatchdogPresent) return UnprocessableEntity(new ErrorMessage(ErrorCode.MissingHostWatchdog)); return await CheckReleasesAndApplyUpdate(model.NewVersion, cancellationToken).ConfigureAwait(false); @@ -292,5 +301,88 @@ namespace Tgstation.Server.Host.Controllers return StatusCode((int)HttpStatusCode.ServiceUnavailable); } } + + /// + /// List s present. + /// + /// The for the operation. + /// A resulting in the of the request. + /// Listed logs successfully. + /// An IO error occurred while listing. + [HttpGet(Routes.Logs)] + [TgsAuthorize(AdministrationRights.DownloadLogs)] + [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(ErrorMessage), 409)] + public async Task ListLogs(CancellationToken cancellationToken) + { + var path = fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider); + try + { + var files = await ioManager.GetFiles(path, cancellationToken).ConfigureAwait(false); + var tasks = files.Select( + async file => new LogFile + { + Name = ioManager.GetFileName(file), + LastModified = await ioManager.GetLastModified( + ioManager.ConcatPath(path, file), + cancellationToken) + .ConfigureAwait(false) + }) + .ToList(); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + var result = tasks.Select(x => x.Result).ToList(); + + return Ok(result); + } + catch (IOException ex) + { + return Conflict(new ErrorMessage(ErrorCode.IOError) + { + AdditionalData = ex.ToString() + }); + } + } + + /// + /// Download a . + /// + /// The path to download. + /// The for the operation. + /// A resulting in the of the request. + /// Downloaded successfully. + /// An IO error occurred while downloading. + [HttpGet(Routes.Logs + "/{path}")] + [TgsAuthorize(AdministrationRights.DownloadLogs)] + [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(ErrorMessage), 409)] + public async Task GetLog(string path, CancellationToken cancellationToken) + { + if (path == null) + throw new ArgumentNullException(nameof(path)); + + var fullPath = ioManager.ConcatPath( + fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider), + path); + try + { + var readTask = ioManager.ReadAllBytes(fullPath, cancellationToken); + + return Ok(new LogFile + { + Name = path, + LastModified = await ioManager.GetLastModified(fullPath, cancellationToken).ConfigureAwait(false), + Content = await readTask.ConfigureAwait(false) + }); + } + catch (IOException ex) + { + return Conflict(new ErrorMessage(ErrorCode.IOError) + { + AdditionalData = ex.ToString() + }); + } + } } } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 87b73cd372..9c886a536f 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -122,13 +122,7 @@ namespace Tgstation.Server.Host.Core if (postSetupServices.FileLoggingConfiguration.Disable) return; - // common app data is C:/ProgramData on windows, else /usr/share - var logPath = !String.IsNullOrEmpty(postSetupServices.FileLoggingConfiguration.Directory) - ? postSetupServices.FileLoggingConfiguration.Directory - : IOManager.ConcatPath( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - AssemblyInformationProvider.VersionPrefix, - "Logs"); + var logPath = postSetupServices.FileLoggingConfiguration.GetFullLogDirectory(IOManager, AssemblyInformationProvider); var logEventLevel = ConvertSeriLogLevel(postSetupServices.FileLoggingConfiguration.LogLevel); diff --git a/src/Tgstation.Server.Host/IO/DefaultIOManager.cs b/src/Tgstation.Server.Host/IO/DefaultIOManager.cs index 73c1a9cfc4..ad7beb4c35 100644 --- a/src/Tgstation.Server.Host/IO/DefaultIOManager.cs +++ b/src/Tgstation.Server.Host/IO/DefaultIOManager.cs @@ -222,7 +222,7 @@ namespace Tgstation.Server.Host.IO public async Task ReadAllBytes(string path, CancellationToken cancellationToken) { path = ResolvePath(path); - using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, DefaultBufferSize, true); + using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, DefaultBufferSize, true); byte[] buf; buf = new byte[file.Length]; await file.ReadAsync(buf, 0, (int)file.Length, cancellationToken).ConfigureAwait(false); @@ -311,5 +311,13 @@ namespace Tgstation.Server.Host.IO /// public bool PathContainsParentAccess(string path) => path?.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }).Any(x => x == "..") ?? throw new ArgumentNullException(nameof(path)); + + /// + public Task GetLastModified(string path, CancellationToken cancellationToken) => Task.Factory.StartNew(() => + { + path = ResolvePath(path ?? throw new ArgumentNullException(nameof(path))); + var fileInfo = new FileInfo(path); + return new DateTimeOffset(fileInfo.LastWriteTimeUtc); + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); } } diff --git a/src/Tgstation.Server.Host/IO/IIOManager.cs b/src/Tgstation.Server.Host/IO/IIOManager.cs index a22b4d94aa..d18e117864 100644 --- a/src/Tgstation.Server.Host/IO/IIOManager.cs +++ b/src/Tgstation.Server.Host/IO/IIOManager.cs @@ -194,5 +194,13 @@ namespace Tgstation.Server.Host.IO /// The for the operation /// A representing the running operation Task ZipToDirectory(string path, byte[] zipFileBytes, CancellationToken cancellationToken); + + /// + /// Get the of when a given was last modified. + /// + /// The path to get metadata for. + /// The for the operation. + /// A resulting in the of when the file was last modified. + Task GetLastModified(string path, CancellationToken cancellationToken); } } diff --git a/tests/Tgstation.Server.Tests/AdministrationTest.cs b/tests/Tgstation.Server.Tests/AdministrationTest.cs index 1e0583424e..740c5c1d9b 100644 --- a/tests/Tgstation.Server.Tests/AdministrationTest.cs +++ b/tests/Tgstation.Server.Tests/AdministrationTest.cs @@ -1,5 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; +using System.Linq; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -19,7 +20,29 @@ namespace Tgstation.Server.Tests public async Task Run(CancellationToken cancellationToken) { + var logsTest = TestLogs(cancellationToken); await TestRead(cancellationToken).ConfigureAwait(false); + await logsTest; + } + + async Task TestLogs(CancellationToken cancellationToken) + { + var logs = await client.ListLogs(cancellationToken); + Assert.AreEqual(1, logs.Count); + var logFile = logs.Single(); + Assert.IsNotNull(logFile); + Assert.IsFalse(String.IsNullOrWhiteSpace(logFile.Name)); + Assert.IsNull(logFile.Content); + + var downloaded = await client.GetLog(logFile, cancellationToken); + Assert.AreEqual(logFile.Name, downloaded.Name); + Assert.IsTrue(logFile.LastModified <= downloaded.LastModified); + Assert.IsNull(logFile.Content); + + await ApiAssert.ThrowsException(() => client.GetLog(new LogFile + { + Name = "very_fake_path.log" + }, cancellationToken), ErrorCode.IOError); } async Task TestRead(CancellationToken cancellationToken) diff --git a/tests/Tgstation.Server.Tests/TestingServer.cs b/tests/Tgstation.Server.Tests/TestingServer.cs index c420bebd7f..afe9b340d7 100644 --- a/tests/Tgstation.Server.Tests/TestingServer.cs +++ b/tests/Tgstation.Server.Tests/TestingServer.cs @@ -79,6 +79,7 @@ namespace Tgstation.Server.Tests String.Format(CultureInfo.InvariantCulture, "General:MinimumPasswordLength={0}", 10), String.Format(CultureInfo.InvariantCulture, "General:InstanceLimit={0}", 11), String.Format(CultureInfo.InvariantCulture, "General:UserLimit={0}", 150), + String.Format(CultureInfo.InvariantCulture, "FileLogging:Directory={0}", Path.Combine(Directory, "Logs")), String.Format(CultureInfo.InvariantCulture, "General:ValidInstancePaths:0={0}", Directory), "General:ByondTopicTimeout=3000" };