mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-08-27 07:04:57 +01:00
Merge pull request #1153 from tgstation/1064-ActualFileStreams
Replaces base64 file transfers with streaming
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"no_summary": "error",
|
||||
"no_array_responses": "off",
|
||||
"parameter_order": "error",
|
||||
"undefined_tag": "off",
|
||||
"unused_tag": "error",
|
||||
"operation_id_naming_convention": "off"
|
||||
},
|
||||
@@ -44,12 +45,15 @@
|
||||
"no_property_description": "off",
|
||||
"description_mentions_json": "error",
|
||||
"array_of_arrays": "error",
|
||||
"inconsistent_property_type": "error",
|
||||
"property_case_convention": "off",
|
||||
"enum_case_convention": "error"
|
||||
"property_case_collision": "error",
|
||||
"enum_case_convention": "error",
|
||||
"undefined_required_properties": "error"
|
||||
},
|
||||
"walker": {
|
||||
"no_empty_descriptions": "error",
|
||||
"has_circular_references": "off",
|
||||
"has_circular_references": "error",
|
||||
"$ref_siblings": "error",
|
||||
"duplicate_sibling_description": "error",
|
||||
"incorrect_ref_pattern": "error"
|
||||
@@ -76,7 +80,11 @@
|
||||
"responses": {
|
||||
"no_response_codes": "error",
|
||||
"no_success_response_codes": "error",
|
||||
"no_response_body": "error",
|
||||
"ibm_status_code_guidelines": "off"
|
||||
},
|
||||
"schemas": {
|
||||
"json_or_param_binary_string": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -3,10 +3,10 @@
|
||||
<!-- Integration tests will ensure they match across the board -->
|
||||
<Import Project="ControlPanelVersion.props" />
|
||||
<PropertyGroup>
|
||||
<TgsCoreVersion>4.6.0</TgsCoreVersion>
|
||||
<TgsConfigVersion>2.1.1</TgsConfigVersion>
|
||||
<TgsApiVersion>7.4.0</TgsApiVersion>
|
||||
<TgsClientVersion>8.4.0</TgsClientVersion>
|
||||
<TgsCoreVersion>4.7.0</TgsCoreVersion>
|
||||
<TgsConfigVersion>2.2.0</TgsConfigVersion>
|
||||
<TgsApiVersion>8.0.0</TgsApiVersion>
|
||||
<TgsClientVersion>9.0.0</TgsClientVersion>
|
||||
<TgsDmapiVersion>5.2.9</TgsDmapiVersion>
|
||||
<TgsHostWatchdogVersion>1.1.0</TgsHostWatchdogVersion>
|
||||
<TgsContainerScriptVersion>1.2.0</TgsContainerScriptVersion>
|
||||
|
||||
@@ -162,6 +162,20 @@ I GET "/InstanceUser" => @ref Tgstation.Server.Api.Models.InstanceUser
|
||||
|
||||
See individual documentation of each permission enum for their usage
|
||||
|
||||
@section api_transfer File Transfers
|
||||
|
||||
Certain responses inherit from @ref Tgstation.Server.Api.Models.FileTicketResult. These are special in that, having that model's @ref Tgstation.Server.Api.Models.FileTicketResult.FileTicket field populated indicates there is a pending file download or upload to take place. These are handled in the "/Transfer" endpoint.
|
||||
|
||||
To perform a file download make the following request:
|
||||
|
||||
GET "/Transfer?ticket=<@ref Tgstation.Server.Api.Models.FileTicketResult.FileTicket>" => application/octet-stream
|
||||
|
||||
To perform a file upload make the following request:
|
||||
|
||||
PUT "/Transfer?ticket=<@ref Tgstation.Server.Api.Models.FileTicketResult.FileTicket>" application/octet-stream => OK
|
||||
|
||||
File tickets are only valid for a short time after the initial request is made and should be dealt with immediately. Ensure that the file tickets are properly URL encoded.
|
||||
|
||||
@section api_user User Management
|
||||
|
||||
TGS start with one user: "Admin". The password is "ISolemlySwearToDeleteTheDataDirectory"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
using System;
|
||||
using Tgstation.Server.Api.Models.Internal;
|
||||
using System;
|
||||
|
||||
namespace Tgstation.Server.Api.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a BYOND installation. <see cref="RawData.Content"/> is used to upload custom BYOND version zip files, though <see cref="Version"/> must still be set.
|
||||
/// Represents a BYOND installation. <see cref="FileTicketResult.FileTicket"/> is used to upload custom BYOND version zip files, though <see cref="Version"/> must still be set.
|
||||
/// </summary>
|
||||
public sealed class Byond : RawData
|
||||
public sealed class Byond : FileTicketResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="System.Version"/> of the <see cref="Byond"/> installation used for new compiles. Will be <see langword="null"/> if the user does not have permission to view it or there is no BYOND version installed. Only considers the <see cref="Version.Major"/> and <see cref="Version.Minor"/> numbers.
|
||||
@@ -17,5 +16,10 @@ namespace Tgstation.Server.Api.Models
|
||||
/// The <see cref="Job"/> being used to install a new <see cref="Version"/>
|
||||
/// </summary>
|
||||
public Job? InstallJob { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If a custom BYOND version is to be uploaded.
|
||||
/// </summary>
|
||||
public bool? UploadCustomZip { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Tgstation.Server.Api.Models.Internal;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Tgstation.Server.Api.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a game configuration file. Create and delete actions uncerimonuously overwrite/delete files
|
||||
/// </summary>
|
||||
public sealed class ConfigurationFile : RawData
|
||||
public sealed class ConfigurationFile : FileTicketResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The path to the <see cref="ConfigurationFile"/> file
|
||||
|
||||
@@ -166,7 +166,7 @@ namespace Tgstation.Server.Api.Models
|
||||
RequiresPosixSystemIdentity,
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ConfigurationFile"/> was attem updated
|
||||
/// A <see cref="ConfigurationFile"/> was updated.
|
||||
/// </summary>
|
||||
[Description("This existing file hash does not match, the file has beeen updated!")]
|
||||
ConfigurationFileUpdated,
|
||||
@@ -594,5 +594,11 @@ namespace Tgstation.Server.Api.Models
|
||||
/// </summary>
|
||||
[Description("The requested OAuth provider is disabled via configuration!")]
|
||||
OAuthProviderDisabled,
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="Job"/> requiring a file upload did not receive it before timing out.
|
||||
/// </summary>
|
||||
[Description("The job did not receive a required upload before timing out!")]
|
||||
FileUploadExpired,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Tgstation.Server.Api.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Response for when file transfers are necessary.
|
||||
/// </summary>
|
||||
public class FileTicketResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The ticket to use to access the <see cref="Routes.Transfer"/> controller.
|
||||
/// </summary>
|
||||
public string? FileTicket { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace Tgstation.Server.Api.Models.Internal
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents raw bytes.
|
||||
/// </summary>
|
||||
public abstract class RawData
|
||||
{
|
||||
/// <summary>
|
||||
/// The bytes of the <see cref="RawData"/>.
|
||||
/// </summary>
|
||||
#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
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Tgstation.Server.Api.Models
|
||||
{
|
||||
/// <summary>
|
||||
@@ -19,5 +21,10 @@ namespace Tgstation.Server.Api.Models
|
||||
/// Length limit for git commit SHAs.
|
||||
/// </summary>
|
||||
public const int MaximumCommitShaLength = 40;
|
||||
|
||||
/// <summary>
|
||||
/// The maximum size for file transfers.
|
||||
/// </summary>
|
||||
public const int MaximumFileTransferSize = Int32.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
using System;
|
||||
using Tgstation.Server.Api.Models.Internal;
|
||||
|
||||
namespace Tgstation.Server.Api.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a server log file.
|
||||
/// </summary>
|
||||
public sealed class LogFile : RawData
|
||||
public sealed class LogFile : FileTicketResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the log file.
|
||||
|
||||
@@ -88,6 +88,11 @@ namespace Tgstation.Server.Api
|
||||
/// </summary>
|
||||
public const string Jobs = Root + nameof(Models.Job);
|
||||
|
||||
/// <summary>
|
||||
/// The transfer controller.
|
||||
/// </summary>
|
||||
public const string Transfer = Root + "Transfer";
|
||||
|
||||
/// <summary>
|
||||
/// The postfix for list operations
|
||||
/// </summary>
|
||||
@@ -109,7 +114,7 @@ namespace Tgstation.Server.Api
|
||||
public static string ListRoute(string route) => String.Format(CultureInfo.InvariantCulture, "{0}/{1}", route, List);
|
||||
|
||||
/// <summary>
|
||||
/// Sanitize a <see cref="Models.Internal.RawData"/> path for use in a GET <see cref="Uri"/>.
|
||||
/// Sanitize a <see cref="Models.FileTicketResult"/> path for use in a GET <see cref="Uri"/>.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to sanitize.</param>
|
||||
/// <returns>The sanitized path.</returns>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
@@ -38,10 +39,25 @@ namespace Tgstation.Server.Client
|
||||
public Task<IReadOnlyList<LogFile>> ListLogs(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<LogFile>>(Routes.Logs, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<LogFile> GetLog(LogFile logFile, CancellationToken cancellationToken) => apiClient.Read<LogFile>(
|
||||
Routes.Logs + Routes.SanitizeGetPath(
|
||||
HttpUtility.UrlEncode(
|
||||
logFile?.Name ?? throw new ArgumentNullException(nameof(logFile)))),
|
||||
cancellationToken);
|
||||
public async Task<Tuple<LogFile, Stream>> GetLog(LogFile logFile, CancellationToken cancellationToken)
|
||||
{
|
||||
var resultFile = await apiClient.Read<LogFile>(
|
||||
Routes.Logs + Routes.SanitizeGetPath(
|
||||
HttpUtility.UrlEncode(
|
||||
logFile?.Name ?? throw new ArgumentNullException(nameof(logFile)))),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var stream = await apiClient.Download(resultFile, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return Tuple.Create(resultFile, stream);
|
||||
}
|
||||
catch
|
||||
{
|
||||
stream.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Tgstation.Server.Api;
|
||||
using Tgstation.Server.Api.Models;
|
||||
|
||||
@@ -36,7 +39,7 @@ namespace Tgstation.Server.Client
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="HttpClient"/> for the <see cref="ApiClient"/>
|
||||
/// The <see cref="HttpClientImplementation"/> for the <see cref="ApiClient"/>
|
||||
/// </summary>
|
||||
readonly IHttpClient httpClient;
|
||||
|
||||
@@ -143,38 +146,92 @@ namespace Tgstation.Server.Client
|
||||
/// <param name="tokenRefresh">If this is a token refresh operation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the response on success</returns>
|
||||
async Task<TResult> RunRequest<TResult>(string route, object? body, HttpMethod method, long? instanceId, bool tokenRefresh, CancellationToken cancellationToken)
|
||||
Task<TResult> RunRequest<TResult>(
|
||||
string route,
|
||||
object? body,
|
||||
HttpMethod method,
|
||||
long? instanceId,
|
||||
bool tokenRefresh,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
HttpContent? content = null;
|
||||
if(body != null)
|
||||
content = new StringContent(
|
||||
JsonConvert.SerializeObject(body, GetSerializerSettings()),
|
||||
Encoding.UTF8,
|
||||
MediaTypeNames.Application.Json);
|
||||
|
||||
return RunRequest<TResult>(
|
||||
route,
|
||||
content,
|
||||
method,
|
||||
instanceId,
|
||||
tokenRefresh,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main request method
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The resulting POCO type</typeparam>
|
||||
/// <param name="route">The route to run</param>
|
||||
/// <param name="content">The <see cref="HttpContent"/> of the request if any.</param>
|
||||
/// <param name="method">The method of the request</param>
|
||||
/// <param name="instanceId">The optional instance <see cref="EntityId.Id"/> for the request</param>
|
||||
/// <param name="tokenRefresh">If this is a token refresh operation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the response on success</returns>
|
||||
async Task<TResult> RunRequest<TResult>(
|
||||
string route,
|
||||
HttpContent? content,
|
||||
HttpMethod method,
|
||||
long? instanceId,
|
||||
bool tokenRefresh,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (route == null)
|
||||
throw new ArgumentNullException(nameof(route));
|
||||
if (method == null)
|
||||
throw new ArgumentNullException(nameof(method));
|
||||
if (body == null && (method == HttpMethod.Post || method == HttpMethod.Put))
|
||||
throw new InvalidOperationException("Body cannot be null for POST or PUT!");
|
||||
if (content == null && (method == HttpMethod.Post || method == HttpMethod.Put))
|
||||
throw new InvalidOperationException("content cannot be null for POST or PUT!");
|
||||
|
||||
HttpResponseMessage response;
|
||||
var fullUri = new Uri(Url, route);
|
||||
var serializerSettings = GetSerializerSettings();
|
||||
var fileDownload = typeof(TResult) == typeof(Stream);
|
||||
using (var request = new HttpRequestMessage(method, fullUri))
|
||||
{
|
||||
if (body != null)
|
||||
request.Content = new StringContent(
|
||||
JsonConvert.SerializeObject(body, serializerSettings),
|
||||
Encoding.UTF8,
|
||||
MediaTypeNames.Application.Json);
|
||||
if (content != null)
|
||||
request.Content = content;
|
||||
|
||||
var headersToUse = tokenRefresh ? tokenRefreshHeaders! : headers;
|
||||
headersToUse.SetRequestHeaders(request.Headers, instanceId);
|
||||
|
||||
if (fileDownload)
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Octet));
|
||||
|
||||
await Task.WhenAll(requestLoggers.Select(x => x.LogRequest(request, cancellationToken))).ConfigureAwait(false);
|
||||
|
||||
response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using (response)
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(requestLoggers.Select(x => x.LogResponse(response, cancellationToken))).ConfigureAwait(false);
|
||||
|
||||
// just stream
|
||||
if (fileDownload && response.IsSuccessStatusCode)
|
||||
return (TResult)(object)await CachedResponseStream.Create(response).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
response.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -182,7 +239,7 @@ namespace Tgstation.Server.Client
|
||||
if (!tokenRefresh
|
||||
&& response.StatusCode == HttpStatusCode.Unauthorized
|
||||
&& await RefreshToken(cancellationToken).ConfigureAwait(false))
|
||||
return await RunRequest<TResult>(route, body, method, instanceId, false, cancellationToken).ConfigureAwait(false);
|
||||
return await RunRequest<TResult>(route, content, method, instanceId, false, cancellationToken).ConfigureAwait(false);
|
||||
HandleBadResponse(response, json);
|
||||
}
|
||||
|
||||
@@ -277,5 +334,41 @@ namespace Tgstation.Server.Client
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddRequestLogger(IRequestLogger requestLogger) => requestLoggers.Add(requestLogger ?? throw new ArgumentNullException(nameof(requestLogger)));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> Download(FileTicketResult ticket, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ticket == null)
|
||||
throw new ArgumentNullException(nameof(ticket));
|
||||
|
||||
return RunRequest<Stream>(
|
||||
$"{Routes.Transfer}?ticket={HttpUtility.UrlEncode(ticket.FileTicket)}",
|
||||
null,
|
||||
HttpMethod.Get,
|
||||
null,
|
||||
false,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Upload(FileTicketResult ticket, Stream? uploadStream, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ticket == null)
|
||||
throw new ArgumentNullException(nameof(ticket));
|
||||
|
||||
MemoryStream? memoryStream = null;
|
||||
if (uploadStream == null)
|
||||
memoryStream = new MemoryStream();
|
||||
|
||||
using (memoryStream)
|
||||
await RunRequest<object>(
|
||||
$"{Routes.Transfer}?ticket={HttpUtility.UrlEncode(ticket.FileTicket)}",
|
||||
new StreamContent(uploadStream ?? memoryStream),
|
||||
HttpMethod.Put,
|
||||
null,
|
||||
false,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ namespace Tgstation.Server.Client
|
||||
sealed class ApiClientFactory : IApiClientFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders) => new ApiClient(new HttpClient(), url, apiHeaders, tokenRefreshHeaders);
|
||||
public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders) => new ApiClient(new HttpClientImplementation(), url, apiHeaders, tokenRefreshHeaders);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Tgstation.Server.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Caches the <see cref="Stream"/> from a <see cref="HttpResponseMessage"/> for later use.
|
||||
/// </summary>
|
||||
sealed class CachedResponseStream : Stream
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="HttpResponseMessage"/> for the <see cref="CachedResponseStream"/>.
|
||||
/// </summary>
|
||||
readonly HttpResponseMessage response;
|
||||
|
||||
/// <summary>
|
||||
/// The reponse content <see cref="Stream"/>.
|
||||
/// </summary>
|
||||
readonly Stream responseStream;
|
||||
|
||||
/// <summary>
|
||||
/// Asyncronously creates a new <see cref="CachedResponseStream"/>.
|
||||
/// </summary>
|
||||
/// <param name="response">The <see cref="HttpResponseMessage"/> to build from.</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in a new <see cref="CachedResponseStream"/>.</returns>
|
||||
public static async Task<CachedResponseStream> Create(HttpResponseMessage response)
|
||||
{
|
||||
var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
return new CachedResponseStream(response, stream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CachedResponseStream"/> <see langword="class"/>.
|
||||
/// </summary>
|
||||
/// <param name="response">The value of <see cref="response"/>.</param>
|
||||
/// <param name="responseStream">The value of <see cref="responseStream"/>.</param>
|
||||
CachedResponseStream(HttpResponseMessage response, Stream responseStream)
|
||||
{
|
||||
this.response = response;
|
||||
this.responseStream = responseStream;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (!disposing)
|
||||
return;
|
||||
responseStream.Dispose();
|
||||
response.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await base.DisposeAsync().ConfigureAwait(false);
|
||||
await responseStream.DisposeAsync().ConfigureAwait(false);
|
||||
response.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => responseStream.CanRead;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => responseStream.CanSeek;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => responseStream.CanWrite;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Length => responseStream.Length;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Position
|
||||
{
|
||||
get => responseStream.Position;
|
||||
set => responseStream.Position = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush() => responseStream.Flush();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count) => responseStream.Read(buffer, offset, count);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin) => responseStream.Seek(offset, origin);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value) => responseStream.SetLength(value);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(byte[] buffer, int offset, int count) => responseStream.Write(buffer, offset, count);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api;
|
||||
@@ -38,6 +39,19 @@ namespace Tgstation.Server.Client.Components
|
||||
public Task<IReadOnlyList<Byond>> InstalledVersions(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<Byond>>(Routes.ListRoute(Routes.Byond), instance.Id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Byond> SetActiveVersion(Byond byond, CancellationToken cancellationToken) => apiClient.Update<Byond, Byond>(Routes.Byond, byond ?? throw new ArgumentNullException(nameof(byond)), instance.Id, cancellationToken);
|
||||
public async Task<Byond> SetActiveVersion(Byond byond, Stream zipFileStream, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await apiClient.Update<Byond, Byond>(
|
||||
Routes.Byond,
|
||||
byond ?? throw new ArgumentNullException(nameof(byond)),
|
||||
instance.Id,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (byond.UploadCustomZip == true)
|
||||
await apiClient.Upload(result, zipFileStream, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api;
|
||||
@@ -41,17 +42,56 @@ namespace Tgstation.Server.Client.Components
|
||||
public Task<IReadOnlyList<ConfigurationFile>> List(string directory, CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<ConfigurationFile>>(Routes.ListRoute(Routes.Configuration) + Routes.SanitizeGetPath(directory), instance.Id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConfigurationFile> Read(ConfigurationFile file, CancellationToken cancellationToken)
|
||||
public async Task<Tuple<ConfigurationFile, Stream>> Read(ConfigurationFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file == null)
|
||||
throw new ArgumentNullException(nameof(file));
|
||||
return apiClient.Read<ConfigurationFile>(
|
||||
var configFile = await apiClient.Read<ConfigurationFile>(
|
||||
Routes.ConfigurationFile + Routes.SanitizeGetPath(file.Path ?? throw new ArgumentException("file.Path should not be null!", nameof(file))),
|
||||
instance.Id,
|
||||
cancellationToken);
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var downloadStream = await apiClient.Download(configFile, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return Tuple.Create(configFile, downloadStream);
|
||||
}
|
||||
catch
|
||||
{
|
||||
downloadStream.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConfigurationFile> Write(ConfigurationFile file, CancellationToken cancellationToken) => apiClient.Update<ConfigurationFile, ConfigurationFile>(Routes.Configuration, file ?? throw new ArgumentNullException(nameof(file)), instance.Id, cancellationToken);
|
||||
public async Task<ConfigurationFile> Write(ConfigurationFile file, Stream uploadStream, CancellationToken cancellationToken)
|
||||
{
|
||||
long initialStreamPosition = 0;
|
||||
MemoryStream? memoryStream = null;
|
||||
if (uploadStream?.CanSeek == false)
|
||||
memoryStream = new MemoryStream();
|
||||
else if (uploadStream != null)
|
||||
initialStreamPosition = uploadStream.Position;
|
||||
|
||||
using (memoryStream)
|
||||
{
|
||||
var configFileTask = apiClient.Update<ConfigurationFile, ConfigurationFile>(
|
||||
Routes.Configuration,
|
||||
file ?? throw new ArgumentNullException(nameof(file)),
|
||||
instance.Id,
|
||||
cancellationToken);
|
||||
|
||||
if (memoryStream != null)
|
||||
await uploadStream!.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var configFile = await configFileTask.ConfigureAwait(false);
|
||||
|
||||
var streamUsed = memoryStream ?? uploadStream;
|
||||
streamUsed?.Seek(initialStreamPosition, SeekOrigin.Begin);
|
||||
await apiClient.Upload(configFile, streamUsed, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return configFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api.Models;
|
||||
@@ -28,8 +29,9 @@ namespace Tgstation.Server.Client.Components
|
||||
/// Updates the <see cref="Byond"/> information
|
||||
/// </summary>
|
||||
/// <param name="byond">The <see cref="Byond"/> information to update</param>
|
||||
/// <param name="zipFileStream">The <see cref="Stream"/> for the .zip file if <see cref="Byond.UploadCustomZip"/> is <see langword="true"/>.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the updated <see cref="Byond"/> information</returns>
|
||||
Task<Byond> SetActiveVersion(Byond byond, CancellationToken cancellationToken);
|
||||
Task<Byond> SetActiveVersion(Byond byond, Stream zipFileStream, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api.Models;
|
||||
@@ -23,16 +25,17 @@ namespace Tgstation.Server.Client.Components
|
||||
/// </summary>
|
||||
/// <param name="file">The <see cref="ConfigurationFile"/> file to read</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
Task<ConfigurationFile> Read(ConfigurationFile file, CancellationToken cancellationToken);
|
||||
/// <returns>A <see cref="Task"/> resulting in a <see cref="Tuple{T1, T2}"/> containing the <see cref="ConfigurationFile"/> and downloaded <see cref="FileTicketResult"/> <see cref="Stream"/>.</returns>
|
||||
Task<Tuple<ConfigurationFile, Stream>> Read(ConfigurationFile file, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Overwrite a <see cref="ConfigurationFile"/> file
|
||||
/// </summary>
|
||||
/// <param name="file">The <see cref="ConfigurationFile"/> file to write</param>
|
||||
/// <param name="uploadStream">The <see cref="Stream"/> of uploaded data. If <see langword="null"/>, a delete will be attempted.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the new <see cref="ConfigurationFile"/></returns>
|
||||
Task<ConfigurationFile> Write(ConfigurationFile file, CancellationToken cancellationToken);
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the new <see cref="ConfigurationFile"/>.</returns>
|
||||
Task<ConfigurationFile> Write(ConfigurationFile file, Stream uploadStream, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Delete an empty <paramref name="directory"/>
|
||||
|
||||
+7
-7
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -6,7 +6,7 @@ using System.Threading.Tasks;
|
||||
namespace Tgstation.Server.Client
|
||||
{
|
||||
/// <inheritdoc />
|
||||
sealed class HttpClient : IHttpClient
|
||||
sealed class HttpClientImplementation : IHttpClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public TimeSpan Timeout
|
||||
@@ -16,16 +16,16 @@ namespace Tgstation.Server.Client
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The real <see cref="System.Net.Http.HttpClient"/>
|
||||
/// The real <see cref="HttpClient"/>
|
||||
/// </summary>
|
||||
readonly System.Net.Http.HttpClient httpClient;
|
||||
readonly HttpClient httpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Construct an <see cref="HttpClient"/>
|
||||
/// Construct an <see cref="HttpClientImplementation"/>
|
||||
/// </summary>
|
||||
public HttpClient()
|
||||
public HttpClientImplementation()
|
||||
{
|
||||
httpClient = new System.Net.Http.HttpClient();
|
||||
httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api.Models;
|
||||
@@ -44,7 +46,7 @@ namespace Tgstation.Server.Client
|
||||
/// </summary>
|
||||
/// <param name="logFile">The <see cref="LogFile"/> to download.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the downloaded <see cref="LogFile"/>.</returns>
|
||||
Task<LogFile> GetLog(LogFile logFile, CancellationToken cancellationToken);
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting a <see cref="Tuple{T1, T2}"/> containing the downloaded <see cref="LogFile"/> and associated <see cref="Stream"/>.</returns>
|
||||
Task<Tuple<LogFile, Stream>> GetLog(LogFile logFile, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api;
|
||||
using Tgstation.Server.Api.Models;
|
||||
|
||||
namespace Tgstation.Server.Client
|
||||
{
|
||||
@@ -113,7 +115,7 @@ namespace Tgstation.Server.Client
|
||||
/// <typeparam name="TResult">The type of the response body</typeparam>
|
||||
/// <param name="route">The server route to make the request to</param>
|
||||
/// <param name="body">The request body</param>
|
||||
/// <param name="instanceId">The instance <see cref="Api.Models.EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the response body as a <typeparamref name="TResult"/></returns>
|
||||
Task<TResult> Create<TBody, TResult>(string route, TBody body, long instanceId, CancellationToken cancellationToken);
|
||||
@@ -123,7 +125,7 @@ namespace Tgstation.Server.Client
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type of the response body</typeparam>
|
||||
/// <param name="route">The server route to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="Api.Models.EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the response body as a <typeparamref name="TResult"/></returns>
|
||||
Task<TResult> Create<TResult>(string route, long instanceId, CancellationToken cancellationToken);
|
||||
@@ -133,7 +135,7 @@ namespace Tgstation.Server.Client
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type of the response body</typeparam>
|
||||
/// <param name="route">The server route to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="Api.Models.EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the response body as a <typeparamref name="TResult"/></returns>
|
||||
Task<TResult> Patch<TResult>(string route, long instanceId, CancellationToken cancellationToken);
|
||||
@@ -143,7 +145,7 @@ namespace Tgstation.Server.Client
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type of the response body</typeparam>
|
||||
/// <param name="route">The server route to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="Api.Models.EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the response body as a <typeparamref name="TResult"/></returns>
|
||||
Task<TResult> Read<TResult>(string route, long instanceId, CancellationToken cancellationToken);
|
||||
@@ -155,7 +157,7 @@ namespace Tgstation.Server.Client
|
||||
/// <typeparam name="TResult">The type of the response body</typeparam>
|
||||
/// <param name="route">The server route to make the request to</param>
|
||||
/// <param name="body">The request body</param>
|
||||
/// <param name="instanceId">The instance <see cref="Api.Models.EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the response body as a <typeparamref name="TResult"/></returns>
|
||||
Task<TResult> Update<TBody, TResult>(string route, TBody body, long instanceId, CancellationToken cancellationToken);
|
||||
@@ -164,7 +166,7 @@ namespace Tgstation.Server.Client
|
||||
/// Run an HTTP DELETE request
|
||||
/// </summary>
|
||||
/// <param name="route">The server route to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="Api.Models.EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
Task Delete(string route, long instanceId, CancellationToken cancellationToken);
|
||||
@@ -175,7 +177,7 @@ namespace Tgstation.Server.Client
|
||||
/// <typeparam name="TBody">The type to of the request body</typeparam>
|
||||
/// <param name="route">The server route to make the request to</param>
|
||||
/// <param name="body">The request body</param>
|
||||
/// <param name="instanceId">The instance <see cref="Api.Models.EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
Task Delete<TBody>(string route, TBody body, long instanceId, CancellationToken cancellationToken);
|
||||
@@ -185,9 +187,26 @@ namespace Tgstation.Server.Client
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type of the response body</typeparam>
|
||||
/// <param name="route">The server route to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="Api.Models.EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="instanceId">The instance <see cref="EntityId.Id"/> to make the request to</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the response body as a <typeparamref name="TResult"/></returns>
|
||||
Task<TResult> Delete<TResult>(string route, long instanceId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a file <see cref="Stream"/> for a given <paramref name="ticket"/>.
|
||||
/// </summary>
|
||||
/// <param name="ticket">The <see cref="FileTicketResult"/> to download.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the downloaded <see cref="Stream"/>.</returns>
|
||||
Task<Stream> Download(FileTicketResult ticket, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a given <paramref name="uploadStream"/> for a given <paramref name="ticket"/>.
|
||||
/// </summary>
|
||||
/// <param name="ticket">The <see cref="FileTicketResult"/> to download.</param>
|
||||
/// <param name="uploadStream">The <see cref="Stream"/> to upload. <see langword="null"/> represents an empty file.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
|
||||
Task Upload(FileTicketResult ticket, Stream? uploadStream, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Tgstation.Server.Tests")]
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
@@ -121,10 +122,10 @@ namespace Tgstation.Server.Host.Components.Byond
|
||||
/// Installs a BYOND <paramref name="version"/> if it isn't already
|
||||
/// </summary>
|
||||
/// <param name="version">The BYOND <see cref="Version"/> to install</param>
|
||||
/// <param name="versionZipBytes">Custom zip file bytes to use. Will cause a <see cref="Version.Build"/> number to be added.</param>
|
||||
/// <param name="customVersionStream">Custom zip file <see cref="Stream"/> to use. Will cause a <see cref="Version.Build"/> number to be added.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
async Task<string> InstallVersion(Version version, byte[] versionZipBytes, CancellationToken cancellationToken)
|
||||
async Task<string> InstallVersion(Version version, Stream customVersionStream, CancellationToken cancellationToken)
|
||||
{
|
||||
var ourTcs = new TaskCompletionSource<object>();
|
||||
Task inProgressTask;
|
||||
@@ -132,7 +133,7 @@ namespace Tgstation.Server.Host.Components.Byond
|
||||
bool installed;
|
||||
lock (installedVersions)
|
||||
{
|
||||
if (versionZipBytes != null)
|
||||
if (customVersionStream != null)
|
||||
{
|
||||
int customInstallationNumber = 1;
|
||||
do
|
||||
@@ -157,7 +158,7 @@ namespace Tgstation.Server.Host.Components.Byond
|
||||
return versionKey;
|
||||
}
|
||||
|
||||
if (versionZipBytes != null)
|
||||
if (customVersionStream != null)
|
||||
logger.LogInformation("Installing custom BYOND version as {0}...", versionKey);
|
||||
else if (version.Build > 0)
|
||||
throw new JobException(ErrorCode.ByondNonExistentCustomVersion);
|
||||
@@ -168,26 +169,43 @@ namespace Tgstation.Server.Host.Components.Byond
|
||||
try
|
||||
{
|
||||
await eventConsumer.HandleEvent(EventType.ByondInstallStart, new List<string> { versionKey }, cancellationToken).ConfigureAwait(false);
|
||||
var zipFileBytesTask = versionZipBytes == null
|
||||
? byondInstaller.DownloadVersion(version, cancellationToken)
|
||||
: Task.FromResult(versionZipBytes);
|
||||
|
||||
await ioManager.DeleteDirectory(versionKey, cancellationToken).ConfigureAwait(false);
|
||||
var extractPath = ioManager.ResolvePath(versionKey);
|
||||
async Task DirectoryCleanup()
|
||||
{
|
||||
await ioManager.DeleteDirectory(extractPath, cancellationToken).ConfigureAwait(false);
|
||||
await ioManager.CreateDirectory(extractPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var directoryCleanupTask = DirectoryCleanup();
|
||||
try
|
||||
{
|
||||
versionZipBytes = await zipFileBytesTask.ConfigureAwait(false);
|
||||
await ioManager.CreateDirectory(versionKey, cancellationToken).ConfigureAwait(false);
|
||||
Stream versionZipStream;
|
||||
Stream downloadedStream = null;
|
||||
if (customVersionStream == null)
|
||||
{
|
||||
var bytes = await byondInstaller.DownloadVersion(version, cancellationToken).ConfigureAwait(false);
|
||||
downloadedStream = new MemoryStream(bytes);
|
||||
versionZipStream = downloadedStream;
|
||||
}
|
||||
else
|
||||
versionZipStream = customVersionStream;
|
||||
|
||||
var extractPath = ioManager.ResolvePath(versionKey);
|
||||
logger.LogTrace("Extracting downloaded BYOND zip to {0}...", extractPath);
|
||||
await ioManager.ZipToDirectory(extractPath, versionZipBytes, cancellationToken).ConfigureAwait(false);
|
||||
versionZipBytes = null;
|
||||
using (downloadedStream)
|
||||
{
|
||||
await directoryCleanupTask.ConfigureAwait(false);
|
||||
logger.LogTrace("Extracting downloaded BYOND zip to {0}...", extractPath);
|
||||
await ioManager.ZipToDirectory(extractPath, versionZipStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await byondInstaller.InstallByond(extractPath, version, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// make sure to do this last because this is what tells us we have a valid version in the future
|
||||
await ioManager.WriteAllBytes(ioManager.ConcatPath(versionKey, VersionFileName), Encoding.UTF8.GetBytes(versionKey), cancellationToken).ConfigureAwait(false);
|
||||
await ioManager.WriteAllBytes(
|
||||
ioManager.ConcatPath(versionKey, VersionFileName),
|
||||
Encoding.UTF8.GetBytes(versionKey),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (WebException e)
|
||||
{
|
||||
@@ -220,12 +238,12 @@ namespace Tgstation.Server.Host.Components.Byond
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ChangeVersion(Version version, byte[] customVersionBytes, CancellationToken cancellationToken)
|
||||
public async Task ChangeVersion(Version version, Stream customVersionStream, CancellationToken cancellationToken)
|
||||
{
|
||||
if (version == null)
|
||||
throw new ArgumentNullException(nameof(version));
|
||||
|
||||
var versionKey = await InstallVersion(version, customVersionBytes, cancellationToken).ConfigureAwait(false);
|
||||
var versionKey = await InstallVersion(version, customVersionStream, cancellationToken).ConfigureAwait(false);
|
||||
using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
await ioManager.WriteAllBytes(ActiveVersionFileName, Encoding.UTF8.GetBytes(versionKey), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -25,10 +26,10 @@ namespace Tgstation.Server.Host.Components.Byond
|
||||
/// Change the active BYOND version
|
||||
/// </summary>
|
||||
/// <param name="version">The new <see cref="Version"/></param>
|
||||
/// <param name="customVersionBytes">Optional <see cref="byte"/>s of a custom BYOND version zip file.</param>
|
||||
/// <param name="customVersionStream">Optional <see cref="Stream"/> of a custom BYOND version zip file.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
Task ChangeVersion(Version version, byte[] customVersionBytes, CancellationToken cancellationToken);
|
||||
Task ChangeVersion(Version version, Stream customVersionStream, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lock the current installation's location and return a <see cref="IByondExecutableLock"/>
|
||||
@@ -38,4 +39,4 @@ namespace Tgstation.Server.Host.Components.Byond
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the requested <see cref="IByondExecutableLock"/></returns>
|
||||
Task<IByondExecutableLock> UseExecutables(Version requiredVersion, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ using Tgstation.Server.Host.IO;
|
||||
using Tgstation.Server.Host.Jobs;
|
||||
using Tgstation.Server.Host.Security;
|
||||
using Tgstation.Server.Host.System;
|
||||
using Tgstation.Server.Host.Transfer;
|
||||
|
||||
namespace Tgstation.Server.Host.Components
|
||||
{
|
||||
@@ -125,6 +126,11 @@ namespace Tgstation.Server.Host.Components
|
||||
/// </summary>
|
||||
readonly IServerPortProvider serverPortProvider;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IFileTransferTicketProvider"/> for the <see cref="InstanceFactory"/>.
|
||||
/// </summary>
|
||||
readonly IFileTransferTicketProvider fileTransferService;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="GeneralConfiguration"/> for the <see cref="InstanceFactory"/>.
|
||||
/// </summary>
|
||||
@@ -153,6 +159,7 @@ namespace Tgstation.Server.Host.Components
|
||||
/// <param name="repositoryFactory">The value of <see cref="repositoryFactory"/>.</param>
|
||||
/// <param name="repositoryCommands">The value of <see cref="repositoryCommands"/>.</param>
|
||||
/// <param name="serverPortProvider">The value of <see cref="serverPortProvider"/>.</param>
|
||||
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
|
||||
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
|
||||
public InstanceFactory(
|
||||
IIOManager ioManager,
|
||||
@@ -175,6 +182,7 @@ namespace Tgstation.Server.Host.Components
|
||||
ILibGit2RepositoryFactory repositoryFactory,
|
||||
ILibGit2Commands repositoryCommands,
|
||||
IServerPortProvider serverPortProvider,
|
||||
IFileTransferTicketProvider fileTransferService,
|
||||
IOptions<GeneralConfiguration> generalConfigurationOptions)
|
||||
{
|
||||
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
|
||||
@@ -197,6 +205,7 @@ namespace Tgstation.Server.Host.Components
|
||||
this.repositoryFactory = repositoryFactory ?? throw new ArgumentNullException(nameof(repositoryFactory));
|
||||
this.repositoryCommands = repositoryCommands ?? throw new ArgumentNullException(nameof(repositoryCommands));
|
||||
this.serverPortProvider = serverPortProvider ?? throw new ArgumentNullException(nameof(serverPortProvider));
|
||||
this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService));
|
||||
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
|
||||
}
|
||||
|
||||
@@ -214,7 +223,15 @@ namespace Tgstation.Server.Host.Components
|
||||
var diagnosticsIOManager = new ResolvingIOManager(instanceIoManager, "Diagnostics");
|
||||
var configurationIoManager = new ResolvingIOManager(instanceIoManager, "Configuration");
|
||||
|
||||
var configuration = new StaticFiles.Configuration(configurationIoManager, synchronousIOManager, symlinkFactory, processExecutor, postWriteHandler, platformIdentifier, loggerFactory.CreateLogger<StaticFiles.Configuration>());
|
||||
var configuration = new StaticFiles.Configuration(
|
||||
configurationIoManager,
|
||||
synchronousIOManager,
|
||||
symlinkFactory,
|
||||
processExecutor,
|
||||
postWriteHandler,
|
||||
platformIdentifier,
|
||||
fileTransferService,
|
||||
loggerFactory.CreateLogger<StaticFiles.Configuration>());
|
||||
var eventConsumer = new EventConsumer(configuration);
|
||||
var repoManager = new RepositoryManager(
|
||||
repositoryFactory,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using LibGit2Sharp;
|
||||
using LibGit2Sharp;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -22,13 +22,10 @@ namespace Tgstation.Server.Host.Components.Repository
|
||||
if (libGit2Repo == null)
|
||||
throw new ArgumentNullException(nameof(libGit2Repo));
|
||||
|
||||
if (!(libGit2Repo is LibGit2Sharp.Repository concreteRepo))
|
||||
throw new ArgumentException("libGit2Repo must be an instance of LibGit2Sharp.Repository!", nameof(libGit2Repo));
|
||||
|
||||
if (remote == null)
|
||||
throw new ArgumentNullException(nameof(remote));
|
||||
|
||||
Commands.Fetch(concreteRepo, remote.Name, refSpecs, fetchOptions, logMessage);
|
||||
Commands.Fetch((LibGit2Sharp.Repository)libGit2Repo, remote.Name, refSpecs, fetchOptions, logMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ using Tgstation.Server.Host.IO;
|
||||
using Tgstation.Server.Host.Jobs;
|
||||
using Tgstation.Server.Host.Security;
|
||||
using Tgstation.Server.Host.System;
|
||||
using Tgstation.Server.Host.Transfer;
|
||||
|
||||
namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
{
|
||||
@@ -76,6 +77,11 @@ namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
/// </summary>
|
||||
readonly IPlatformIdentifier platformIdentifier;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IFileTransferTicketProvider"/> for <see cref="Configuration"/>.
|
||||
/// </summary>>
|
||||
readonly IFileTransferTicketProvider fileTransferService;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ILogger"/> for <see cref="Configuration"/>
|
||||
/// </summary>
|
||||
@@ -86,6 +92,16 @@ namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
/// </summary>
|
||||
readonly SemaphoreSlim semaphore;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="CancellationTokenSource"/> that is triggered when <see cref="IDisposable.Dispose"/> is called.
|
||||
/// </summary>
|
||||
readonly CancellationTokenSource disposeCts;
|
||||
|
||||
/// <summary>
|
||||
/// The culmination of all upload file transfer callbacks.
|
||||
/// </summary>
|
||||
Task uploadTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Construct <see cref="Configuration"/>
|
||||
/// </summary>
|
||||
@@ -95,6 +111,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
/// <param name="processExecutor">The value of <see cref="processExecutor"/></param>
|
||||
/// <param name="postWriteHandler">The value of <see cref="postWriteHandler"/></param>
|
||||
/// <param name="platformIdentifier">The value of <see cref="platformIdentifier"/></param>
|
||||
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
|
||||
/// <param name="logger">The value of <see cref="logger"/></param>
|
||||
public Configuration(
|
||||
IIOManager ioManager,
|
||||
@@ -103,6 +120,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
IProcessExecutor processExecutor,
|
||||
IPostWriteHandler postWriteHandler,
|
||||
IPlatformIdentifier platformIdentifier,
|
||||
IFileTransferTicketProvider fileTransferService,
|
||||
ILogger<Configuration> logger)
|
||||
{
|
||||
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
|
||||
@@ -111,13 +129,21 @@ namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
this.processExecutor = processExecutor ?? throw new ArgumentNullException(nameof(processExecutor));
|
||||
this.postWriteHandler = postWriteHandler ?? throw new ArgumentNullException(nameof(postWriteHandler));
|
||||
this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
|
||||
this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
semaphore = new SemaphoreSlim(1);
|
||||
disposeCts = new CancellationTokenSource();
|
||||
uploadTasks = Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => semaphore.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
semaphore.Dispose();
|
||||
disposeCts.Cancel();
|
||||
disposeCts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the proper path to <see cref="StaticIgnoreFile"/>
|
||||
@@ -246,17 +272,55 @@ namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
lock (semaphore)
|
||||
try
|
||||
{
|
||||
var content = synchronousIOManager.ReadFile(path);
|
||||
string sha1String;
|
||||
string GetFileSha()
|
||||
{
|
||||
var content = synchronousIOManager.ReadFile(path);
|
||||
#pragma warning disable CA5350 // Do not use insecure cryptographic algorithm SHA1.
|
||||
using (var sha1 = new SHA1Managed())
|
||||
using var sha1 = new SHA1Managed();
|
||||
#pragma warning restore CA5350 // Do not use insecure cryptographic algorithm SHA1.
|
||||
sha1String = String.Join(String.Empty, sha1.ComputeHash(content).Select(b => b.ToString("x2", CultureInfo.InvariantCulture)));
|
||||
return String.Join(String.Empty, sha1.ComputeHash(content).Select(b => b.ToString("x2", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
var originalSha = GetFileSha();
|
||||
|
||||
var disposeToken = disposeCts.Token;
|
||||
var fileTicket = fileTransferService.CreateDownload(
|
||||
new FileDownloadProvider(
|
||||
() =>
|
||||
{
|
||||
if (disposeToken.IsCancellationRequested)
|
||||
return ErrorCode.InstanceOffline;
|
||||
|
||||
var newSha = GetFileSha();
|
||||
if (newSha != originalSha)
|
||||
return ErrorCode.ConfigurationFileUpdated;
|
||||
|
||||
return null;
|
||||
},
|
||||
async cancellationToken =>
|
||||
{
|
||||
FileStream result = null;
|
||||
void GetFileStream()
|
||||
{
|
||||
result = ioManager.GetFileStream(path, false);
|
||||
}
|
||||
|
||||
using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false))
|
||||
if (systemIdentity == null)
|
||||
await Task.Factory.StartNew(GetFileStream, cancellationToken, DefaultIOManager.BlockingTaskCreationOptions, TaskScheduler.Current).ConfigureAwait(false);
|
||||
else
|
||||
await systemIdentity.RunImpersonated(GetFileStream, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
},
|
||||
path,
|
||||
false));
|
||||
|
||||
result = new ConfigurationFile
|
||||
{
|
||||
Content = content,
|
||||
FileTicket = fileTicket.FileTicket,
|
||||
IsDirectory = false,
|
||||
LastReadHash = sha1String,
|
||||
LastReadHash = originalSha,
|
||||
AccessDenied = false,
|
||||
Path = configurationRelativePath
|
||||
};
|
||||
@@ -365,7 +429,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConfigurationFile> Write(string configurationRelativePath, ISystemIdentity systemIdentity, byte[] data, string previousHash, CancellationToken cancellationToken)
|
||||
public async Task<ConfigurationFile> Write(string configurationRelativePath, ISystemIdentity systemIdentity, string previousHash, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureDirectories(cancellationToken).ConfigureAwait(false);
|
||||
var path = ValidateConfigRelativePath(configurationRelativePath);
|
||||
@@ -377,20 +441,47 @@ namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
lock (semaphore)
|
||||
try
|
||||
{
|
||||
var fileHash = previousHash;
|
||||
var success = synchronousIOManager.WriteFileChecked(path, data, ref fileHash, cancellationToken);
|
||||
if (!success)
|
||||
return;
|
||||
if (data != null)
|
||||
postWriteHandler.HandleWrite(path);
|
||||
var fileTicket = fileTransferService.CreateUpload(true);
|
||||
var uploadCancellationToken = disposeCts.Token;
|
||||
async Task UploadHandler()
|
||||
{
|
||||
using (fileTicket)
|
||||
{
|
||||
var fileHash = previousHash;
|
||||
using var uploadStream = await fileTicket.GetResult(uploadCancellationToken).ConfigureAwait(false);
|
||||
bool success = false;
|
||||
void WriteCallback()
|
||||
{
|
||||
success = synchronousIOManager.WriteFileChecked(path, uploadStream, ref fileHash, cancellationToken);
|
||||
}
|
||||
|
||||
using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false))
|
||||
if (systemIdentity == null)
|
||||
await Task.Factory.StartNew(WriteCallback, cancellationToken, DefaultIOManager.BlockingTaskCreationOptions, TaskScheduler.Current).ConfigureAwait(false);
|
||||
else
|
||||
await systemIdentity.RunImpersonated(WriteCallback, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!success)
|
||||
fileTicket.SetErrorMessage(new ErrorMessage(ErrorCode.ConfigurationFileUpdated)
|
||||
{
|
||||
AdditionalData = fileHash
|
||||
});
|
||||
else if(uploadStream.Length > 0)
|
||||
postWriteHandler.HandleWrite(path);
|
||||
}
|
||||
}
|
||||
|
||||
result = new ConfigurationFile
|
||||
{
|
||||
Content = data,
|
||||
FileTicket = fileTicket.Ticket.FileTicket,
|
||||
LastReadHash = previousHash,
|
||||
IsDirectory = false,
|
||||
LastReadHash = fileHash,
|
||||
AccessDenied = false,
|
||||
Path = configurationRelativePath
|
||||
};
|
||||
|
||||
lock (disposeCts)
|
||||
uploadTasks = Task.WhenAll(uploadTasks, UploadHandler());
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
@@ -37,7 +37,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
/// <param name="configurationRelativePath">The relative path in the Configuration directory</param>
|
||||
/// <param name="systemIdentity">The <see cref="ISystemIdentity"/> for the operation. If <see langword="null"/>, the operation will be performed as the user of the <see cref="Core.Application"/></param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="ConfigurationFile"/>s for the items in the directory. <see cref="Api.Models.Internal.RawData.Content"/> and <see cref="ConfigurationFile.LastReadHash"/> will both be <see langword="null"/></returns>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="ConfigurationFile"/>s for the items in the directory. <see cref="FileTicketResult.FileTicket"/> and <see cref="ConfigurationFile.LastReadHash"/> will both be <see langword="null"/></returns>
|
||||
Task<IReadOnlyList<ConfigurationFile>> ListDirectory(string configurationRelativePath, ISystemIdentity systemIdentity, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -72,10 +72,9 @@ namespace Tgstation.Server.Host.Components.StaticFiles
|
||||
/// </summary>
|
||||
/// <param name="configurationRelativePath">The relative path in the Configuration directory</param>
|
||||
/// <param name="systemIdentity">The <see cref="ISystemIdentity"/> for the operation. If <see langword="null"/>, the operation will be performed as the user of the <see cref="Core.Application"/></param>
|
||||
/// <param name="data">The data to write. If <see langword="null"/>, the file is deleted</param>
|
||||
/// <param name="previousHash">The hash any existing file must match in order for the write to succeed</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation. Usage may result in partial writes</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the updated <see cref="ConfigurationFile"/> or <see langword="null"/> if the write failed due to <see cref="ConfigurationFile.LastReadHash"/> conflicts</returns>
|
||||
Task<ConfigurationFile> Write(string configurationRelativePath, ISystemIdentity systemIdentity, byte[] data, string previousHash, CancellationToken cancellationToken);
|
||||
Task<ConfigurationFile> Write(string configurationRelativePath, ISystemIdentity systemIdentity, string previousHash, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ using Tgstation.Server.Host.Extensions;
|
||||
using Tgstation.Server.Host.IO;
|
||||
using Tgstation.Server.Host.Security;
|
||||
using Tgstation.Server.Host.System;
|
||||
using Tgstation.Server.Host.Transfer;
|
||||
|
||||
namespace Tgstation.Server.Host.Controllers
|
||||
{
|
||||
@@ -56,6 +57,11 @@ namespace Tgstation.Server.Host.Controllers
|
||||
/// </summary>
|
||||
readonly IPlatformIdentifier platformIdentifier;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IFileTransferTicketProvider"/> for the <see cref="AdministrationController"/>.
|
||||
/// </summary>
|
||||
readonly IFileTransferTicketProvider fileTransferService;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="UpdatesConfiguration"/> for the <see cref="AdministrationController"/>
|
||||
/// </summary>
|
||||
@@ -81,6 +87,7 @@ namespace Tgstation.Server.Host.Controllers
|
||||
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/></param>
|
||||
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
|
||||
/// <param name="platformIdentifier">The value of <see cref="platformIdentifier"/></param>
|
||||
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
|
||||
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
|
||||
/// <param name="updatesConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="updatesConfiguration"/></param>
|
||||
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="generalConfiguration"/></param>
|
||||
@@ -93,6 +100,7 @@ namespace Tgstation.Server.Host.Controllers
|
||||
IAssemblyInformationProvider assemblyInformationProvider,
|
||||
IIOManager ioManager,
|
||||
IPlatformIdentifier platformIdentifier,
|
||||
IFileTransferTicketProvider fileTransferService,
|
||||
ILogger<AdministrationController> logger,
|
||||
IOptions<UpdatesConfiguration> updatesConfigurationOptions,
|
||||
IOptions<GeneralConfiguration> generalConfigurationOptions,
|
||||
@@ -108,6 +116,7 @@ namespace Tgstation.Server.Host.Controllers
|
||||
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
|
||||
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
|
||||
this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
|
||||
this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService));
|
||||
updatesConfiguration = updatesConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(updatesConfigurationOptions));
|
||||
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
|
||||
fileLoggingConfiguration = fileLoggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions));
|
||||
@@ -386,13 +395,20 @@ namespace Tgstation.Server.Host.Controllers
|
||||
path);
|
||||
try
|
||||
{
|
||||
var fileTransferTicket = fileTransferService.CreateDownload(
|
||||
new FileDownloadProvider(
|
||||
() => null,
|
||||
null,
|
||||
fullPath,
|
||||
true));
|
||||
|
||||
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)
|
||||
FileTicket = fileTransferTicket.FileTicket
|
||||
});
|
||||
}
|
||||
catch (IOException ex)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -13,6 +14,7 @@ using Tgstation.Server.Host.Database;
|
||||
using Tgstation.Server.Host.Jobs;
|
||||
using Tgstation.Server.Host.Models;
|
||||
using Tgstation.Server.Host.Security;
|
||||
using Tgstation.Server.Host.Transfer;
|
||||
|
||||
namespace Tgstation.Server.Host.Controllers
|
||||
{
|
||||
@@ -27,6 +29,11 @@ namespace Tgstation.Server.Host.Controllers
|
||||
/// </summary>
|
||||
readonly IJobManager jobManager;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IFileTransferTicketProvider"/> for the <see cref="ByondController"/>.
|
||||
/// </summary>
|
||||
readonly IFileTransferTicketProvider fileTransferService;
|
||||
|
||||
/// <summary>
|
||||
/// Construct a <see cref="ByondController"/>
|
||||
/// </summary>
|
||||
@@ -34,12 +41,14 @@ namespace Tgstation.Server.Host.Controllers
|
||||
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/></param>
|
||||
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
|
||||
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
|
||||
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
|
||||
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
|
||||
public ByondController(
|
||||
IDatabaseContext databaseContext,
|
||||
IAuthenticationContextFactory authenticationContextFactory,
|
||||
IInstanceManager instanceManager,
|
||||
IJobManager jobManager,
|
||||
IFileTransferTicketProvider fileTransferService,
|
||||
ILogger<ByondController> logger)
|
||||
: base(
|
||||
instanceManager,
|
||||
@@ -48,6 +57,7 @@ namespace Tgstation.Server.Host.Controllers
|
||||
logger)
|
||||
{
|
||||
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
|
||||
this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -104,14 +114,16 @@ namespace Tgstation.Server.Host.Controllers
|
||||
if (model == null)
|
||||
throw new ArgumentNullException(nameof(model));
|
||||
|
||||
var uploadingZip = model.UploadCustomZip == true;
|
||||
|
||||
if (model.Version == null
|
||||
|| model.Version.Revision != -1
|
||||
|| (model.Content != null && model.Version.Build > 0))
|
||||
|| (uploadingZip && model.Version.Build > 0))
|
||||
return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure));
|
||||
|
||||
var userByondRights = AuthenticationContext.InstanceUser.ByondRights.Value;
|
||||
if ((!userByondRights.HasFlag(ByondRights.InstallOfficialOrChangeActiveVersion) && model.Content == null)
|
||||
|| (!userByondRights.HasFlag(ByondRights.InstallCustomVersion) && model.Content != null))
|
||||
if ((!userByondRights.HasFlag(ByondRights.InstallOfficialOrChangeActiveVersion) && !uploadingZip)
|
||||
|| (!userByondRights.HasFlag(ByondRights.InstallCustomVersion) && uploadingZip))
|
||||
return Forbid();
|
||||
|
||||
// remove cruff fields
|
||||
@@ -120,7 +132,7 @@ namespace Tgstation.Server.Host.Controllers
|
||||
async instance =>
|
||||
{
|
||||
var byondManager = instance.ByondManager;
|
||||
if (model.Content == null && byondManager.InstalledVersions.Any(x => x == model.Version))
|
||||
if (!uploadingZip && byondManager.InstalledVersions.Any(x => x == model.Version))
|
||||
{
|
||||
Logger.LogInformation(
|
||||
"User ID {0} changing instance ID {1} BYOND version to {2}",
|
||||
@@ -146,21 +158,61 @@ namespace Tgstation.Server.Host.Controllers
|
||||
// run the install through the job manager
|
||||
var job = new Models.Job
|
||||
{
|
||||
Description = $"Install {(model.Content == null ? String.Empty : "custom ")}BYOND version {model.Version.Major}.{model.Version.Minor}",
|
||||
Description = $"Install {(!uploadingZip ? String.Empty : "custom ")}BYOND version {model.Version.Major}.{model.Version.Minor}",
|
||||
StartedBy = AuthenticationContext.User,
|
||||
CancelRightsType = RightsType.Byond,
|
||||
CancelRight = (ulong)ByondRights.CancelInstall,
|
||||
Instance = Instance
|
||||
};
|
||||
await jobManager.RegisterOperation(
|
||||
job,
|
||||
(core, databaseContextFactory, paramJob, progressHandler, jobCancellationToken) => core.ByondManager.ChangeVersion(
|
||||
model.Version,
|
||||
model.Content,
|
||||
jobCancellationToken),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
result.InstallJob = job.ToApi();
|
||||
|
||||
IFileUploadTicket fileUploadTicket = null;
|
||||
if (uploadingZip)
|
||||
fileUploadTicket = fileTransferService.CreateUpload(false);
|
||||
|
||||
try
|
||||
{
|
||||
await jobManager.RegisterOperation(
|
||||
job,
|
||||
async (core, databaseContextFactory, paramJob, progressHandler, jobCancellationToken) =>
|
||||
{
|
||||
Stream zipFileStream = null;
|
||||
if (fileUploadTicket != null)
|
||||
using (fileUploadTicket)
|
||||
{
|
||||
var uploadStream = await fileUploadTicket.GetResult(jobCancellationToken).ConfigureAwait(false);
|
||||
if (uploadStream == null)
|
||||
throw new JobException(ErrorCode.FileUploadExpired);
|
||||
|
||||
zipFileStream = new MemoryStream();
|
||||
try
|
||||
{
|
||||
await uploadStream.CopyToAsync(zipFileStream, jobCancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await zipFileStream.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
using (zipFileStream)
|
||||
await core.ByondManager.ChangeVersion(
|
||||
model.Version,
|
||||
zipFileStream,
|
||||
jobCancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
result.InstallJob = job.ToApi();
|
||||
result.FileTicket = fileUploadTicket?.Ticket.FileTicket;
|
||||
}
|
||||
catch
|
||||
{
|
||||
fileUploadTicket?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
if ((AuthenticationContext.GetRight(RightsType.Byond) & (ulong)ByondRights.ReadActive) != 0)
|
||||
|
||||
@@ -77,11 +77,11 @@ namespace Tgstation.Server.Host.Controllers
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
|
||||
/// <response code="200">File updated successfully.</response>
|
||||
/// <response code="201">File created successfully.</response>
|
||||
/// <response code="202">File upload ticket created successfully.</response>
|
||||
[HttpPost]
|
||||
[TgsAuthorize(ConfigurationRights.Write)]
|
||||
[ProducesResponseType(typeof(ConfigurationFile), 200)]
|
||||
[ProducesResponseType(typeof(ConfigurationFile), 201)]
|
||||
[ProducesResponseType(typeof(ConfigurationFile), 202)]
|
||||
public async Task<IActionResult> Update([FromBody] ConfigurationFile model, CancellationToken cancellationToken)
|
||||
{
|
||||
if (model == null)
|
||||
@@ -99,16 +99,11 @@ namespace Tgstation.Server.Host.Controllers
|
||||
.Write(
|
||||
model.Path,
|
||||
systemIdentity,
|
||||
model.Content,
|
||||
model.LastReadHash,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (newFile == null)
|
||||
return Conflict(new ErrorMessage(ErrorCode.ConfigurationFileUpdated));
|
||||
|
||||
newFile.Content = null;
|
||||
|
||||
return model.LastReadHash == null ? (IActionResult)Created(newFile) : Json(newFile);
|
||||
return model.LastReadHash == null ? (IActionResult)Accepted(newFile) : Json(newFile);
|
||||
})
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Mime;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Tgstation.Server.Host.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// Very similar to <see cref="FileStreamResult"/> except it's <see cref="IActionResultExecutor{TResult}"/> contains a fix for https://github.com/dotnet/aspnetcore/issues/28189.
|
||||
/// </summary>
|
||||
public sealed class LimitedFileStreamResult : FileResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="global::System.IO.FileStream"/> representing the file to download.
|
||||
/// </summary>
|
||||
public FileStream FileStream { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LimitedFileStreamResult"/> <see langword="class"/>.
|
||||
/// </summary>
|
||||
/// <param name="stream">The value of <see cref="FileStream"/>.</param>
|
||||
public LimitedFileStreamResult(FileStream stream)
|
||||
: base(MediaTypeNames.Application.Octet)
|
||||
{
|
||||
FileStream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task ExecuteResultAsync(ActionContext context)
|
||||
{
|
||||
if (context == null)
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
|
||||
var executor = context
|
||||
.HttpContext
|
||||
.RequestServices
|
||||
.GetRequiredService<IActionResultExecutor<LimitedFileStreamResult>>();
|
||||
return executor.ExecuteAsync(context, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Tgstation.Server.Host.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="IActionResultExecutor{TResult}"/> for <see cref="LimitedFileStreamResult"/>s.
|
||||
/// </summary>
|
||||
public class LimitedFileStreamResultExecutor : FileResultExecutorBase, IActionResultExecutor<LimitedFileStreamResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LimitedFileStreamResultExecutor"/> <see langword="class"/>.
|
||||
/// </summary>
|
||||
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="FileResultExecutorBase"/>.</param>
|
||||
public LimitedFileStreamResultExecutor(ILogger<LimitedFileStreamResultExecutor> logger)
|
||||
: base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(ActionContext context, LimitedFileStreamResult result)
|
||||
{
|
||||
if (context == null)
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
|
||||
if (result == null)
|
||||
throw new ArgumentNullException(nameof(result));
|
||||
|
||||
using (result.FileStream)
|
||||
{
|
||||
var contentLength = result.FileStream.Length;
|
||||
var (range, rangeLength, serveBody) = SetHeadersAndLog(context, result, contentLength, result.EnableRangeProcessing);
|
||||
if (!serveBody)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var cancellationToken = context.HttpContext.RequestAborted;
|
||||
var outputStream = context.HttpContext.Response.Body;
|
||||
if (range == null)
|
||||
{
|
||||
await StreamCopyOperation.CopyToAsync(
|
||||
result.FileStream,
|
||||
outputStream,
|
||||
contentLength,
|
||||
BufferSize,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.FileStream.Seek(range.From.Value, SeekOrigin.Begin);
|
||||
await StreamCopyOperation.CopyToAsync(
|
||||
result.FileStream,
|
||||
outputStream,
|
||||
rangeLength,
|
||||
BufferSize,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Don't throw this exception, it's most likely caused by the client disconnecting.
|
||||
// However, if it was cancelled for any other reason we need to prevent empty responses.
|
||||
context.HttpContext.Abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Mime;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api;
|
||||
using Tgstation.Server.Api.Models;
|
||||
using Tgstation.Server.Host.Database;
|
||||
using Tgstation.Server.Host.Security;
|
||||
using Tgstation.Server.Host.Transfer;
|
||||
|
||||
namespace Tgstation.Server.Host.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="ApiController"/> for file streaming.
|
||||
/// </summary>
|
||||
[Route(Routes.Transfer)]
|
||||
[RequestSizeLimit(Limits.MaximumFileTransferSize)]
|
||||
public sealed class TransferController : ApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="IFileTransferStreamHandler"/> for the <see cref="TransferController"/>.
|
||||
/// </summary>
|
||||
readonly IFileTransferStreamHandler fileTransferService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TransferController"/> <see langword="class"/>.
|
||||
/// </summary>
|
||||
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/></param>
|
||||
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/></param>
|
||||
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
|
||||
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
|
||||
public TransferController(
|
||||
IDatabaseContext databaseContext,
|
||||
IAuthenticationContextFactory authenticationContextFactory,
|
||||
IFileTransferStreamHandler fileTransferService,
|
||||
ILogger<ApiController> logger)
|
||||
: base(
|
||||
databaseContext,
|
||||
authenticationContextFactory,
|
||||
logger,
|
||||
true)
|
||||
{
|
||||
this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a file with a given <paramref name="ticket"/>.
|
||||
/// </summary>
|
||||
/// <param name="ticket">The <see cref="FileTicketResult.FileTicket"/> for the download.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the method.</returns>
|
||||
/// <response code="200">Started streaming download successfully.</response>
|
||||
/// <response code="410">The <paramref name="ticket"/> was no longer or was never valid.</response>
|
||||
[TgsAuthorize]
|
||||
[HttpGet]
|
||||
[ProducesResponseType(200, Type = typeof(LimitedFileStreamResult))]
|
||||
[ProducesResponseType(410, Type = typeof(ErrorMessage))]
|
||||
public async Task<IActionResult> Download([Required, FromQuery] string ticket, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ticket == null)
|
||||
return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure));
|
||||
|
||||
var streamAccept = new MediaTypeHeaderValue(MediaTypeNames.Application.Octet);
|
||||
if (!Request.GetTypedHeaders().Accept.Any(x => streamAccept.IsSubsetOf(x)))
|
||||
return StatusCode(HttpStatusCode.NotAcceptable, new ErrorMessage(ErrorCode.BadHeaders)
|
||||
{
|
||||
AdditionalData = $"File downloads must accept both {MediaTypeNames.Application.Octet} and {MediaTypeNames.Application.Json}!"
|
||||
});
|
||||
|
||||
var fileTicketResult = new FileTicketResult
|
||||
{
|
||||
FileTicket = ticket
|
||||
};
|
||||
|
||||
var tuple = await fileTransferService.RetrieveDownloadStream(fileTicketResult, cancellationToken).ConfigureAwait(false);
|
||||
var stream = tuple.Item1;
|
||||
try
|
||||
{
|
||||
if (tuple.Item2 != null)
|
||||
return Conflict(tuple.Item2);
|
||||
|
||||
if (stream == null)
|
||||
return Gone();
|
||||
|
||||
return new LimitedFileStreamResult(stream);
|
||||
}
|
||||
catch
|
||||
{
|
||||
stream.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a file with a given <paramref name="ticket"/>.
|
||||
/// </summary>
|
||||
/// <param name="ticket">The <see cref="FileTicketResult.FileTicket"/> for the upload.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the method.</returns>
|
||||
/// <response code="204">Uploaded file successfully.</response>
|
||||
/// <response code="409">An error occurred during the upload.</response>
|
||||
/// <response code="410">The <paramref name="ticket"/> was no longer or was never valid.</response>
|
||||
[TgsAuthorize]
|
||||
[HttpPut]
|
||||
[ProducesResponseType(204)]
|
||||
[ProducesResponseType(410, Type = typeof(ErrorMessage))]
|
||||
public async Task<IActionResult> Upload([Required, FromQuery] string ticket, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ticket == null)
|
||||
return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure));
|
||||
|
||||
var fileTicketResult = new FileTicketResult
|
||||
{
|
||||
FileTicket = ticket
|
||||
};
|
||||
|
||||
var result = await fileTransferService.SetUploadStream(fileTicketResult, Request.Body, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null)
|
||||
return result.ErrorCode == ErrorCode.ResourceNotPresent
|
||||
? Gone()
|
||||
: Conflict(result);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -31,6 +32,7 @@ using Tgstation.Server.Host.Components.Repository;
|
||||
using Tgstation.Server.Host.Components.Session;
|
||||
using Tgstation.Server.Host.Components.Watchdog;
|
||||
using Tgstation.Server.Host.Configuration;
|
||||
using Tgstation.Server.Host.Controllers;
|
||||
using Tgstation.Server.Host.Database;
|
||||
using Tgstation.Server.Host.Extensions;
|
||||
using Tgstation.Server.Host.IO;
|
||||
@@ -40,6 +42,7 @@ using Tgstation.Server.Host.Security;
|
||||
using Tgstation.Server.Host.Security.OAuth;
|
||||
using Tgstation.Server.Host.Setup;
|
||||
using Tgstation.Server.Host.System;
|
||||
using Tgstation.Server.Host.Transfer;
|
||||
|
||||
namespace Tgstation.Server.Host.Core
|
||||
{
|
||||
@@ -304,12 +307,16 @@ namespace Tgstation.Server.Host.Core
|
||||
}
|
||||
|
||||
// configure misc services
|
||||
services.AddScoped<IPortAllocator, PortAllocator>();
|
||||
services.AddTransient<IActionResultExecutor<LimitedFileStreamResult>, LimitedFileStreamResultExecutor>();
|
||||
services.AddSingleton<ISynchronousIOManager, SynchronousIOManager>();
|
||||
services.AddSingleton<IGitHubClientFactory, GitHubClientFactory>();
|
||||
services.AddSingleton<IProcessExecutor, ProcessExecutor>();
|
||||
services.AddSingleton<IServerPortProvider, ServerPortProivder>();
|
||||
services.AddSingleton<ITopicClientFactory, TopicClientFactory>();
|
||||
services.AddScoped<IPortAllocator, PortAllocator>();
|
||||
services.AddSingleton<FileTransferService>();
|
||||
services.AddSingleton<IFileTransferStreamHandler>(x => x.GetRequiredService<FileTransferService>());
|
||||
services.AddSingleton<IFileTransferTicketProvider>(x => x.GetRequiredService<FileTransferService>());
|
||||
|
||||
// configure component services
|
||||
services.AddSingleton<ILibGit2RepositoryFactory, LibGit2RepositoryFactory>();
|
||||
|
||||
@@ -25,6 +25,11 @@ namespace Tgstation.Server.Host.Core
|
||||
/// </summary>
|
||||
const string PasswordSecuritySchemeId = "Password_Login_Scheme";
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="OpenApiSecurityScheme"/> name for OAuth 2.0 authentication.
|
||||
/// </summary>
|
||||
const string OAuthSecuritySchemeId = "OAuth_Login_Scheme";
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="OpenApiSecurityScheme"/> name for token authentication.
|
||||
/// </summary>
|
||||
@@ -170,6 +175,14 @@ namespace Tgstation.Server.Host.Core
|
||||
Scheme = ApiHeaders.BasicAuthenticationScheme
|
||||
});
|
||||
|
||||
swaggerGenOptions.AddSecurityDefinition(OAuthSecuritySchemeId, new OpenApiSecurityScheme
|
||||
{
|
||||
In = ParameterLocation.Header,
|
||||
Type = SecuritySchemeType.Http,
|
||||
Name = HeaderNames.Authorization,
|
||||
Scheme = ApiHeaders.OAuthAuthenticationScheme
|
||||
});
|
||||
|
||||
swaggerGenOptions.AddSecurityDefinition(TokenSecuritySchemeId, new OpenApiSecurityScheme
|
||||
{
|
||||
BearerFormat = "JWT",
|
||||
@@ -236,10 +249,35 @@ namespace Tgstation.Server.Host.Core
|
||||
Id = ApiHeaders.InstanceIdHeader
|
||||
}
|
||||
});
|
||||
else if (typeof(TransferController).IsAssignableFrom(context.MethodInfo.DeclaringType))
|
||||
if (context.MethodInfo.Name == nameof(TransferController.Upload))
|
||||
operation.RequestBody = new OpenApiRequestBody
|
||||
{
|
||||
Content = new Dictionary<string, OpenApiMediaType>
|
||||
{
|
||||
{
|
||||
MediaTypeNames.Application.Octet,
|
||||
new OpenApiMediaType
|
||||
{
|
||||
Schema = new OpenApiSchema
|
||||
{
|
||||
Type = "string",
|
||||
Format = "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
else if (context.MethodInfo.Name == nameof(TransferController.Download))
|
||||
{
|
||||
var twoHundredResponseContents = operation.Responses["200"].Content;
|
||||
var fileContent = twoHundredResponseContents[MediaTypeNames.Application.Json];
|
||||
twoHundredResponseContents.Remove(MediaTypeNames.Application.Json);
|
||||
twoHundredResponseContents.Add(MediaTypeNames.Application.Octet, fileContent);
|
||||
}
|
||||
}
|
||||
else
|
||||
else if (context.MethodInfo.Name == nameof(HomeController.CreateToken))
|
||||
{
|
||||
// HomeController.CreateToken
|
||||
var passwordScheme = new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
@@ -249,6 +287,28 @@ namespace Tgstation.Server.Host.Core
|
||||
}
|
||||
};
|
||||
|
||||
var oAuthScheme = new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = OAuthSecuritySchemeId
|
||||
}
|
||||
};
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
In = ParameterLocation.Header,
|
||||
Name = ApiHeaders.OAuthProviderHeader,
|
||||
Description = "The external OAuth service provider.",
|
||||
Style = ParameterStyle.Simple,
|
||||
Example = new OpenApiString("Discord"),
|
||||
Schema = new OpenApiSchema
|
||||
{
|
||||
Type = "string"
|
||||
}
|
||||
});
|
||||
|
||||
operation.Security = new List<OpenApiSecurityRequirement>
|
||||
{
|
||||
new OpenApiSecurityRequirement
|
||||
@@ -256,6 +316,10 @@ namespace Tgstation.Server.Host.Core
|
||||
{
|
||||
passwordScheme,
|
||||
new List<string>()
|
||||
},
|
||||
{
|
||||
oAuthScheme,
|
||||
new List<string>()
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -311,17 +375,24 @@ namespace Tgstation.Server.Host.Core
|
||||
Schema = productHeaderSchema
|
||||
});
|
||||
|
||||
string bridgeOperationPath = null;
|
||||
var pathsToRemove = new List<string>();
|
||||
var filteredControllers = new string[]
|
||||
{
|
||||
nameof(BridgeController),
|
||||
nameof(ControlPanelController),
|
||||
};
|
||||
|
||||
foreach (var path in swaggerDoc.Paths)
|
||||
foreach (var operation in path.Value.Operations.Select(x => x.Value))
|
||||
{
|
||||
if (operation.OperationId.Equals($"{nameof(BridgeController)}.{nameof(BridgeController.Process)}", StringComparison.Ordinal))
|
||||
if (filteredControllers.Any(
|
||||
x => operation.OperationId.StartsWith(x, StringComparison.Ordinal)))
|
||||
{
|
||||
bridgeOperationPath = path.Key;
|
||||
pathsToRemove.Add(path.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
operation.Parameters.Insert(0, new OpenApiParameter
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
@@ -330,7 +401,7 @@ namespace Tgstation.Server.Host.Core
|
||||
},
|
||||
});
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
operation.Parameters.Insert(1, new OpenApiParameter
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
@@ -340,7 +411,8 @@ namespace Tgstation.Server.Host.Core
|
||||
});
|
||||
}
|
||||
|
||||
swaggerDoc.Paths.Remove(bridgeOperationPath);
|
||||
foreach (var filteredPath in pathsToRemove)
|
||||
swaggerDoc.Paths.Remove(filteredPath);
|
||||
|
||||
AddDefaultResponses(swaggerDoc);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ namespace Tgstation.Server.Host.IO
|
||||
/// <inheritdoc />
|
||||
public async Task CopyDirectory(string src, string dest, IEnumerable<string> ignore, CancellationToken cancellationToken)
|
||||
{
|
||||
if (dest == null)
|
||||
if (src == null)
|
||||
throw new ArgumentNullException(nameof(src));
|
||||
if (dest == null)
|
||||
throw new ArgumentNullException(nameof(src));
|
||||
@@ -134,12 +134,7 @@ namespace Tgstation.Server.Host.IO
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ConcatPath(params string[] paths)
|
||||
{
|
||||
if (paths == null)
|
||||
throw new ArgumentNullException(nameof(paths));
|
||||
return Path.Combine(paths);
|
||||
}
|
||||
public string ConcatPath(params string[] paths) => Path.Combine(paths);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CopyFile(string src, string dest, CancellationToken cancellationToken)
|
||||
@@ -150,6 +145,8 @@ namespace Tgstation.Server.Host.IO
|
||||
throw new ArgumentNullException(nameof(dest));
|
||||
using var srcStream = new FileStream(ResolvePath(src), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, DefaultBufferSize, true);
|
||||
using var destStream = new FileStream(ResolvePath(dest), FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, DefaultBufferSize, true);
|
||||
|
||||
// value taken from documentation
|
||||
await srcStream.CopyToAsync(destStream, 81920, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -308,14 +305,13 @@ namespace Tgstation.Server.Host.IO
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ZipToDirectory(string path, byte[] zipFileBytes, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
|
||||
public Task ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
|
||||
{
|
||||
path = ResolvePath(path);
|
||||
if (zipFileBytes == null)
|
||||
throw new ArgumentNullException(nameof(zipFileBytes));
|
||||
if (zipFile == null)
|
||||
throw new ArgumentNullException(nameof(zipFile));
|
||||
|
||||
using var ms = new MemoryStream(zipFileBytes);
|
||||
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
|
||||
using var archive = new ZipArchive(zipFile, ZipArchiveMode.Read);
|
||||
archive.ExtractToDirectory(path);
|
||||
}, cancellationToken, BlockingTaskCreationOptions, TaskScheduler.Current);
|
||||
|
||||
@@ -329,5 +325,8 @@ namespace Tgstation.Server.Host.IO
|
||||
var fileInfo = new FileInfo(path);
|
||||
return new DateTimeOffset(fileInfo.LastWriteTimeUtc);
|
||||
}, cancellationToken, BlockingTaskCreationOptions, TaskScheduler.Current);
|
||||
|
||||
/// <inheritdoc />
|
||||
public FileStream GetFileStream(string path, bool shareWrite) => new FileStream(ResolvePath(path), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete | (shareWrite ? FileShare.Write : FileShare.None), DefaultBufferSize, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -187,13 +188,13 @@ namespace Tgstation.Server.Host.IO
|
||||
Task<byte[]> DownloadFile(Uri url, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Extract a set of <paramref name="zipFileBytes"/> to a given <paramref name="path"/>
|
||||
/// Extract a set of <paramref name="zipFile"/> to a given <paramref name="path"/>
|
||||
/// </summary>
|
||||
/// <param name="path">The path to unzip to</param>
|
||||
/// <param name="zipFileBytes">The <see cref="byte"/>s of the <see cref="global::System.IO.Compression.ZipArchive"/></param>
|
||||
/// <param name="zipFile">The <see cref="Stream"/> of the <see cref="global::System.IO.Compression.ZipArchive"/></param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
Task ZipToDirectory(string path, byte[] zipFileBytes, CancellationToken cancellationToken);
|
||||
Task ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get the <see cref="DateTimeOffset"/> of when a given <paramref name="path"/> was last modified.
|
||||
@@ -202,5 +203,14 @@ namespace Tgstation.Server.Host.IO
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="DateTimeOffset"/> of when the file was last modified.</returns>
|
||||
Task<DateTimeOffset> GetLastModified(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="Stream"/> for a given file <paramref name="path"/>.
|
||||
/// </summary>
|
||||
/// <param name="path">The path of the file.</param>
|
||||
/// <param name="shareWrite">If <see cref="FileShare.Write"/> should be used.</param>
|
||||
/// <returns>The <see cref="FileStream"/> of the file.</returns>
|
||||
/// <remarks>This function is sychronous.</remarks>
|
||||
FileStream GetFileStream(string path, bool shareWrite);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace Tgstation.Server.Host.IO
|
||||
@@ -50,11 +51,11 @@ namespace Tgstation.Server.Host.IO
|
||||
/// Write <paramref name="data"/> to a file at a given <paramref name="path"/>
|
||||
/// </summary>
|
||||
/// <param name="path">The path to the file to write</param>
|
||||
/// <param name="data">The new contents of the file</param>
|
||||
/// <param name="data">A <see cref="Stream"/> containing the new contents of the file</param>
|
||||
/// <param name="sha1InOut">The function only succeeds if this parameter matches the SHA-1 hash of the contents of the current file. Contains the SHA1 of the file on disk once the function returns</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns><see langword="true"/> on success, <see langword="false"/> if the operation failed due to <paramref name="sha1InOut"/> not matching the file's contents</returns>
|
||||
bool WriteFileChecked(string path, byte[] data, ref string sha1InOut, CancellationToken cancellationToken);
|
||||
bool WriteFileChecked(string path, Stream data, ref string sha1InOut, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a given <paramref name="path"/> is a directory
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -74,30 +74,24 @@ namespace Tgstation.Server.Host.IO
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool WriteFileChecked(string path, byte[] data, ref string sha1InOut, CancellationToken cancellationToken)
|
||||
public bool WriteFileChecked(string path, Stream data, ref string sha1InOut, CancellationToken cancellationToken)
|
||||
{
|
||||
if (path == null)
|
||||
throw new ArgumentNullException(nameof(path));
|
||||
if (data == null)
|
||||
throw new ArgumentNullException(nameof(data));
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using (var file = File.Open(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// as nice as it would be to not have to arrayify the memory stream, we have to
|
||||
// because, oddly enough sha1(memorystream) != sha1(memorystream.ToArray())
|
||||
// vOv
|
||||
byte[] originalBytes;
|
||||
using (var readMs = new MemoryStream())
|
||||
{
|
||||
file.CopyTo(readMs);
|
||||
originalBytes = readMs.ToArray();
|
||||
}
|
||||
|
||||
// no sha1? no write
|
||||
if (originalBytes.Length != 0 && sha1InOut == null)
|
||||
if (file.Length != 0 && sha1InOut == null)
|
||||
return false;
|
||||
|
||||
// suppressed due to only using for consistency checks
|
||||
@@ -105,8 +99,8 @@ namespace Tgstation.Server.Host.IO
|
||||
using (var sha1 = new SHA1Managed())
|
||||
#pragma warning restore CA5350 // Do not use insecure cryptographic algorithm SHA1.
|
||||
{
|
||||
string GetSha1(byte[] dataToHash) => dataToHash != null && dataToHash.Length != 0 ? String.Join(String.Empty, sha1.ComputeHash(dataToHash).Select(b => b.ToString("x2", CultureInfo.InvariantCulture))) : null;
|
||||
var originalSha1 = GetSha1(originalBytes);
|
||||
string GetSha1(Stream dataToHash) => dataToHash != null && dataToHash.Length != 0 ? String.Join(String.Empty, sha1.ComputeHash(dataToHash).Select(b => b.ToString("x2", CultureInfo.InvariantCulture))) : null;
|
||||
var originalSha1 = GetSha1(file);
|
||||
if (originalSha1 != sha1InOut)
|
||||
{
|
||||
sha1InOut = originalSha1;
|
||||
@@ -118,17 +112,18 @@ namespace Tgstation.Server.Host.IO
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (data != null)
|
||||
if (data.Length != 0)
|
||||
{
|
||||
file.Seek(0, SeekOrigin.Begin);
|
||||
data.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
file.SetLength(data.Length);
|
||||
file.Write(data, 0, data.Length);
|
||||
data.CopyTo(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (data == null)
|
||||
if (data.Length == 0)
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -194,8 +194,11 @@ namespace Tgstation.Server.Host
|
||||
var cancellationToken = cancellationTokenSource.Token;
|
||||
|
||||
logger.LogTrace("Downloading zip package...");
|
||||
var updateZipData = await ioManager.DownloadFile(updateZipUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var updateZipData = new MemoryStream(
|
||||
await ioManager.DownloadFile(
|
||||
updateZipUrl,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
try
|
||||
{
|
||||
logger.LogTrace("Exctracting zip package to {0}...", updatePath);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api.Models;
|
||||
|
||||
namespace Tgstation.Server.Host.Transfer
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a file on disk to be downloaded.
|
||||
/// </summary>
|
||||
public sealed class FileDownloadProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="Func{TResult}"/> to run before providing the download. If it returns a non-null <see cref="ErrorCode"/>, a 400 error with that code will be returned instead of a download stream.
|
||||
/// </summary>
|
||||
public Func<ErrorCode?> ActivationCallback { get; }
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="Func{T, TResult}"/> to specially provide a <see cref="Task{TResult}"/> returning the <see cref="FileStream"/>.
|
||||
/// </summary>
|
||||
public Func<CancellationToken, Task<FileStream>> FileStreamProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The full path to the file on disk to download.
|
||||
/// </summary>
|
||||
public string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// If the file read stream should be allowed to share writes. If this is set, the entire file will be buffered to avoid Content-Length mismatches.
|
||||
/// </summary>
|
||||
public bool ShareWrite { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileDownloadProvider"/> <see langword="class"/>.
|
||||
/// </summary>
|
||||
/// <param name="activationCallback">The value of <see cref="ActivationCallback"/>.</param>
|
||||
/// <param name="fileStreamProvider">The optional value of <see cref="FileStreamProvider"/>.</param>
|
||||
/// <param name="filePath">The value of <see cref="FilePath"/>.</param>
|
||||
/// <param name="shareWrite">The value of <see cref="ShareWrite"/>.</param>
|
||||
public FileDownloadProvider(
|
||||
Func<ErrorCode?> activationCallback,
|
||||
Func<CancellationToken, Task<FileStream>> fileStreamProvider,
|
||||
string filePath,
|
||||
bool shareWrite)
|
||||
{
|
||||
ActivationCallback = activationCallback ?? throw new ArgumentNullException(nameof(activationCallback));
|
||||
FileStreamProvider = fileStreamProvider;
|
||||
FilePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
|
||||
ShareWrite = shareWrite;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api.Models;
|
||||
using Tgstation.Server.Host.Core;
|
||||
using Tgstation.Server.Host.Extensions;
|
||||
using Tgstation.Server.Host.IO;
|
||||
using Tgstation.Server.Host.Security;
|
||||
|
||||
namespace Tgstation.Server.Host.Transfer
|
||||
{
|
||||
/// <summary>
|
||||
/// Implementation of the file transfer service.
|
||||
/// </summary>
|
||||
sealed class FileTransferService : IFileTransferTicketProvider, IFileTransferStreamHandler, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of minutes before transfer ticket expire.
|
||||
/// </summary>
|
||||
const int TicketValidityMinutes = 5;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ICryptographySuite"/> for the <see cref="FileTransferService"/>.
|
||||
/// </summary>
|
||||
readonly ICryptographySuite cryptographySuite;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IIOManager"/> for the <see cref="FileTransferService"/>.
|
||||
/// </summary>
|
||||
readonly IIOManager ioManager;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IAsyncDelayer"/> for the <see cref="FileTransferService"/>.
|
||||
/// </summary>
|
||||
readonly IAsyncDelayer asyncDelayer;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ILogger"/> for the <see cref="FileTransferService"/>.
|
||||
/// </summary>
|
||||
readonly ILogger<FileTransferService> logger;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Dictionary{TKey, TValue}"/> of <see cref="FileTicketResult.FileTicket"/>s to upload <see cref="Stream"/> <see cref="TaskCompletionSource{TResult}"/>s.
|
||||
/// </summary>
|
||||
readonly Dictionary<string, FileUploadProvider> uploadTickets;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Dictionary{TKey, TValue}"/> of <see cref="FileTicketResult.FileTicket"/>s to <see cref="FileDownloadProvider"/>s.
|
||||
/// </summary>
|
||||
readonly Dictionary<string, FileDownloadProvider> downloadTickets;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CancellationTokenSource"/> that is triggered when <see cref="IAsyncDisposable.DisposeAsync"/> is called.
|
||||
/// </summary>
|
||||
readonly CancellationTokenSource disposeCts;
|
||||
|
||||
/// <summary>
|
||||
/// <see langword="lock"/> <see cref="object"/> used to update <see cref="expireTask"/>.
|
||||
/// </summary>
|
||||
readonly object synchronizationLock;
|
||||
|
||||
/// <summary>
|
||||
/// Combined <see cref="Task"/> of all <see cref="QueueExpiry(Action)"/> calls.
|
||||
/// </summary>
|
||||
Task expireTask;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileTransferService"/> <see langword="class"/>.
|
||||
/// </summary>
|
||||
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
|
||||
/// <param name="ioManager">The value of <see cref="ioManager"/>.</param>
|
||||
/// <param name="asyncDelayer">The value of <see cref="asyncDelayer"/>.</param>
|
||||
/// <param name="logger">The value of <see cref="logger"/>.</param>
|
||||
public FileTransferService(
|
||||
ICryptographySuite cryptographySuite,
|
||||
IIOManager ioManager,
|
||||
IAsyncDelayer asyncDelayer,
|
||||
ILogger<FileTransferService> logger)
|
||||
{
|
||||
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
|
||||
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
|
||||
this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
uploadTickets = new Dictionary<string, FileUploadProvider>();
|
||||
downloadTickets = new Dictionary<string, FileDownloadProvider>();
|
||||
|
||||
disposeCts = new CancellationTokenSource();
|
||||
|
||||
expireTask = Task.CompletedTask;
|
||||
synchronizationLock = new object();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Task toAwait;
|
||||
lock (synchronizationLock)
|
||||
if (expireTask != null)
|
||||
{
|
||||
disposeCts.Cancel();
|
||||
disposeCts.Dispose();
|
||||
toAwait = expireTask;
|
||||
expireTask = null;
|
||||
}
|
||||
else
|
||||
toAwait = Task.CompletedTask;
|
||||
|
||||
await toAwait.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="FileTicketResult"/>.
|
||||
/// </summary>
|
||||
/// <returns>A new <see cref="FileTicketResult"/>.</returns>
|
||||
FileTicketResult CreateTicket() => new FileTicketResult
|
||||
{
|
||||
FileTicket = cryptographySuite.GetSecureString()
|
||||
};
|
||||
|
||||
void QueueExpiry(Action expireAction)
|
||||
{
|
||||
Task oldExpireTask = null;
|
||||
|
||||
async Task ExpireAsync()
|
||||
{
|
||||
var expireAt = DateTimeOffset.Now + TimeSpan.FromMinutes(TicketValidityMinutes);
|
||||
try
|
||||
{
|
||||
await oldExpireTask.WithToken(disposeCts.Token).ConfigureAwait(false);
|
||||
|
||||
var now = DateTimeOffset.Now;
|
||||
if (now < expireAt)
|
||||
await asyncDelayer.Delay(expireAt - now, disposeCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
expireAction();
|
||||
}
|
||||
}
|
||||
|
||||
lock (synchronizationLock)
|
||||
{
|
||||
oldExpireTask = expireTask;
|
||||
expireTask = ExpireAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public FileTicketResult CreateDownload(FileDownloadProvider downloadProvider)
|
||||
{
|
||||
if (downloadProvider == null)
|
||||
throw new ArgumentNullException(nameof(downloadProvider));
|
||||
|
||||
logger.LogDebug("Creating download ticket for path {0}", downloadProvider.FilePath);
|
||||
var ticketResult = CreateTicket();
|
||||
|
||||
lock (downloadTickets)
|
||||
downloadTickets.Add(ticketResult.FileTicket, downloadProvider);
|
||||
|
||||
QueueExpiry(() =>
|
||||
{
|
||||
lock (downloadTickets)
|
||||
if(downloadTickets.Remove(ticketResult.FileTicket))
|
||||
logger.LogTrace("Expired download ticket {0}...", ticketResult.FileTicket);
|
||||
});
|
||||
|
||||
logger.LogTrace("Created download ticket {0}", ticketResult.FileTicket);
|
||||
|
||||
return ticketResult;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IFileUploadTicket CreateUpload(bool requireSynchronousIO)
|
||||
{
|
||||
logger.LogDebug("Creating upload ticket...");
|
||||
var uploadTicket = new FileUploadProvider(CreateTicket(), requireSynchronousIO);
|
||||
|
||||
lock (uploadTickets)
|
||||
uploadTickets.Add(uploadTicket.Ticket.FileTicket, uploadTicket);
|
||||
|
||||
QueueExpiry(() =>
|
||||
{
|
||||
lock (uploadTickets)
|
||||
if (uploadTickets.Remove(uploadTicket.Ticket.FileTicket))
|
||||
logger.LogTrace("Expired upload ticket {0}...", uploadTicket.Ticket.FileTicket);
|
||||
else
|
||||
return;
|
||||
|
||||
uploadTicket.Expire();
|
||||
});
|
||||
|
||||
logger.LogTrace("Created upload ticket {0}", uploadTicket.Ticket.FileTicket);
|
||||
|
||||
return uploadTicket;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Tuple<FileStream, ErrorMessage>> RetrieveDownloadStream(FileTicketResult ticket, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ticket == null)
|
||||
throw new ArgumentNullException(nameof(ticket));
|
||||
|
||||
FileDownloadProvider downloadProvider;
|
||||
lock (downloadTickets)
|
||||
{
|
||||
if (!downloadTickets.TryGetValue(ticket.FileTicket, out downloadProvider))
|
||||
{
|
||||
logger.LogTrace("Download ticket {0} not found!", ticket.FileTicket);
|
||||
return Tuple.Create<FileStream, ErrorMessage>(null, null);
|
||||
}
|
||||
|
||||
downloadTickets.Remove(ticket.FileTicket);
|
||||
}
|
||||
|
||||
var errorCode = downloadProvider.ActivationCallback();
|
||||
if (errorCode.HasValue)
|
||||
{
|
||||
logger.LogDebug("Download ticket {0} failed activation!", ticket.FileTicket);
|
||||
return Tuple.Create<FileStream, ErrorMessage>(null, new ErrorMessage(errorCode.Value));
|
||||
}
|
||||
|
||||
FileStream stream;
|
||||
try
|
||||
{
|
||||
if (downloadProvider.FileStreamProvider != null)
|
||||
stream = await downloadProvider.FileStreamProvider(cancellationToken).ConfigureAwait(false);
|
||||
else
|
||||
stream = ioManager.GetFileStream(downloadProvider.FilePath, downloadProvider.ShareWrite);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
return Tuple.Create<FileStream, ErrorMessage>(
|
||||
null,
|
||||
new ErrorMessage(ErrorCode.IOError)
|
||||
{
|
||||
AdditionalData = ex.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
logger.LogTrace("Ticket {0} downloading...", ticket.FileTicket);
|
||||
return Tuple.Create<FileStream, ErrorMessage>(stream, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
stream.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ErrorMessage> SetUploadStream(FileTicketResult ticket, Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ticket == null)
|
||||
throw new ArgumentNullException(nameof(ticket));
|
||||
|
||||
FileUploadProvider uploadProvider;
|
||||
lock (uploadTickets)
|
||||
{
|
||||
if (!uploadTickets.TryGetValue(ticket.FileTicket, out uploadProvider))
|
||||
{
|
||||
logger.LogTrace("Upload ticket {0} not found!", ticket.FileTicket);
|
||||
return new ErrorMessage(ErrorCode.ResourceNotPresent);
|
||||
}
|
||||
|
||||
uploadTickets.Remove(ticket.FileTicket);
|
||||
}
|
||||
|
||||
return await uploadProvider.Completion(stream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api.Models;
|
||||
using Tgstation.Server.Host.Extensions;
|
||||
using Tgstation.Server.Host.IO;
|
||||
|
||||
namespace Tgstation.Server.Host.Transfer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
sealed class FileUploadProvider : IFileUploadTicket
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public FileTicketResult Ticket { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="CancellationTokenSource"/> for the ticket duration.
|
||||
/// </summary>
|
||||
readonly CancellationTokenSource ticketExpiryCts;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="TaskCompletionSource{TResult}"/> for the <see cref="Stream"/>.
|
||||
/// </summary>
|
||||
readonly TaskCompletionSource<Stream> taskCompletionSource;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="TaskCompletionSource{TResult}"/> that completes in <see cref="IDisposable.Dispose"/> or when <see cref="SetErrorMessage(ErrorMessage)"/> is called.
|
||||
/// </summary>
|
||||
readonly TaskCompletionSource<object> completionTcs;
|
||||
|
||||
/// <summary>
|
||||
/// If synchronous IO is required. Uses a <see cref="FileBufferingReadStream"/> as a backend if set.
|
||||
/// </summary>
|
||||
readonly bool requireSynchronousIO;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ErrorMessage"/> that occurred while processing the upload if any.
|
||||
/// </summary>
|
||||
ErrorMessage errorMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileUploadProvider"/> <see langword="class"/>.
|
||||
/// </summary>
|
||||
/// <param name="ticket">The value of <see cref="Ticket"/>.</param>
|
||||
/// <param name="requireSynchronousIO">The value of <see cref="requireSynchronousIO"/></param>
|
||||
public FileUploadProvider(FileTicketResult ticket, bool requireSynchronousIO)
|
||||
{
|
||||
Ticket = ticket ?? throw new ArgumentNullException(nameof(ticket));
|
||||
|
||||
ticketExpiryCts = new CancellationTokenSource();
|
||||
taskCompletionSource = new TaskCompletionSource<Stream>();
|
||||
completionTcs = new TaskCompletionSource<object>();
|
||||
this.requireSynchronousIO = requireSynchronousIO;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
ticketExpiryCts.Dispose();
|
||||
completionTcs.TrySetResult(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> GetResult(CancellationToken cancellationToken)
|
||||
{
|
||||
using (cancellationToken.Register(() => taskCompletionSource.TrySetCanceled()))
|
||||
using (ticketExpiryCts.Token.Register(() => taskCompletionSource.TrySetResult(null)))
|
||||
return await taskCompletionSource.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expire the <see cref="FileUploadProvider"/>.
|
||||
/// </summary>
|
||||
public void Expire()
|
||||
{
|
||||
if (!completionTcs.Task.IsCompleted)
|
||||
ticketExpiryCts.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the <paramref name="stream"/> for the <see cref="FileUploadProvider"/> and awaits the upload.
|
||||
/// </summary>
|
||||
/// <param name="stream">The <see cref="Stream"/> containing uploaded data.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in <see langword="null"/>, <see cref="ErrorMessage"/> otherwise.</returns>
|
||||
public async Task<ErrorMessage> Completion(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
if (stream == null)
|
||||
throw new ArgumentNullException(nameof(stream));
|
||||
|
||||
if (ticketExpiryCts.IsCancellationRequested)
|
||||
return new ErrorMessage(ErrorCode.ResourceNotPresent);
|
||||
|
||||
Stream bufferedStream = null;
|
||||
if (requireSynchronousIO)
|
||||
{
|
||||
// big reads, we should buffer to disk
|
||||
bufferedStream = new FileBufferingReadStream(stream, DefaultIOManager.DefaultBufferSize);
|
||||
await bufferedStream.DrainAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using (bufferedStream)
|
||||
{
|
||||
taskCompletionSource.TrySetResult(bufferedStream ?? stream);
|
||||
|
||||
await completionTcs.Task.WithToken(cancellationToken).ConfigureAwait(false);
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetErrorMessage(ErrorMessage errorMessage)
|
||||
{
|
||||
if (errorMessage == null)
|
||||
#pragma warning disable IDE0016 // Use 'throw' expression
|
||||
throw new ArgumentNullException(nameof(errorMessage));
|
||||
#pragma warning restore IDE0016 // Use 'throw' expression
|
||||
|
||||
if (this.errorMessage != null)
|
||||
throw new InvalidOperationException("ErrorMessage already set!");
|
||||
|
||||
this.errorMessage = errorMessage;
|
||||
completionTcs.TrySetResult(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api.Models;
|
||||
|
||||
namespace Tgstation.Server.Host.Transfer
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads and writes to <see cref="Stream"/>s associated with <see cref="FileTicketResult"/>s.
|
||||
/// </summary>
|
||||
public interface IFileTransferStreamHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the <see cref="Stream"/> for a given <paramref name="ticket"/> associated with a pending upload.
|
||||
/// </summary>
|
||||
/// <param name="ticket">The <see cref="FileTicketResult"/>.</param>
|
||||
/// <param name="stream">The <see cref="Stream"/> with uploaded data.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns><see langword="null"/> if the upload completed successfully, <see cref="ErrorMessage"/> otherwise.</returns>
|
||||
Task<ErrorMessage> SetUploadStream(FileTicketResult ticket, Stream stream, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the the <see cref="Stream"/> for a given <paramref name="ticket"/> associated with a pending download.
|
||||
/// </summary>
|
||||
/// <param name="ticket">The <see cref="FileTicketResult"/>.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="Tuple{T1, T2}"/> containing either a <see cref="Stream"/> containing the data to download or an <see cref="ErrorMessage"/> to return.</returns>
|
||||
Task<Tuple<FileStream, ErrorMessage>> RetrieveDownloadStream(FileTicketResult ticket, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Tgstation.Server.Api.Models;
|
||||
|
||||
namespace Tgstation.Server.Host.Transfer
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for temporarily storing files to be downloaded or uploaded.
|
||||
/// </summary>
|
||||
public interface IFileTransferTicketProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a <see cref="FileTicketResult"/> for a download.
|
||||
/// </summary>
|
||||
/// <param name="fileDownloadProvider">The <see cref="FileDownloadProvider"/>.</param>
|
||||
/// <returns>A new <see cref="FileTicketResult"/> for a download.</returns>
|
||||
FileTicketResult CreateDownload(FileDownloadProvider fileDownloadProvider);
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="IFileUploadTicket"/>.
|
||||
/// </summary>
|
||||
/// <param name="requiresSynchronousIO">If synchronous IO is required on the provided stream.</param>
|
||||
/// <returns>A new <see cref="IFileUploadTicket"/>.</returns>
|
||||
IFileUploadTicket CreateUpload(bool requiresSynchronousIO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Api.Models;
|
||||
|
||||
namespace Tgstation.Server.Host.Transfer
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="FileTicketResult"/> that waits for a pending upload.
|
||||
/// </summary>
|
||||
public interface IFileUploadTicket : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="FileTicketResult"/>.
|
||||
/// </summary>
|
||||
FileTicketResult Ticket { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="Stream"/> for the uploaded file.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the uploaded <see cref="Stream"/> of the file on success, <see langword="null"/> if the ticket timed out.</returns>
|
||||
/// <remarks>The resulting <see cref="Stream"/> is short lived and should be buffered if it needs use outside the lifetime of the <see cref="IFileUploadTicket"/>.</remarks>
|
||||
Task<Stream> GetResult(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets an <paramref name="errorMessage"/> for the upload. Will be returned in upload request as a 409 error.
|
||||
/// </summary>
|
||||
/// <param name="errorMessage">The <see cref="ErrorMessage"/> to set.</param>
|
||||
void SetErrorMessage(ErrorMessage errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,12 @@ namespace Tgstation.Server.Tests
|
||||
var logFile = logs.First();
|
||||
Assert.IsNotNull(logFile);
|
||||
Assert.IsFalse(String.IsNullOrWhiteSpace(logFile.Name));
|
||||
Assert.IsNull(logFile.Content);
|
||||
Assert.IsNull(logFile.FileTicket);
|
||||
|
||||
var downloaded = await client.GetLog(logFile, cancellationToken);
|
||||
Assert.AreEqual(logFile.Name, downloaded.Name);
|
||||
Assert.IsTrue(logFile.LastModified <= downloaded.LastModified);
|
||||
Assert.IsNull(logFile.Content);
|
||||
var downloadedTuple = await client.GetLog(logFile, cancellationToken);
|
||||
Assert.AreEqual(logFile.Name, downloadedTuple.Item1.Name);
|
||||
Assert.IsTrue(logFile.LastModified <= downloadedTuple.Item1.LastModified);
|
||||
Assert.IsNull(logFile.FileTicket);
|
||||
|
||||
await ApiAssert.ThrowsException<ConflictException>(() => client.GetLog(new LogFile
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Castle.Core.Logging;
|
||||
using Castle.Core.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Moq;
|
||||
@@ -45,7 +45,7 @@ namespace Tgstation.Server.Tests.Instance
|
||||
{
|
||||
Version = new Version(5011, 1385)
|
||||
};
|
||||
var test = await byondClient.SetActiveVersion(newModel, cancellationToken).ConfigureAwait(false);
|
||||
var test = await byondClient.SetActiveVersion(newModel, null, cancellationToken).ConfigureAwait(false);
|
||||
Assert.IsNotNull(test.InstallJob);
|
||||
await WaitForJob(test.InstallJob, 60, true, ErrorCode.ByondDownloadFail, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ namespace Tgstation.Server.Tests.Instance
|
||||
{
|
||||
Version = TestVersion
|
||||
};
|
||||
var test = await byondClient.SetActiveVersion(newModel, cancellationToken).ConfigureAwait(false);
|
||||
var test = await byondClient.SetActiveVersion(newModel, null, cancellationToken).ConfigureAwait(false);
|
||||
Assert.IsNotNull(test.InstallJob);
|
||||
Assert.IsNull(test.Version);
|
||||
await WaitForJob(test.InstallJob, 60, false, null, cancellationToken).ConfigureAwait(false);
|
||||
@@ -98,11 +98,18 @@ namespace Tgstation.Server.Tests.Instance
|
||||
Mock.Of<ILogger<PosixByondInstaller>>());
|
||||
|
||||
// get the bytes for stable
|
||||
var test = await byondClient.SetActiveVersion(new Api.Models.Byond
|
||||
{
|
||||
Version = TestVersion,
|
||||
Content = await byondInstaller.DownloadVersion(TestVersion, cancellationToken)
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
using var stableBytesMs = new MemoryStream(
|
||||
await byondInstaller.DownloadVersion(TestVersion, cancellationToken));
|
||||
|
||||
var test = await byondClient.SetActiveVersion(
|
||||
new Api.Models.Byond
|
||||
{
|
||||
Version = TestVersion,
|
||||
UploadCustomZip = true
|
||||
},
|
||||
stableBytesMs,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.IsNotNull(test.InstallJob);
|
||||
await WaitForJob(test.InstallJob, 60, false, null, cancellationToken).ConfigureAwait(false);
|
||||
@@ -114,17 +121,17 @@ namespace Tgstation.Server.Tests.Instance
|
||||
newSettings = await byondClient.SetActiveVersion(new Api.Models.Byond
|
||||
{
|
||||
Version = TestVersion
|
||||
}, cancellationToken);
|
||||
}, null, cancellationToken);
|
||||
Assert.IsNull(newSettings.InstallJob);
|
||||
await ApiAssert.ThrowsException<ApiConflictException>(() => byondClient.SetActiveVersion(new Api.Models.Byond
|
||||
{
|
||||
Version = new Version(TestVersion.Major, TestVersion.Minor, 2)
|
||||
}, cancellationToken), ErrorCode.ByondNonExistentCustomVersion);
|
||||
}, null, cancellationToken), ErrorCode.ByondNonExistentCustomVersion);
|
||||
|
||||
newSettings = await byondClient.SetActiveVersion(new Api.Models.Byond
|
||||
{
|
||||
Version = new Version(TestVersion.Major, TestVersion.Minor, 1)
|
||||
}, cancellationToken);
|
||||
}, null, cancellationToken);
|
||||
Assert.IsNull(newSettings.InstallJob);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Mime;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -25,7 +28,8 @@ namespace Tgstation.Server.Tests.Instance
|
||||
{
|
||||
var tmp = (file.Path?.StartsWith('/') ?? false) ? '.' + file.Path : file.Path;
|
||||
var path = Path.Combine(instance.Path, "Configuration", tmp);
|
||||
return File.Exists(path);
|
||||
var result = File.Exists(path);
|
||||
return result;
|
||||
}
|
||||
|
||||
async Task TestDeleteDirectory(CancellationToken cancellationToken)
|
||||
@@ -39,20 +43,43 @@ namespace Tgstation.Server.Tests.Instance
|
||||
await configurationClient.DeleteEmptyDirectory(TestDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
//try to delete non-empty
|
||||
const string TestString = "Hello world!";
|
||||
using var uploadMs = new MemoryStream(Encoding.UTF8.GetBytes(TestString));
|
||||
var file = await configurationClient.Write(new ConfigurationFile
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("Hello world!"),
|
||||
Path = TestDir.Path + "/test.txt"
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}, uploadMs, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Assert.IsTrue(FileExists(file));
|
||||
Assert.IsNull(file.LastReadHash);
|
||||
|
||||
var updatedFileTuple = await configurationClient.Read(file, cancellationToken).ConfigureAwait(false);
|
||||
var updatedFile = updatedFileTuple.Item1;
|
||||
Assert.IsNotNull(updatedFile.LastReadHash);
|
||||
using (var downloadMemoryStream = new MemoryStream())
|
||||
{
|
||||
using (var downloadStream = updatedFileTuple.Item2)
|
||||
{
|
||||
var requestStream = downloadStream as CachedResponseStream;
|
||||
Assert.IsNotNull(requestStream);
|
||||
var response = (HttpResponseMessage)requestStream.GetType().GetField("response", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(requestStream);
|
||||
Assert.AreEqual(response.Content.Headers.ContentType.MediaType, MediaTypeNames.Application.Octet);
|
||||
await downloadStream.CopyToAsync(downloadMemoryStream);
|
||||
}
|
||||
Assert.AreEqual(TestString, Encoding.UTF8.GetString(downloadMemoryStream.ToArray()).Trim());
|
||||
}
|
||||
|
||||
await ApiAssert.ThrowsException<ConflictException>(() => configurationClient.DeleteEmptyDirectory(TestDir, cancellationToken), ErrorCode.ConfigurationDirectoryNotEmpty).ConfigureAwait(false);
|
||||
|
||||
file.Content = null;
|
||||
await configurationClient.Write(file, cancellationToken).ConfigureAwait(false);
|
||||
file.FileTicket = null;
|
||||
await configurationClient.Write(updatedFile, null, cancellationToken).ConfigureAwait(false);
|
||||
Assert.IsFalse(FileExists(file));
|
||||
|
||||
await configurationClient.DeleteEmptyDirectory(TestDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tmp = (TestDir.Path?.StartsWith('/') ?? false) ? '.' + TestDir.Path : TestDir.Path;
|
||||
var path = Path.Combine(instance.Path, "Configuration", tmp);
|
||||
Assert.IsFalse(Directory.Exists(path));
|
||||
}
|
||||
|
||||
public async Task Run(CancellationToken cancellationToken)
|
||||
|
||||
@@ -79,7 +79,7 @@ namespace Tgstation.Server.Tests.Instance
|
||||
|
||||
clone = await repositoryClient.Clone(initalRepo, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WaitForJob(clone.ActiveJob, 180, false, null, cancellationToken).ConfigureAwait(false);
|
||||
await WaitForJob(clone.ActiveJob, 600, false, null, cancellationToken).ConfigureAwait(false);
|
||||
var cloned = await repositoryClient.Read(cancellationToken);
|
||||
|
||||
Assert.AreEqual(Origin, cloned.Origin);
|
||||
|
||||
@@ -396,6 +396,7 @@ namespace Tgstation.Server.Tests.Instance
|
||||
{
|
||||
Version = versionToInstall
|
||||
},
|
||||
null,
|
||||
cancellationToken);
|
||||
var byondInstallJob = await byondInstallJobTask;
|
||||
|
||||
|
||||
@@ -194,11 +194,86 @@ namespace Tgstation.Server.Tests
|
||||
Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
async Task TestInvalidTransfers(IServerClient serverClient, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = serverClient.Url;
|
||||
var token = serverClient.Token.Bearer;
|
||||
// check that 400s are returned appropriately
|
||||
using var httpClient = new HttpClient();
|
||||
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.Transfer.Substring(1)))
|
||||
{
|
||||
request.Headers.Accept.Clear();
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
|
||||
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token);
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var message = JsonConvert.DeserializeObject<ErrorMessage>(content);
|
||||
Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType);
|
||||
Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode);
|
||||
}
|
||||
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Put, url.ToString() + Routes.Transfer.Substring(1)))
|
||||
{
|
||||
request.Headers.Accept.Clear();
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
|
||||
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token);
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var message = JsonConvert.DeserializeObject<ErrorMessage>(content);
|
||||
Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType);
|
||||
Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode);
|
||||
}
|
||||
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.Transfer.Substring(1) + "?ticket=veryfaketicket"))
|
||||
{
|
||||
request.Headers.Accept.Clear();
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
|
||||
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token);
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType);
|
||||
Assert.AreEqual(HttpStatusCode.NotAcceptable, response.StatusCode);
|
||||
}
|
||||
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.Transfer.Substring(1) + "?ticket=veryfaketicket"))
|
||||
{
|
||||
request.Headers.Accept.Clear();
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Octet));
|
||||
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token);
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType);
|
||||
Assert.AreEqual(HttpStatusCode.Gone, response.StatusCode);
|
||||
}
|
||||
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Put, url.ToString() + Routes.Transfer.Substring(1) + "?ticket=veryfaketicket"))
|
||||
{
|
||||
request.Headers.Accept.Clear();
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
|
||||
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token);
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType);
|
||||
Assert.AreEqual(HttpStatusCode.Gone, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
public Task Run(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken)
|
||||
=> Task.WhenAll(
|
||||
TestRequestValidation(serverClient, cancellationToken),
|
||||
TestOAuthFails(serverClient, cancellationToken),
|
||||
TestServerInformation(clientFactory, serverClient, cancellationToken));
|
||||
TestServerInformation(clientFactory, serverClient, cancellationToken),
|
||||
TestInvalidTransfers(serverClient, cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user