Merge pull request #1153 from tgstation/1064-ActualFileStreams

Replaces base64 file transfers with streaming
This commit is contained in:
Jordan Brown
2020-12-03 00:26:47 -05:00
committed by GitHub
54 changed files with 1741 additions and 231 deletions
+11 -3
View File
@@ -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
View File
@@ -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>
+14
View File
@@ -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"
+8 -4
View File
@@ -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
+7 -1
View 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
}
}
+8 -1
View File
@@ -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 -2
View File
@@ -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.
+6 -1
View 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;
}
}
}
}
+106 -13
View File
@@ -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"/>
@@ -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);
}
}
+28 -9
View File
@@ -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);
}
}
+14 -4
View File
@@ -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;
}
+5 -2
View File
@@ -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;
+76 -1
View File
@@ -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));
}
}