Hermod
A cross-platform, modular and fully GDPR-compliant email archival solution!
Loading...
Searching...
No Matches
ConfigManager.cs
Go to the documentation of this file.
1using System;
2
3namespace Hermod.Config {
4
5 using Core;
6 using Detail;
7
8 using Newtonsoft.Json;
9 using Newtonsoft.Json.Linq;
10
11 using Serilog;
12
13 using System.IO;
14 using System.Text;
15 using System.Text.RegularExpressions;
16
46
47 private readonly object m_lock;
48 private volatile string m_lockedBy;
49
54 public ILogger? AppLogger { get; set; }
55
56 #region IConfigLoaded
58
59 protected void OnConfigLoaded() => ConfigLoaded?.Invoke(this, new ConfigLoadedEventArgs());
60 #endregion
61
62 #region ConfigChanged
64
65 protected void OnConfigChanged(string configName, object? prevValue, object? newValue, Type cfgType) {
66 ConfigChanged?.Invoke(this, new ConfigChangedEventArgs(configName, prevValue, newValue, cfgType));
67 }
68 #endregion
69
70 #region Defaults
71 const string DefaultConfigFileName = ".hermod.json";
72
73 private static FileInfo? _defaultConfigPathCache = null;
74
76 return
78 (
80 .GetSubFile(
83 )
84 );
85 }
86 #endregion
87
88 #region Singleton
89 private static ConfigManager? _instance;
90
98
99 protected ConfigManager() {
100 m_lock = new object();
101 m_lockedBy = string.Empty;
102 m_configDictionary = new JObject();
103 m_configFile = GetDefaultConfigPath();
104 m_defaultConfig = LoadDefaultConfig();
105 }
106 #endregion
107
108 [GeneratedRegex(@"^([A-z_][A-z0-9_]+)(\.[A-z_][A-z0-9_]+)+?[^\.]$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
109 public static partial Regex ConfigDotNotation();
110
111 protected JObject m_configDictionary;
112 protected JObject m_defaultConfig;
113
118 protected JObject LoadDefaultConfig() {
119 using var rStream = typeof(ConfigManager).Assembly.GetManifestResourceStream("Hermod.Config.Resources.DefaultConfig.json");
120 if (rStream is null) {
121 throw new Exception("Failed to load default configuration! Hermod will abort!");
122 }
123
124 using (var sReader = new StreamReader(rStream)) {
125 var text = sReader.ReadToEnd();
126 return JObject.Parse(text);
127 }
128 }
129
130 private FileInfo? m_configFile = null;
137 public FileInfo ConfigFile {
138 get => m_configFile ?? GetDefaultConfigPath();
139 set {
140 if (value == m_configFile) { return; }
141
142 m_configFile = value;
143 LoadConfig();
144 }
145 }
146
154 public async Task LoadConfigAsync() {
155 if (!ConfigFile.Exists || ConfigFile.Length < 10) {
156 // this will happen for files that don't exist
157 // and files that are less than 10B
158 lock (m_lock) {
159 m_lockedBy = nameof(LoadConfigAsync);
160 m_configDictionary = m_defaultConfig;
161 m_lockedBy = string.Empty;
162 }
163 await SaveConfigAsync();
164 return;
165 }
166
167 using (var cfgFile = ConfigFile.Open(FileMode.Open))
168 using (var sReader = new StreamReader(cfgFile)) {
169 var sBuilder = new StringBuilder();
170
171 while (sReader.Peek() != -1) {
172 sBuilder.AppendLine(await sReader.ReadLineAsync());
173 }
174
175 try {
176 lock (m_lock) {
177 m_lockedBy = nameof(LoadConfigAsync);
178 m_configDictionary = JObject.Parse(sBuilder.ToString());
179 m_lockedBy = string.Empty;
180 }
181 } catch (Exception ex) {
182 Console.Error.WriteLine("An error occurred while loading configs! Loading defaults!");
183 Console.Error.WriteLine($"Error message: { ex.Message }");
184
185 #if DEBUG
186 Console.Error.WriteLine(ex.StackTrace);
187 #endif
188 }
189 }
190 }
191
195 public void LoadConfig() {
196 if (!ConfigFile.Exists || ConfigFile.Length < 10) {
197 // this will happen for files that don't exist
198 // and files that are less than 10B
199 lock (m_lock) {
200 m_lockedBy = nameof(LoadConfig);
201 m_configDictionary = m_defaultConfig;
202 m_lockedBy = string.Empty;
203 }
204 SaveConfig();
205 return;
206 }
207
208 using (var cfgFile = ConfigFile.Open(FileMode.Open))
209 using (var sReader = new StreamReader(cfgFile)) {
210 var sBuilder = new StringBuilder();
211
212 while (sReader.Peek() != -1) {
213 sBuilder.AppendLine(sReader.ReadLine());
214 }
215
216 try {
217 lock (m_lock) {
218 m_lockedBy = nameof(LoadConfig);
219 m_configDictionary = JObject.Parse(sBuilder.ToString());
220 m_lockedBy = string.Empty;
221 }
222 } catch (Exception ex) {
223 Console.Error.WriteLine("An error occurred while loading configs! Loading defaults!");
224 Console.Error.WriteLine($"Error message: { ex.Message }");
225
226 #if DEBUG
227 Console.Error.WriteLine(ex.StackTrace);
228 #endif
229 }
230 }
231 }
232
240 public async Task SaveConfigAsync() {
241 if (!ConfigFile.Exists) {
242 try {
243 ConfigFile.Directory?.Create();
244 } catch (UnauthorizedAccessException ex) {
245 HandleUnauthorizedAccessWhenSavingConfig(ex);
246 await SaveConfigAsync();
247 }
248 ConfigFile.Create().Close();
249 }
250
251 using (var cfgFile = ConfigFile.Open(FileMode.Truncate))
252 using (var sWriter = new StreamWriter(cfgFile)) {
253 string serialisedData;
254 lock (m_lock) {
255 m_lockedBy = nameof(SaveConfigAsync);
256 serialisedData = JsonConvert.SerializeObject(m_configDictionary, Formatting.Indented);
257 m_lockedBy = string.Empty;
258 }
259 await sWriter.WriteLineAsync(serialisedData);
260 }
261 }
262
263 private void HandleUnauthorizedAccessWhenSavingConfig(UnauthorizedAccessException ex) {
264 AppLogger?.Error($"Failed to create config file in { ConfigFile.FullName }!");
265 AppLogger?.Error(ex, "Hermod does not have sufficient access rights.");
266 AppLogger?.Warning("Switching to user-local config location!");
267 ConfigFile = AppInfo.GetLocalHermodDirectory().CreateSubdirectory(AppInfo.HermodAppCfgDirName).GetSubFile(DefaultConfigFileName);
268 }
269
276 public void SaveConfig() {
277 if (!ConfigFile.Exists) {
278 try {
279 ConfigFile.Directory?.Create();
280 } catch (UnauthorizedAccessException ex) {
281 HandleUnauthorizedAccessWhenSavingConfig(ex);
282 SaveConfig();
283 }
284 ConfigFile.Create().Close();
285 }
286
287 using (var cfgFile = ConfigFile.Open(FileMode.Truncate))
288 using (var sWriter = new StreamWriter(cfgFile)) {
289 string serialisedData;
290 lock (m_lock) {
291 m_lockedBy = nameof(SaveConfig);
292 serialisedData = JsonConvert.SerializeObject(m_configDictionary, Formatting.Indented);
293 m_lockedBy = string.Empty;
294 }
295 sWriter.WriteLine(serialisedData);
296 }
297 }
298
316 public T GetConfig<T>(string configName) {
317 try {
318 return GetConfig<T>(configName, m_configDictionary);
319 } catch (ConfigNotFoundException) {
320 return GetConfig<T>(configName, m_defaultConfig); // no point in catching anything here
321 } catch {
322 throw;
323 }
324 }
325
332 public void SetConfig<T>(string configName, T? configValue) => SetConfig<T>(configName, configValue, ref m_configDictionary);
333
343 protected void SetConfig<T>(string configName, T? configValue, ref JObject dict) {
344 if (string.IsNullOrEmpty(configName) || string.IsNullOrWhiteSpace(configName)) {
345 throw new ArgumentNullException(nameof(configName), "The config name must not be null or empty!");
346 }
347
348 if (ConfigDotNotation().IsMatch(configName)) {
349 var periodPos = configName.IndexOf('.');
350 var container = configName.Substring(0, periodPos);
351 var subConfig = configName.Substring(periodPos + 1);
352 var subConfigHasDotNotation = ConfigDotNotation().IsMatch(subConfig);
353
354 if (dict.ContainsKey(container) && subConfigHasDotNotation) {
355 var token = dict[container];
356 if (token?.Type != JTokenType.Object) {
357 throw new ConfigException($"Config type mismatch! Expected object; got { token?.Type.ToString() }");
358 }
359
360 var tokenObj = (JObject)token;
361 SetConfig<T>(subConfig, configValue, ref tokenObj);
362 return;
363 } else if (subConfigHasDotNotation) {
364 // the desired object doesn't exist, so we'll have to add it
365 lock (m_lock) {
366 m_lockedBy = nameof(SetConfig);
367 dict.Add(container, new JObject()); // add a new object and then recursively call this method so the first condition is true
368 m_lockedBy = string.Empty;
369 }
370 SetConfig<T>(configName, configValue, ref dict);
371 return;
372 }
373
374 // the object exists, but subConfig does not match config dot notation; call the method again so the value can be set
375 SetConfig<T>(subConfig, configValue, ref dict);
376 }
377
378 if (!dict.ContainsKey(configName)) {
379 lock (m_lock) {
380 m_lockedBy = nameof(SetConfig);
381 dict.Add(configName, JToken.FromObject(configValue));
382 m_lockedBy = string.Empty;
383 }
384 ConfigChanged?.Invoke(this, new ConfigChangedEventArgs(configName, null, configValue, typeof(T)));
385 return;
386 }
387
388 var prevValue = dict[configName];
389 lock (m_lock) {
390 m_lockedBy = nameof(SetConfig);
391 dict[configName] = JToken.FromObject(configValue);
392 m_lockedBy = string.Empty;
393 }
394 ConfigChanged?.Invoke(this, new ConfigChangedEventArgs(configName, prevValue, configValue, typeof(T)));
395 }
396
407 protected T GetConfig<T>(string configName, JObject dict) {
408 if (string.IsNullOrEmpty(configName) || string.IsNullOrWhiteSpace(configName)) {
409 throw new ArgumentNullException(nameof(configName), "The config name must not be null or empty!");
410 }
411
412 if (ConfigDotNotation().IsMatch(configName)) {
413 var periodPos = configName.IndexOf('.');
414 var container = configName.Substring(0, periodPos);
415
416 if (dict.ContainsKey(container)) {
417 var token = dict[container];
418 if (token?.Type != JTokenType.Object) {
419 throw new ConfigException($"Config type mismatch! Expected object; got { token?.Type.ToString() }");
420 }
421
422 return GetConfig<T>(configName.Substring(periodPos + 1), (JObject)token);
423 } else {
424 throw new ConfigNotFoundException(container, $"Could not find container for { configName }!");
425 }
426 }
427
428 if (!dict.ContainsKey(configName)) {
429 throw new ConfigNotFoundException(configName, "Could not find requested config key!");
430 }
431
432 JToken config;
433 lock (m_lock) {
434 m_lockedBy = nameof(GetConfig);
435 config = dict[configName];
436 m_lockedBy = string.Empty;
437 }
438
439 return config.ToObject<T>();
440 }
441
442 #region Special Accessors
447 public LoggerConfig GetConsoleLoggerConfig() => GetConfig<LoggerConfig>("Logging.ConsoleLogging");
448
453 public LoggerConfig GetFileLoggerConfig() => GetConfig<LoggerConfig>("Logging.FileLogging");
454
459 public DirectoryInfo GetPluginInstallDir() {
460 var installDir = GetConfig<string?>("Plugins.InstallDir");
461
462 if (string.IsNullOrEmpty(installDir)) {
463 installDir = AppInfo.GetLocalHermodDirectory().CreateSubdirectory(AppInfo.HermodAppPluginDirName).FullName;
464 SetConfig<string>("Plugins.InstallDir", installDir);
465 }
466
467 return new DirectoryInfo(installDir);
468 }
469
470 #endregion
471
472 }
473}
474
Event arguments for when a configuration has changed.
Provides an application-wide, thread safe way of getting and setting configurations relevant to the m...
volatile string m_lockedBy
ConfigChangedEventHandler? ConfigChanged
static ? FileInfo _defaultConfigPathCache
static ConfigManager Instance
Gets the application-wide instance of the ConfigManager.
void OnConfigChanged(string configName, object? prevValue, object? newValue, Type cfgType)
ILogger? AppLogger
Gets or sets the logger instance.
const string DefaultConfigFileName
static ? ConfigManager _instance
ConfigLoadedEventHandler? ConfigLoaded
Static class containing basic information for and about the application.
Definition: AppInfo.cs:10
static string HermodAppCfgDirName
Gets the name of the application's config directory.
Definition: AppInfo.cs:20
static DirectoryInfo GetBaseHermodDirectory()
Gets the application's base data directory.
Definition: AppInfo.cs:37
Generic contract defining the behaviour when a config was changed.
Interface defining a contract which notifies anyone whom it may concern when the application configur...
delegate void ConfigChangedEventHandler(object? sender, ConfigChangedEventArgs e)
delegate void ConfigLoadedEventHandler(object? sender, ConfigLoadedEventArgs e)