Skip to content
Merged
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
9 changes: 9 additions & 0 deletions SW.Bitween.Api/Data/BitweenDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.HasOne<Role>().WithMany().HasForeignKey(p => p.RoleId).OnDelete(DeleteBehavior.Cascade);
});

modelBuilder.Entity<Setting>(b =>
{
b.ToTable("Settings");
b.HasKey(p => p.Id);
// Id is the catalog key, e.g. "Theme.PrimaryColor". Value is left unbounded:
// it carries anything from a hex color to a license key or a page of blurb.
b.Property(p => p.Id).IsUnicode(false).HasMaxLength(200);
});

}

/// <summary>
Expand Down
26 changes: 26 additions & 0 deletions SW.Bitween.Api/Domain/Setting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain;

/// <summary>
/// One instance-wide setting, keyed by the setting's catalog key (e.g. <c>Theme.PrimaryColor</c>).
/// This table is the single source of truth: configuration seeds a key once — on the first boot
/// after that key exists — and is ignored for it from then on. Every catalog key normally has a
/// row; "reset to default" rewrites the row with the product default rather than removing it.
/// <para>
/// Deliberately a plain key/value store: the definition of a key (label, section, type,
/// whether it's a secret) lives in <see cref="Services.SettingsCatalog"/>, so adding a
/// setting never needs a migration or a data fix-up. A secret's value is encrypted before it
/// gets here — see <see cref="Services.SettingsProtector"/>.
/// </para>
/// </summary>
public class Setting : BaseEntity<string>, IAudited
{
public string Value { get; set; }

public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
public DateTime? ModifiedOn { get; set; }
public string ModifiedBy { get; set; }
}
5 changes: 4 additions & 1 deletion SW.Bitween.Api/Resources/Settings/Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ public async Task<object> Handle()
IsRabbitMqManagementConfigured = !string.IsNullOrWhiteSpace(_BitweenOptions.RabbitMqManagementUrl)
&& !string.IsNullOrWhiteSpace(_BitweenOptions.RabbitMqManagementUsername)
&& !string.IsNullOrWhiteSpace(_BitweenOptions.RabbitMqManagementPassword),
Theme = _themeOptions
Theme = _themeOptions,
// The product defaults, so the sign-in page — which has no session and can't read the
// settings list — can tell a brand value someone chose from one nobody has touched.
ThemeDefaults = SettingsService.DefaultsUnder("Theme.")
};
}
}
51 changes: 51 additions & 0 deletions SW.Bitween.Api/Resources/Settings/Delete.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Threading.Tasks;
using SW.Bitween.Domain;
using SW.Bitween.Services;
using SW.PrimitiveTypes;

namespace SW.Bitween.Resources.Settings;

/// <summary>
/// Resets one setting to the product default — the value the options class ships with.
/// <para>
/// The row is rewritten rather than deleted: a missing row is how startup recognises a key it has
/// never imported, so dropping it would let configuration seep back in on the next boot. Reset
/// therefore means "stop choosing", not "forget this key exists".
/// </para>
/// </summary>
public class Delete(
BitweenDbContext dbContext,
RequestContext requestContext,
SettingsService settings,
IInfolinkCache cache) : IDeleteHandler<string, object>
{
public async Task<object> Handle(string key)
{
await requestContext.EnsurePermission(dbContext, Model.Permissions.Settings.Edit);

var definition = SettingsCatalog.Find(key)
?? throw new SWValidationException("SETTING_NOT_FOUND", $"'{key}' is not a known setting.");

if (!settings.CanStore(definition))
throw new SWValidationException("SETTING_ENCRYPTION_UNAVAILABLE",
$"{definition.Label} is a secret that isn't stored, so there's nothing to reset.");

var productDefault = SettingsService.DefaultOf(definition);
var stored = await dbContext.Set<Setting>().FindAsync(definition.Key);
var toStore = settings.ToStored(definition, productDefault);

if (stored is null)
dbContext.Add(new Setting { Id = definition.Key, Value = toStore });
else
stored.Value = toStore;

await dbContext.SaveChangesAsync();

// Resetting an already-default setting is a no-op, not an error — the UI can fire this for
// a staged reset it never saved a value for.
settings.Apply(definition, productDefault);
await cache.BroadcastRevoke();

return null;
}
}
58 changes: 58 additions & 0 deletions SW.Bitween.Api/Resources/Settings/Get.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using SW.Bitween.Domain;
using SW.Bitween.Model;
using SW.Bitween.Services;
using SW.PrimitiveTypes;

namespace SW.Bitween.Resources.Settings;

/// <summary>
/// Every editable setting, in catalog order: the definition from <see cref="SettingsCatalog"/>
/// joined with the stored value. Rows normally exist for all of them — startup imports whatever
/// configuration had — so a missing row means the key couldn't be stored yet.
/// </summary>
public class Get(BitweenDbContext dbContext, RequestContext requestContext, SettingsService settings)
: IQueryHandler<object>
{
public async Task<object> Handle()
{
await requestContext.EnsurePermission(dbContext, Model.Permissions.Settings.View);

var rows = await dbContext.Set<Setting>().AsNoTracking()
.ToDictionaryAsync(s => s.Id, s => s.Value, StringComparer.OrdinalIgnoreCase);

return SettingsCatalog.All.Select(definition =>
{
var hasRow = rows.TryGetValue(definition.Key, out var stored);
var productDefault = SettingsService.DefaultOf(definition);
// A secret's value is withheld either way round: only whether one is set is public.
var value = definition.Secret ? null : hasRow ? stored : productDefault;
// A secret has no product default, and its ciphertext couldn't be compared with one
// anyway — so for a secret both "is set" and "is overridden" mean the same thing:
// a non-empty value is stored.
var secretIsSet = hasRow && !string.IsNullOrEmpty(stored);

return new SettingRow
{
Key = definition.Key,
Section = definition.Section,
Label = definition.Label,
Description = definition.Description,
Kind = definition.Kind.ToString().ToLowerInvariant(),
DefaultValue = definition.Secret ? string.Empty : productDefault,
Value = value,
Secret = definition.Secret,
Overridden = definition.Secret ? secretIsSet : hasRow && stored != productDefault,
HasValue = definition.Secret
? secretIsSet
: !string.IsNullOrEmpty(hasRow ? stored : productDefault),
// The only thing that makes a setting uneditable: a secret with no passphrase
// configured to protect it.
Editable = settings.CanStore(definition)
};
}).ToArray();
}
}
68 changes: 68 additions & 0 deletions SW.Bitween.Api/Resources/Settings/Update.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System;
using System.Threading.Tasks;
using SW.Bitween.Domain;
using SW.Bitween.Model;
using SW.Bitween.Services;
using SW.PrimitiveTypes;

namespace SW.Bitween.Resources.Settings;

/// <summary>
/// Stores a new value for one setting. It's applied to the live options singletons as well as
/// stored, so the very next request already sees it; the cache-revoke broadcast carries the change
/// to any other instance. Secrets are encrypted before they're written.
/// </summary>
public class Update(
BitweenDbContext dbContext,
RequestContext requestContext,
SettingsService settings,
IInfolinkCache cache) : ICommandHandler<string, SettingUpdate, object>
{
public async Task<object> Handle(string key, SettingUpdate request)
{
await requestContext.EnsurePermission(dbContext, Model.Permissions.Settings.Edit);

var definition = SettingsCatalog.Find(key)
?? throw new SWValidationException("SETTING_NOT_FOUND", $"'{key}' is not a known setting.");

if (!settings.CanStore(definition))
throw new SWValidationException("SETTING_ENCRYPTION_UNAVAILABLE",
$"{definition.Label} is a secret and can only be stored once " +
$"{BitweenOptions.ConfigurationSection}:{nameof(BitweenOptions.SettingsEncryptionKey)} is configured.");

// Empty is a real value — it's how you clear an optional link or a license key.
var value = request?.Value ?? string.Empty;

try
{
SettingsService.Validate(definition, value);
}
catch (FormatException ex)
{
throw new SWValidationException("SETTING_INVALID_VALUE", $"{definition.Label}: {ex.Message}");
}

await Store(definition, value);
settings.Apply(definition, value);
await cache.BroadcastRevoke();

return null;
}

/// <summary>
/// Writes the value, creating the row if startup hasn't imported this key yet. Every setting
/// keeps exactly one row, keyed by the catalog key.
/// </summary>
private async Task Store(SettingDefinition definition, string value)
{
var stored = await dbContext.Set<Setting>().FindAsync(definition.Key);
var toStore = settings.ToStored(definition, value);

if (stored is null)
dbContext.Add(new Setting { Id = definition.Key, Value = toStore });
else
stored.Value = toStore;

await dbContext.SaveChangesAsync();
}
}
23 changes: 17 additions & 6 deletions SW.Bitween.Api/Services/BitweenOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@ public class BitweenOptions
public BitweenOptions()
{
// AESEncryptionKey = "BitweenS9SecretKey";
AdapterPath = "./adapters";
AdapterPath = "adapters";
AdminCredentials = "admin:1234512345";
DocumentPrefix = "temp30/Bitweendocs";
ClientIpHeaderName = "X-Real-IP";
DatabaseType = "MySql";
AdminDatabaseName = "defaultdb";
ServerlessCommandTimeout = 300;
ApiCallSubscriptionResponseAcceptedStatusCode = 202;
ReceiversDelayInSeconds = 63;
StorageProvider = "S3";
JwtExpiryMinutes = 60;
BusDefaultQueuePrefetch = 12;
Expand All @@ -29,14 +27,17 @@ public BitweenOptions()

public string DatabaseType { get; set; }
public string AdminDatabaseName { get; set; }

/// <summary>
/// Cloud-storage key prefix the serverless runner downloads custom adapter packages from
/// (<c>{AdapterPath}/{adapterId}</c>). Passed to <c>ServerlessOptions.AdapterRemotePath</c>.
/// </summary>
public string AdapterPath { get; set; }
public string AdminCredentials { get; set; }
public string DocumentPrefix { get; set; }
public string ClientIpHeaderName { get; set; }
public int ServerlessCommandTimeout { get; set; }
public bool AreXChangeFilesPrivate { get; set; } = false;
public int? ApiCallSubscriptionResponseAcceptedStatusCode { get; set; }
public int? ReceiversDelayInSeconds { get; set; }

public string StorageProvider { get; set; }

Expand All @@ -63,12 +64,22 @@ public BitweenOptions()
/// </summary>
public string AzureManagedIdentityClientId { get; set; }

/// <summary>
/// Passphrase used to encrypt secret settings before they're stored. Environment-only and
/// never itself a setting — it's what protects the table, so it can't live in it. Without
/// it, secret settings are neither imported nor editable and keep coming from configuration.
/// Rotating it makes anything already stored unreadable.
/// </summary>
public string SettingsEncryptionKey { get; set; }

public string RabbitMqManagementUrl { get; set; }
public string RabbitMqManagementUsername { get; set; }
public string RabbitMqManagementPassword { get; set; }

/// <summary>
/// License key for the Rebex POP3 library. When not set, the native Rebex POP3 receiver adapter is not registered.
/// License key for the Rebex library the native POP3 and FTP adapters are built on. Those
/// adapters are always registered, so a key stored in Settings takes effect without a
/// restart; while no key is set they're kept out of the adapter pickers instead.
/// </summary>
public string RebexLicenseKey { get; set; }

Expand Down
9 changes: 8 additions & 1 deletion SW.Bitween.Api/Services/NativeAdapterDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public class NativeAdapterDiscoveryService(
IEnumerable<INativeInfolinkMapper> nativeMappers,
IEnumerable<INativeInfolinkReceiver> nativeReceivers,
IEnumerable<INativeInfolinkValidator> nativeValidators,
IEnumerable<INativeAdapter> nativeAdapters)
IEnumerable<INativeAdapter> nativeAdapters,
BitweenOptions bitweenOptions)
{
public const string NativePrefix = "native";
public Dictionary<string, StartupValue> GetStartupValues(string adapterId)
Expand Down Expand Up @@ -148,6 +149,12 @@ public List<string> GetNativeAdapters(string? type)
return new List<string>();
}

// Rebex-backed adapters are always registered (the license key is a setting that can
// change at runtime), so they're filtered out here while no key is set rather than
// being offered in a picker where they could only fail.
if (string.IsNullOrWhiteSpace(bitweenOptions.RebexLicenseKey))
adapters = adapters.Where(a => a is not IRequiresRebexLicense).ToList();

return adapters.Select(a => a.GetType().Name).ToList();
}
private string? GetDefaultValue(PropertyInfo property)
Expand Down
Loading