Skip to content

Commit 185b815

Browse files
committed
Initial work
1 parent 5622a28 commit 185b815

17 files changed

Lines changed: 144 additions & 200 deletions

File tree

API.IntegrationTests/IntegrationTestWebAppFactory.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ public async Task InitializeAsync()
4646
{ "OPENSHOCK__REDIS__PASSWORD", "" },
4747
{ "OPENSHOCK__REDIS__PORT", "6379" },
4848

49+
{ "OPENSHOCK__METRICS__ALLOWEDNETWORKS__0", "127.0.0.0/8" },
50+
4951
{ "OPENSHOCK__FRONTEND__BASEURL", "https://openshock.app" },
5052
{ "OPENSHOCK__FRONTEND__SHORTURL", "https://openshock.app" },
5153
{ "OPENSHOCK__FRONTEND__COOKIEDOMAIN", "openshock.app" },

API/Controller/Account/Login.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ public sealed partial class AccountController
2828
[MapToApiVersion("1")]
2929
public async Task<IActionResult> Login(
3030
[FromBody] Login body,
31-
[FromServices] IOptions<FrontendOptions> options,
31+
[FromServices] FrontendOptions options,
3232
CancellationToken cancellationToken)
3333
{
34-
var cookieDomainToUse = options.Value.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
34+
var cookieDomainToUse = options.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
3535
if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain);
3636

3737
var loginAction = await _accountService.CreateUserLoginSessionAsync(body.Email, body.Password, new LoginContext

API/Controller/Account/LoginV2.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ public sealed partial class AccountController
3333
public async Task<IActionResult> LoginV2(
3434
[FromBody] LoginV2 body,
3535
[FromServices] ICloudflareTurnstileService turnstileService,
36-
[FromServices] IOptions<FrontendOptions> options,
36+
[FromServices] FrontendOptions options,
3737
CancellationToken cancellationToken)
3838
{
39-
var cookieDomainToUse = options.Value.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
39+
var cookieDomainToUse = options.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
4040
if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain);
4141

4242
var remoteIP = HttpContext.GetRemoteIP();

API/Controller/Account/Logout.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,23 @@ public sealed partial class AccountController
1414
[MapToApiVersion("1")]
1515
public async Task<IActionResult> Logout(
1616
[FromServices] ISessionService sessionService,
17-
[FromServices] IOptions<FrontendOptions> options)
17+
[FromServices] FrontendOptions options)
1818
{
19-
var config = options.Value;
20-
2119
// Remove session if valid
2220
if (HttpContext.TryGetUserSessionToken(out var sessionToken))
2321
{
2422
await sessionService.DeleteSessionByTokenAsync(sessionToken);
2523
}
2624

2725
// Make sure cookie is removed, no matter if authenticated or not
28-
var cookieDomainToUse = config.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
26+
var cookieDomainToUse = options.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
2927
if (cookieDomainToUse is not null)
3028
{
3129
HttpContext.RemoveSessionKeyCookie("." + cookieDomainToUse);
3230
}
3331
else // Fallback to all domains
3432
{
35-
foreach (var domain in config.CookieDomain.Split(','))
33+
foreach (var domain in options.CookieDomain.Split(','))
3634
{
3735
HttpContext.RemoveSessionKeyCookie("." + domain);
3836
}

API/Controller/Version/_ApiController.cs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,35 +26,36 @@ public sealed partial class VersionController : OpenShockControllerBase
2626
/// </summary>
2727
/// <response code="200">The version was successfully retrieved.</response>
2828
[HttpGet]
29-
public LegacyDataResponse<ApiVersionResponse> GetBackendVersion(
30-
[FromServices] IOptions<FrontendOptions> frontendOptions,
29+
public LegacyDataResponse<BackendInfoResponse> GetBackendInfo(
30+
[FromServices] FrontendOptions frontendOptions,
3131
[FromServices] IOptions<TurnstileOptions> turnstileOptions
3232
)
3333
{
34-
var frontendConfig = frontendOptions.Value;
3534
var turnstileConfig = turnstileOptions.Value;
3635

37-
return new(
38-
new ApiVersionResponse
36+
return new LegacyDataResponse<BackendInfoResponse>(
37+
new BackendInfoResponse
3938
{
4039
Version = OpenShockBackendVersion,
4140
Commit = GitHashAttribute.FullHash,
4241
CurrentTime = DateTimeOffset.UtcNow,
43-
FrontendUrl = frontendConfig.BaseUrl,
44-
ShortLinkUrl = frontendConfig.ShortUrl,
45-
TurnstileSiteKey = turnstileConfig.SiteKey
42+
FrontendUrl = frontendOptions.BaseUrl,
43+
ShortLinkUrl = frontendOptions.ShortUrl,
44+
TurnstileSiteKey = turnstileConfig.SiteKey,
45+
IsUserAuthenticated = HttpContext.TryGetUserSessionToken(out _)
4646
},
4747
"OpenShock"
4848
);
4949
}
5050

51-
public sealed class ApiVersionResponse
51+
public sealed class BackendInfoResponse
5252
{
5353
public required string Version { get; init; }
5454
public required string Commit { get; init; }
5555
public required DateTimeOffset CurrentTime { get; init; }
5656
public required Uri FrontendUrl { get; init; }
5757
public required Uri ShortLinkUrl { get; init; }
5858
public required string? TurnstileSiteKey { get; init; }
59+
public required bool IsUserAuthenticated { get; init; }
5960
}
6061
}

API/Program.cs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,18 @@
2222

2323
#region Config
2424

25-
builder.RegisterCommonOpenShockOptions();
26-
27-
builder.Services.Configure<FrontendOptions>(builder.Configuration.GetRequiredSection(FrontendOptions.SectionName));
28-
builder.Services.AddSingleton<IValidateOptions<FrontendOptions>, FrontendOptionsValidator>();
29-
30-
var databaseConfig = builder.Configuration.GetDatabaseOptions();
31-
var redisConfig = builder.Configuration.GetRedisConfigurationOptions();
25+
var redisOptions = builder.RegisterRedisOptions();
26+
var databaseOptions = builder.RegisterDatabaseOptions();
27+
builder.RegisterMetricsOptions();
28+
builder.RegisterFrontendOptions();
3229

3330
#endregion
3431

3532
builder.Services
36-
.AddOpenShockMemDB(redisConfig)
37-
.AddOpenShockDB(databaseConfig)
33+
.AddOpenShockMemDB(redisOptions)
34+
.AddOpenShockDB(databaseOptions)
3835
.AddOpenShockServices()
39-
.AddOpenShockSignalR(redisConfig);
36+
.AddOpenShockSignalR(redisOptions);
4037

4138
builder.Services.AddScoped<IDeviceService, DeviceService>();
4239
builder.Services.AddScoped<IControlSender, ControlSender>();
@@ -59,9 +56,9 @@
5956

6057
await app.UseCommonOpenShockMiddleware();
6158

62-
if (!databaseConfig.SkipMigration)
59+
if (!databaseOptions.SkipMigration)
6360
{
64-
await app.ApplyPendingOpenShockMigrations(databaseConfig);
61+
await app.ApplyPendingOpenShockMigrations(databaseOptions);
6562
}
6663
else
6764
{

API/Services/Account/AccountService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ public sealed class AccountService : IAccountService
3535
/// <param name="logger"></param>
3636
/// <param name="options"></param>
3737
public AccountService(OpenShockContext db, IEmailService emailService,
38-
ISessionService sessionService, ILogger<AccountService> logger, IOptions<FrontendOptions> options)
38+
ISessionService sessionService, ILogger<AccountService> logger, FrontendOptions options)
3939
{
4040
_db = db;
4141
_emailService = emailService;
4242
_logger = logger;
43-
_frontendConfig = options.Value;
43+
_frontendConfig = options;
4444
_sessionService = sessionService;
4545
}
4646

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,100 @@
1-
using Microsoft.Extensions.Options;
2-
using OpenShock.Common.Options;
1+
using OpenShock.Common.Options;
2+
using StackExchange.Redis;
33

44
namespace OpenShock.Common.Extensions;
55

66
public static class ConfigurationExtensions
77
{
8-
public static WebApplicationBuilder RegisterCommonOpenShockOptions(this WebApplicationBuilder builder)
8+
// Reusable helper: bind or throw with a clear message
9+
private static T GetRequired<T>(this ConfigurationManager configuration, string path, string? example = null) where T : class
910
{
10-
if (builder.Environment.IsDevelopment())
11+
var section = configuration.GetSection(path);
12+
var value = section.Get<T>();
13+
if (value is null)
1114
{
12-
Console.WriteLine(builder.Configuration.GetDebugView());
15+
var hint = string.IsNullOrWhiteSpace(example) ? "" : $" Example: {example}";
16+
throw new InvalidOperationException(
17+
$"Missing or invalid configuration at '{path}'.{hint}"
18+
);
1319
}
14-
15-
builder.Services.Configure<DatabaseOptions>(builder.Configuration.GetRequiredSection(DatabaseOptions.SectionName));
16-
builder.Services.AddSingleton<IValidateOptions<DatabaseOptions>, DatabaseOptionsValidator>();
17-
18-
builder.Services.Configure<RedisOptions>(builder.Configuration.GetRequiredSection(RedisOptions.SectionName));
19-
builder.Services.AddSingleton<IValidateOptions<RedisOptions>, RedisOptionsValidator>();
20-
21-
builder.Services.Configure<MetricsOptions>(builder.Configuration.GetSection(MetricsOptions.SectionName));
22-
builder.Services.AddSingleton<IValidateOptions<MetricsOptions>, MetricsOptionsValidator>();
23-
24-
return builder;
20+
return value;
21+
}
22+
23+
public static DatabaseOptions RegisterDatabaseOptions(this WebApplicationBuilder builder)
24+
{
25+
// If DatabaseOptions has required fields, you can validate them after binding.
26+
var options = builder.Configuration.GetRequired<DatabaseOptions>(
27+
"OpenShock:DB",
28+
"""{ "ConnectionString": "..." }"""
29+
);
30+
31+
builder.Services.AddSingleton(options);
32+
return options;
33+
}
34+
35+
private sealed record RedisSection(string? Conn, string? User, string? Password, string? Host, string? Port);
36+
public static ConfigurationOptions RegisterRedisOptions(this WebApplicationBuilder builder)
37+
{
38+
var section = builder.Configuration.GetRequired<RedisSection>(
39+
"OpenShock:Redis",
40+
"""{ "Conn": "redis://user:pass@host:6379" } or { "Host": "host", "Password": "...", "Port": "6379" }"""
41+
);
42+
43+
ConfigurationOptions options;
44+
45+
if (!string.IsNullOrWhiteSpace(section.Conn))
46+
{
47+
options = ConfigurationOptions.Parse(section.Conn);
48+
}
49+
else
50+
{
51+
if (string.IsNullOrWhiteSpace(section.Host))
52+
throw new ArgumentException("Redis Host is required (OpenShock:Redis:Host).");
53+
54+
if (string.IsNullOrWhiteSpace(section.Password))
55+
throw new ArgumentException("Redis Password is required (OpenShock:Redis:Password).");
56+
57+
// Parse port with sane default + validation
58+
ushort port = 6379;
59+
if (!string.IsNullOrWhiteSpace(section.Port))
60+
{
61+
if (!ushort.TryParse(section.Port, out port) || port == 0)
62+
throw new ArgumentException("Redis Port must be a number between 1 and 65535 (OpenShock:Redis:Port).");
63+
}
64+
65+
options = new ConfigurationOptions
66+
{
67+
User = section.User, // optional; only if ACLs enabled
68+
Password = section.Password,
69+
Ssl = false, // flip via connection string if needed
70+
};
71+
options.EndPoints.Add(section.Host!, port);
72+
}
73+
74+
// Sensible defaults (adjust to taste)
75+
options.AbortOnConnectFail = true;
76+
77+
builder.Services.AddSingleton(options);
78+
return options;
79+
}
80+
81+
public static MetricsOptions RegisterMetricsOptions(this WebApplicationBuilder builder)
82+
{
83+
var options = builder.Configuration.GetRequired<MetricsOptions>(
84+
"OpenShock:Metrics"
85+
);
86+
87+
builder.Services.AddSingleton(options);
88+
return options;
89+
}
90+
91+
public static FrontendOptions RegisterFrontendOptions(this WebApplicationBuilder builder)
92+
{
93+
var options = builder.Configuration.GetRequired<FrontendOptions>(
94+
"OpenShock:Frontend"
95+
);
96+
97+
builder.Services.AddSingleton(options);
98+
return options;
2599
}
26100
}

Common/OpenShockMiddlewareHelper.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public static class OpenShockMiddlewareHelper
2525

2626
public static async Task<IApplicationBuilder> UseCommonOpenShockMiddleware(this WebApplication app)
2727
{
28-
var metricsOptions = app.Services.GetRequiredService<IOptions<MetricsOptions>>().Value;
28+
var metricsOptions = app.Services.GetRequiredService<MetricsOptions>();
2929
var metricsAllowedIpNetworks = metricsOptions.AllowedNetworks.Select(x => IPNetwork.Parse(x)).ToArray();
3030

3131
foreach (var proxy in await TrustedProxiesFetcher.GetTrustedNetworksAsync())

Common/OpenShockServiceHelper.cs

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -34,48 +34,6 @@ namespace OpenShock.Common;
3434

3535
public static class OpenShockServiceHelper
3636
{
37-
public static DatabaseOptions GetDatabaseOptions(this ConfigurationManager configuration)
38-
{
39-
var section = configuration.GetRequiredSection(DatabaseOptions.SectionName);
40-
if (!section.Exists()) throw new Exception("TODO");
41-
42-
return section.Get<DatabaseOptions>() ?? throw new Exception("TODO");
43-
}
44-
45-
public static ConfigurationOptions GetRedisConfigurationOptions(this ConfigurationManager configuration)
46-
{
47-
var section = configuration.GetRequiredSection(RedisOptions.SectionName);
48-
if (!section.Exists()) throw new Exception("TODO");
49-
50-
var options = section.Get<RedisOptions>() ?? throw new Exception("TODO");
51-
52-
53-
ConfigurationOptions configurationOptions;
54-
55-
if (string.IsNullOrWhiteSpace(options.Conn))
56-
{
57-
configurationOptions = new ConfigurationOptions
58-
{
59-
AbortOnConnectFail = true,
60-
Password = options.Password,
61-
User = options.User,
62-
Ssl = false,
63-
EndPoints = new EndPointCollection
64-
{
65-
{ options.Host, options.Port }
66-
}
67-
};
68-
}
69-
else
70-
{
71-
configurationOptions = ConfigurationOptions.Parse(options.Conn);
72-
}
73-
74-
configurationOptions.AbortOnConnectFail = true;
75-
76-
return configurationOptions;
77-
}
78-
7937
public static IServiceCollection AddOpenShockMemDB(this IServiceCollection services, ConfigurationOptions options)
8038
{
8139
// <---- Redis ---->

0 commit comments

Comments
 (0)