Initial QMAX production app
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Data.Entities;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Auth;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
string CreateAccessToken(User user, UserSession session, DateTimeOffset expiresAt);
|
||||
string CreateRefreshToken();
|
||||
string HashRefreshToken(string refreshToken);
|
||||
}
|
||||
|
||||
public sealed class TokenService(IOptions<QMaxOptions> options) : ITokenService
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
public string CreateAccessToken(User user, UserSession session, DateTimeOffset expiresAt)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtSecret()));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim("sid", session.Id.ToString()),
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.DisplayName)
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _options.JwtIssuer,
|
||||
audience: _options.JwtAudience,
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,
|
||||
expires: expiresAt.UtcDateTime,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public string CreateRefreshToken()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[48];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
public string HashRefreshToken(string refreshToken)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
private string GetJwtSecret()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.JwtSecret) && _options.JwtSecret.Length >= 32)
|
||||
{
|
||||
return _options.JwtSecret;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
|
||||
{
|
||||
return "development-only-qmax-secret-change-before-production";
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("QMax:JwtSecret must be set to at least 32 characters in production.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Auth;
|
||||
|
||||
public static class UserContext
|
||||
{
|
||||
public static Guid GetUserId(this ClaimsPrincipal principal)
|
||||
{
|
||||
var value = principal.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? principal.FindFirstValue("sub")
|
||||
?? throw new InvalidOperationException("Authenticated user id is missing.");
|
||||
return Guid.Parse(value);
|
||||
}
|
||||
|
||||
public static Guid? GetSessionId(this ClaimsPrincipal principal)
|
||||
{
|
||||
var value = principal.FindFirstValue("sid");
|
||||
return Guid.TryParse(value, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Hubs;
|
||||
|
||||
[Authorize]
|
||||
public sealed class QMaxHub : Hub
|
||||
{
|
||||
public Task JoinChat(string chatId)
|
||||
{
|
||||
return Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{chatId}");
|
||||
}
|
||||
|
||||
public Task LeaveChat(string chatId)
|
||||
{
|
||||
return Groups.RemoveFromGroupAsync(Context.ConnectionId, $"chat:{chatId}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace QMax.Api.Infrastructure.Max;
|
||||
|
||||
public interface IMaxBridgeClient
|
||||
{
|
||||
Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken);
|
||||
Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken);
|
||||
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
|
||||
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
|
||||
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken);
|
||||
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken);
|
||||
Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken);
|
||||
Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken);
|
||||
Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken);
|
||||
Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken);
|
||||
Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
|
||||
Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
|
||||
Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace QMax.Api.Infrastructure.Max;
|
||||
|
||||
public sealed record MaxBridgeStatus(
|
||||
string Mode,
|
||||
bool IsAuthorized,
|
||||
string? LoginStage,
|
||||
string Status,
|
||||
string? Url,
|
||||
string? Title,
|
||||
string? LastError,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record MaxBrowserSnapshot(
|
||||
string? Url,
|
||||
string? Title,
|
||||
string BodyText,
|
||||
string ScreenshotPngBase64,
|
||||
DateTimeOffset CapturedAt);
|
||||
|
||||
public sealed record MaxChatUpdate(
|
||||
string ExternalId,
|
||||
string Title,
|
||||
string? AvatarUrl,
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyList<MaxMessageUpdate> Messages,
|
||||
string? LastMessagePreview = null,
|
||||
bool? LastMessageIsOutgoing = null,
|
||||
DateTimeOffset? LastMessageAt = null,
|
||||
string? LastMessageTimeText = null);
|
||||
|
||||
public sealed record MaxMessageUpdate(
|
||||
string ExternalId,
|
||||
string? SenderExternalId,
|
||||
string? SenderName,
|
||||
bool IsOutgoing,
|
||||
string? Text,
|
||||
DateTimeOffset SentAt,
|
||||
IReadOnlyList<MaxAttachmentUpdate>? Attachments = null,
|
||||
string? DeliveryState = null);
|
||||
|
||||
public sealed record MaxAttachmentUpdate(
|
||||
string ExternalId,
|
||||
string FileName,
|
||||
string? ContentType,
|
||||
long? FileSizeBytes,
|
||||
string? RemoteUrl,
|
||||
string? Kind,
|
||||
int SortOrder,
|
||||
string? TextContent = null);
|
||||
|
||||
public sealed record MaxSendResult(bool Success, string? ExternalMessageId, string? Error);
|
||||
|
||||
public sealed record MaxActionResult(bool Success, string? Error);
|
||||
|
||||
public sealed record MaxMediaDownload(
|
||||
Stream Stream,
|
||||
string ContentType,
|
||||
long? ContentLength);
|
||||
|
||||
public sealed record MaxChatPresence(
|
||||
bool IsTyping,
|
||||
string? StatusText,
|
||||
DateTimeOffset UpdatedAt);
|
||||
@@ -0,0 +1,93 @@
|
||||
namespace QMax.Api.Infrastructure.Max;
|
||||
|
||||
public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
||||
{
|
||||
private readonly List<MaxChatUpdate> _updates =
|
||||
[
|
||||
new MaxChatUpdate(
|
||||
"mock-qmax",
|
||||
"QMAX smoke chat",
|
||||
null,
|
||||
DateTimeOffset.UtcNow,
|
||||
[
|
||||
new MaxMessageUpdate(
|
||||
"mock-message-1",
|
||||
"max",
|
||||
"MAX",
|
||||
false,
|
||||
"Mock mode is active. Switch QMax:MaxMode to Playwright for real web.max.ru.",
|
||||
DateTimeOffset.UtcNow)
|
||||
])
|
||||
];
|
||||
|
||||
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxBridgeStatus("Mock", true, "Authorized", "MockReady", null, "Mock", null, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
return GetStatusAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
return GetStatusAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxBrowserSnapshot(null, "Mock", "Mock MAX bridge", "", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
|
||||
}
|
||||
|
||||
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
|
||||
return Task.FromResult(update);
|
||||
}
|
||||
|
||||
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<MaxChatPresence?>(new MaxChatPresence(false, null, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxSendResult(true, $"mock-out-{Guid.NewGuid():N}", null));
|
||||
}
|
||||
|
||||
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
}
|
||||
|
||||
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<MaxMediaDownload?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Max;
|
||||
|
||||
public sealed class WorkerMaxBridgeClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<QMaxOptions> options,
|
||||
ILogger<WorkerMaxBridgeClient> logger) : IMaxBridgeClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
public async Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxBridgeStatus>(HttpMethod.Get, "/status", null, cancellationToken)
|
||||
?? ErrorStatus("Worker returned an empty status.");
|
||||
}
|
||||
|
||||
public async Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber }, cancellationToken)
|
||||
?? ErrorStatus("Worker returned an empty login status.");
|
||||
}
|
||||
|
||||
public async Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/code", new { code, waitMs = 60000 }, cancellationToken)
|
||||
?? ErrorStatus("Worker returned an empty code status.");
|
||||
}
|
||||
|
||||
public async Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxBrowserSnapshot>(HttpMethod.Get, "/snapshot", null, cancellationToken)
|
||||
?? new MaxBrowserSnapshot(null, "Worker unavailable", "", "", DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<IReadOnlyList<MaxChatUpdate>>(HttpMethod.Get, "/updates", null, cancellationToken)
|
||||
?? Array.Empty<MaxChatUpdate>();
|
||||
}
|
||||
|
||||
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxChatUpdate>(HttpMethod.Post, "/chat/history", new { externalChatId }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxChatPresence>(HttpMethod.Post, "/chat/presence", new { externalChatId }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/text", new { externalChatId, text }, cancellationToken)
|
||||
?? new MaxSendResult(false, null, "Worker returned an empty send result.");
|
||||
}
|
||||
|
||||
public async Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/attachment", new { externalChatId, path = ToWorkerPath(path), caption }, cancellationToken)
|
||||
?? new MaxSendResult(false, null, "Worker returned an empty attachment result.");
|
||||
}
|
||||
|
||||
public async Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxActionResult>(
|
||||
HttpMethod.Post,
|
||||
"/message/edit",
|
||||
new { externalChatId, externalMessageId, currentText, text },
|
||||
cancellationToken)
|
||||
?? new MaxActionResult(false, "Worker returned an empty edit result.");
|
||||
}
|
||||
|
||||
public async Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxActionResult>(
|
||||
HttpMethod.Post,
|
||||
"/message/delete",
|
||||
new { externalChatId, externalMessageId, currentText },
|
||||
cancellationToken)
|
||||
?? new MaxActionResult(false, "Worker returned an empty delete result.");
|
||||
}
|
||||
|
||||
public async Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxActionResult>(
|
||||
HttpMethod.Post,
|
||||
"/message/reaction",
|
||||
new { externalChatId, externalMessageId, currentText, emoji },
|
||||
cancellationToken)
|
||||
?? new MaxActionResult(false, "Worker returned an empty reaction result.");
|
||||
}
|
||||
|
||||
public async Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxActionResult>(
|
||||
HttpMethod.Delete,
|
||||
"/message/reaction",
|
||||
new { externalChatId, externalMessageId, currentText, emoji },
|
||||
cancellationToken)
|
||||
?? new MaxActionResult(false, "Worker returned an empty reaction clear result.");
|
||||
}
|
||||
|
||||
public async Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(remoteUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
var path = $"media/fetch?url={Uri.EscapeDataString(remoteUrl)}";
|
||||
var response = await httpClient.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
logger.LogWarning("MAX worker media fetch failed with {Status}: {Content}", response.StatusCode, content);
|
||||
response.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream";
|
||||
var contentLength = response.Content.Headers.ContentLength;
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return new MaxMediaDownload(new ResponseDisposingStream(stream, response), contentType, contentLength);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "MAX worker media fetch request failed.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T?> SendAsync<T>(HttpMethod method, string path, object? body, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
logger.LogWarning("MAX worker {Path} failed with {Status}: {Content}", path, response.StatusCode, content);
|
||||
return default;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "MAX worker {Path} request failed.", path);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private static MaxBridgeStatus ErrorStatus(string error)
|
||||
{
|
||||
return new MaxBridgeStatus("Worker", false, "Unavailable", "WorkerUnavailable", null, null, error, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static string ToWorkerPath(string path)
|
||||
{
|
||||
var normalized = path.Replace('\\', '/');
|
||||
return normalized.StartsWith("/data/", StringComparison.Ordinal)
|
||||
? "/qmax-data/" + normalized["/data/".Length..]
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private sealed class ResponseDisposingStream(Stream inner, HttpResponseMessage response) : Stream
|
||||
{
|
||||
public override bool CanRead => inner.CanRead;
|
||||
public override bool CanSeek => inner.CanSeek;
|
||||
public override bool CanWrite => inner.CanWrite;
|
||||
public override long Length => inner.Length;
|
||||
public override long Position
|
||||
{
|
||||
get => inner.Position;
|
||||
set => inner.Position = value;
|
||||
}
|
||||
|
||||
public override void Flush() => inner.Flush();
|
||||
public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count);
|
||||
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
|
||||
public override void SetLength(long value) => inner.SetLength(value);
|
||||
public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count);
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) =>
|
||||
await inner.ReadAsync(buffer, cancellationToken);
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
inner.Dispose();
|
||||
response.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await inner.DisposeAsync();
|
||||
response.Dispose();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user