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"
};