Skip to content
This repository was archived by the owner on Sep 28, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions src/StreamMaster.API/ConfigureServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,20 +113,6 @@ public static IServiceCollection AddWebUIServices(this IServiceCollection servic
AllowAutoRedirect = true
});

services.AddHttpClient(nameof(SchedulesDirectHttpService), client =>
{
client.BaseAddress = new Uri("https://json.schedulesdirect.org/20141201/");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip"));
client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate"));
client.DefaultRequestHeaders.ExpectContinue = true;
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
AllowAutoRedirect = true,
});

services.AddControllersWithViews();
services.AddRazorPages();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ public interface ISchedulesDirectHttpService

Task<bool> RefreshTokenAsync(CancellationToken cancellationToken);

Task<HttpResponseMessage> SendRawRequestAsync(HttpRequestMessage message, CancellationToken cancellationToken = default(CancellationToken));
Task<HttpResponseMessage> SendRawRequestAsync(HttpRequestMessage message, CancellationToken cancellationToken = default, bool authenticationRequired = false);

Task<T?> SendRequestAsync<T>(APIMethod method, string endpoint, object? payload = null, CancellationToken cancellationToken = default(CancellationToken));
Task<T?> SendRequestAsync<T>(APIMethod method, string endpoint, object? payload = null, CancellationToken cancellationToken = default, bool authenticationRequired = false);

Task<bool> ValidateTokenAsync(bool forceReset = false, CancellationToken cancellationToken = default(CancellationToken));
}
Expand Down
21 changes: 21 additions & 0 deletions src/StreamMaster.SchedulesDirect.Services/ConfigureServices.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Headers;

namespace StreamMaster.SchedulesDirect.Services;

public static class ConfigureServices
{
public static IServiceCollection AddSchedulesDirectAPIServices(this IServiceCollection services)
{
services.AddHttpClient(nameof(SchedulesDirectHttpService), client =>
{
client.BaseAddress = new Uri("https://json.schedulesdirect.org/20141201/");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip"));
client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate"));
client.DefaultRequestHeaders.ExpectContinue = true;
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
AllowAutoRedirect = true,
})
.AddHttpMessageHandler<TokenHandler>()
.AddHttpMessageHandler<RedirectTokenPreservingHandler>();

return services
.AddSingleton<ITokenStore, TokenStore>()
.AddTransient<RedirectTokenPreservingHandler>()
.AddTransient<TokenHandler>()
.AddSingleton<ISchedulesDirectAPIService, SchedulesDirectAPIService>()
.AddSingleton<ISchedulesDirectRepository, SchedulesDirectRepository>()
.AddSingleton<IApiErrorManager, ApiErrorManager>()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Net;

namespace StreamMaster.SchedulesDirect.Services
{
public class RedirectTokenPreservingHandler : DelegatingHandler
{
private readonly ILogger<RedirectTokenPreservingHandler> _logger;

public RedirectTokenPreservingHandler(ILogger<RedirectTokenPreservingHandler> logger)
{
_logger = logger;
}

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var tokenValue = string.Empty;
var hasToken = request.Headers.TryGetValues("token", out var tokenValues);
if (hasToken)
{
tokenValue = tokenValues.Single();
_logger.LogTrace("Request to {Uri} contains token header", request.RequestUri);
}

_logger.LogTrace("Sending request to {Uri} with method {Method}", request.RequestUri, request.Method);

var response = await base.SendAsync(request, cancellationToken);

_logger.LogTrace("Received response from {Uri} with status {StatusCode}",
request.RequestUri, response.StatusCode);

if (IsRedirect(response))
{
var redirectLocation = response.Headers.Location;
_logger.LogTrace("Redirect detected: {StatusCode} from {OriginalUri} to {RedirectUri}",
response.StatusCode, request.RequestUri, redirectLocation);

if (!string.IsNullOrEmpty(tokenValue))
{
_logger.LogDebug("Preserving token header during redirect from {OriginalUri} to {RedirectUri}",
request.RequestUri, redirectLocation);

var newRequest = new HttpRequestMessage(request.Method, redirectLocation);

var headerCount = 0;
foreach (var header in request.Headers)
{
newRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
headerCount++;
}

newRequest.Headers.TryAddWithoutValidation("token", tokenValue);

_logger.LogDebug("Copied {HeaderCount} headers to redirect request, including preserved token", headerCount);

response.Dispose();
return await SendAsync(newRequest, cancellationToken);
}
else
{
_logger.LogWarning("Redirect detected but no token to preserve from {OriginalUri} to {RedirectUri}",
request.RequestUri, redirectLocation);
}
}

return response;
}

private static bool IsRedirect(HttpResponseMessage response) =>
response.StatusCode == HttpStatusCode.Redirect ||
response.StatusCode == HttpStatusCode.MovedPermanently ||
response.StatusCode == HttpStatusCode.Found ||
response.StatusCode == HttpStatusCode.TemporaryRedirect ||
response.StatusCode == HttpStatusCode.PermanentRedirect;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,38 @@ namespace StreamMaster.SchedulesDirect.Services;
/// </summary>
public class SchedulesDirectHttpService : ISchedulesDirectHttpService, IDisposable
{
private readonly ITokenStore _tokenStore;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SchedulesDirectHttpService> _logger;
private readonly IOptionsMonitor<SDSettings> _sdSettings;
private readonly IDataRefreshService _dataRefreshService;
private readonly IApiErrorManager _apiErrorManager;
private readonly SemaphoreSlim _tokenSemaphore = new(1, 1);
private bool _disposed;
private static readonly HttpRequestOptionsKey<object?> SkipTokenAuthKey = new("SkipTokenAuth");

public string? Token { get; private set; }
public string? Token => _tokenStore.Token; // ✅ Fixed: Use token store
public DateTime TokenTimestamp { get; private set; }
public bool GoodToken { get; private set; }
public bool IsReady => !_disposed && _sdSettings.CurrentValue.TokenErrorTimestamp < SMDT.UtcNow;
public bool IsReady => !_disposed && _sdSettings.CurrentValue.ErrorCooldowns.All(errorCooldown => errorCooldown.CooldownUntil <= SMDT.UtcNow); // ✅ Fixed: <= not >

public SchedulesDirectHttpService(
IHttpClientFactory httpClientFactory,
ILogger<SchedulesDirectHttpService> logger,
IOptionsMonitor<SDSettings> sdSettings,
IDataRefreshService dataRefreshService,
IApiErrorManager apiErrorManager)
IApiErrorManager apiErrorManager,
ITokenStore tokenStore)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_sdSettings = sdSettings ?? throw new ArgumentNullException(nameof(sdSettings));
_dataRefreshService = dataRefreshService ?? throw new ArgumentNullException(nameof(dataRefreshService));
_apiErrorManager = apiErrorManager ?? throw new ArgumentNullException(nameof(apiErrorManager));
_tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
}

public async Task<T?> SendRequestAsync<T>(APIMethod method, string endpoint, object? payload = null, CancellationToken cancellationToken = default)
public async Task<T?> SendRequestAsync<T>(APIMethod method, string endpoint, object? payload = null, CancellationToken cancellationToken = default, bool authenticationRequired = false)
{
if (_disposed)
{
Expand Down Expand Up @@ -85,7 +89,7 @@ public SchedulesDirectHttpService(
"application/json");
}

using var httpClient = GetHttpClient();
var httpClient = GetHttpClient(authenticationRequired);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken);

Expand Down Expand Up @@ -115,7 +119,7 @@ public SchedulesDirectHttpService(
}
}

public async Task<HttpResponseMessage> SendRawRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
public async Task<HttpResponseMessage> SendRawRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken = default, bool authenticationRequired = false)
{
if (_disposed)
{
Expand Down Expand Up @@ -148,11 +152,12 @@ public async Task<HttpResponseMessage> SendRawRequestAsync(HttpRequestMessage re

try
{
using var httpClient = GetHttpClient();
var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var httpClient = GetHttpClient(authenticationRequired);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken);

var clonedRequest = CloneHttpRequest(request);
var clonedRequest = await CloneHttpRequestAsync(request);

HttpResponseMessage response = await httpClient
.SendAsync(clonedRequest, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token)
.ConfigureAwait(false);
Expand Down Expand Up @@ -247,6 +252,13 @@ private HttpResponseMessage HandleHttpResponseError(HttpResponseMessage response
break;

case SDHttpResponseCode.TOKEN_MISSING:
// In case of a request being made where no token is added, ensure we don't block ourselves.
_apiErrorManager.SetCooldown(sdCode, SMDT.UtcNow.AddMinutes(10), "A request was made to SchedulesDirect without a token.");
response.StatusCode = HttpStatusCode.Unauthorized;
response.ReasonPhrase = "Unauthorized";
ClearToken();
break;

case SDHttpResponseCode.TOKEN_INVALID:
case SDHttpResponseCode.INVALID_USER:
case SDHttpResponseCode.TOKEN_EXPIRED:
Expand Down Expand Up @@ -338,28 +350,31 @@ public async Task<bool> RefreshTokenAsync(CancellationToken cancellationToken)
}
ClearToken();

using var httpClient = GetHttpClient();
// We are performing an authentication request, so authentication isn't required
var httpClient = GetHttpClient(authenticationRequired: false);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken);

using var response = await httpClient.PostAsJsonAsync(
"token",
new { username, password },
linkedCts.Token
).ConfigureAwait(false);
var requestPayload = JsonSerializer.Serialize(new { username, password });
using var request = new HttpRequestMessage(HttpMethod.Post, "token")
{
Content = new StringContent(requestPayload, Encoding.UTF8, "application/json")
};
request.Options.Set(SkipTokenAuthKey, true);

using var response = await httpClient.SendAsync(request, linkedCts.Token).ConfigureAwait(false);

TokenResponse? tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: linkedCts.Token);
if (response.IsSuccessStatusCode)
{
if (tokenResponse?.Code == 0 && !string.IsNullOrEmpty(tokenResponse.Token))
{
Token = tokenResponse.Token;
TokenTimestamp = tokenResponse.Datetime;
GoodToken = true;
SetToken(tokenResponse.Token, tokenResponse.Datetime);

_logger.LogInformation("Token refreshed successfully. Token={Token}...",
Token?[..Math.Min(5, Token.Length)]);
_sdSettings.CurrentValue.TokenErrorTimestamp = DateTime.MinValue;
//TODO: Clean up any login related error timeout
SettingsHelper.UpdateSetting(_sdSettings.CurrentValue);
await _dataRefreshService.RefreshSDReady().ConfigureAwait(false);
return true;
Expand Down Expand Up @@ -432,49 +447,69 @@ public async Task<bool> ValidateTokenAsync(bool forceReset = false, Cancellation

public void ClearToken()
{
Token = null;
_tokenStore.ClearToken();
GoodToken = false;
TokenTimestamp = DateTime.MinValue;
_logger.LogWarning("Token cleared.");
}

private HttpClient GetHttpClient()
private void SetToken(string token, DateTime timestamp)
{
_tokenStore.SetToken(token);
TokenTimestamp = timestamp;
GoodToken = true;
}

private HttpClient GetHttpClient(bool authenticationRequired = false)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SchedulesDirectHttpService));
}

var httpClient = _httpClientFactory.CreateClient(nameof(SchedulesDirectHttpService));

if (!string.IsNullOrEmpty(Token))
if (authenticationRequired && string.IsNullOrEmpty(Token))
{
httpClient.DefaultRequestHeaders.Remove("token");
httpClient.DefaultRequestHeaders.Add("token", Token);
throw new UnauthorizedAccessException("Auth token is found as empty, but caller expects authentication to be made.");
}

var httpClient = _httpClientFactory.CreateClient(nameof(SchedulesDirectHttpService));

httpClient.DefaultRequestHeaders.UserAgent.Clear();
string userAgent = _sdSettings.CurrentValue.UserAgent ?? "StreamMaster/1.0";
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);

httpClient.Timeout = TimeSpan.FromSeconds(30);

return httpClient;
return httpClient; // ✅ Fixed: Don't set timeout on factory-created client
}

private static HttpRequestMessage CloneHttpRequest(HttpRequestMessage request)
private static async Task<HttpRequestMessage> CloneHttpRequestAsync(HttpRequestMessage request)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri)
{
Content = request.Content,
Version = request.Version
};

if (request.Content != null)
{
var contentBytes = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
clone.Content = new ByteArrayContent(contentBytes);

// Copy content headers
foreach (var header in request.Content.Headers)
{
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}

foreach (var header in request.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}

foreach (var option in request.Options)
{
clone.Options.Set(new HttpRequestOptionsKey<object?>(option.Key), option.Value);
}

return clone;
}

Expand Down
Loading