New Webserver and WebApp (#942)

* Replace the WCF webserver with an ASP.NET Core webserver (VocaluxeWeb)

- New project VocaluxeWeb: ASP.NET Core 2.3 (Kestrel) on .NET Framework 4.8.1, HTTP or HTTPS
  with a self-signed certificate, REST API (/api/v1) with Swagger, live events (Server-Sent Events)
- Access to the game only through IVocaluxeRemote; requests run on the main thread once per frame
- Sessions with roles (guest, player, admin) and configurable permissions, PINs hashed with PBKDF2
- Queue as a playlist with requesters and singers, fair order (one song per person and round)
- Admin: settings, server status, logged-in devices, activate/deactivate and delete profiles
- Profile pictures: own photo or one of the Vocaluxe avatars
- Game: WebApp queue screen (song options, playlist group) to start the next song with the chosen
  singers, return to it after singing, warning when the webserver is off, remote keyboard
- Old WCF server, WebserverInitalConfig and URL reservations removed; libraries moved to libs/managed
- Unit tests for PIN hashing, settings and permissions, queue order and device names
- AppVeyor: Visual Studio 2022 image and Node.js 24 (the build compiles the WebApp)
- Documentation in VocaluxeWeb/README.md

* New WebApp (Vue 3, TypeScript, Vite) replacing the old jQuery Mobile website

- Login with profile and PIN, create profiles
- Songs with search, filters, sorting and 30 second previews
- Queue: add songs with game mode, audio and singers, edit, reorder, start in the game
- Playlists: create, edit, add to the queue
- Now playing with live updates, remote control and keyboard
- Players: own profile with picture (photo or Vocaluxe avatar), roles for admins
- Admin settings: permissions, server status, devices, profiles
- Help page, responsive layout for phones, tablets and desktops, light and dark mode
- Texts in WebApp/src/locales/en.json, translated with Transifex
- Old website and PhoneGap config removed

---------

Co-authored-by: Florian Ostertag <flokuep@users.noreply.github.com>
This commit is contained in:
McSchneidarandFlorian Ostertag authored and GitHub committed 2026-10-26 05:40:11 +01:00
1 parent e3e9d1d6ae
commit 336d08040e
186 files changed
+14971 -6988

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
namespace VocaluxeWeb.Services
{
/// Scaled JPEG versions of covers and avatars, cached on disk.
/// Uses System.Drawing, which is fine on .NET Framework; replace it (e.g. with SkiaSharp) when moving to .NET 10 / Linux.
public sealed class CImageService
{
private static readonly int[] _Sizes = { 96, 200, 400 };
private const long _JpegQuality = 82L;
private const int _MaxAvatarSize = 512;
private readonly string _CacheFolder;
private readonly object _Lock = new object();
public CImageService(string cacheFolder)
{
_CacheFolder = cacheFolder;
}
/// Path of a cached, square-bounded JPEG of the image, or null if the source does not exist.
public string GetScaled(string sourcePath, int requestedSize, out string etag)
{
etag = null;
if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath))
{
return null;
}
var size = _Sizes.FirstOrDefault(s => s >= requestedSize);
if (size == 0)
{
size = _Sizes[_Sizes.Length - 1];
}
var key = _Hash(sourcePath + "|" + File.GetLastWriteTimeUtc(sourcePath).Ticks + "|" + size + "|white");
etag = "\"" + key + "\"";
var target = Path.Combine(_CacheFolder, key + ".jpg");
if (File.Exists(target))
{
return target;
}
lock (_Lock)
{
if (!File.Exists(target))
{
Directory.CreateDirectory(_CacheFolder);
using (var source = _Load(sourcePath))
using (var scaled = _Resize(source, size, false))
{
var temp = target + ".tmp";
_SaveJpeg(scaled, temp);
File.Move(temp, target);
}
}
}
return target;
}
/// Turns an uploaded picture (JPEG, PNG, ...) into a square JPEG suitable as avatar.
public static byte[] CreateAvatar(byte[] uploaded)
{
using (var input = new MemoryStream(uploaded))
using (var source = Image.FromStream(input))
{
_ApplyExifOrientation(source);
using (var square = _Resize(source, Math.Min(_MaxAvatarSize, Math.Min(source.Width, source.Height)), true))
using (var output = new MemoryStream())
{
_SaveJpeg(square, output);
return output.ToArray();
}
}
}
private static Image _Load(string path)
{
//Read into memory so the file is not locked; the game may have it open at the same time
using (var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
var buffer = new MemoryStream();
file.CopyTo(buffer);
buffer.Position = 0;
return Image.FromStream(buffer);
}
}
/// Fits the image into size x size, or crops it to a square of that size.
private static Bitmap _Resize(Image source, int size, bool crop)
{
int width, height;
var srcRect = new Rectangle(0, 0, source.Width, source.Height);
if (crop)
{
var side = Math.Min(source.Width, source.Height);
srcRect = new Rectangle((source.Width - side) / 2, (source.Height - side) / 2, side, side);
width = height = size;
}
else
{
var scale = Math.Min(1.0, (double)size / Math.Max(source.Width, source.Height));
width = Math.Max(1, (int)Math.Round(source.Width * scale));
height = Math.Max(1, (int)Math.Round(source.Height * scale));
}
var result = new Bitmap(width, height, PixelFormat.Format24bppRgb);
using (var g = Graphics.FromImage(result))
{
g.Clear(Color.White);
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
g.CompositingQuality = CompositingQuality.HighQuality;
g.DrawImage(source, new Rectangle(0, 0, width, height), srcRect, GraphicsUnit.Pixel);
}
return result;
}
private static void _SaveJpeg(Image image, string path)
{
using (var file = File.Create(path))
{
_SaveJpeg(image, file);
}
}
private static void _SaveJpeg(Image image, Stream stream)
{
var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
using (var parameters = new EncoderParameters(1))
{
parameters.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, _JpegQuality);
image.Save(stream, codec, parameters);
}
}
/// Photos from phones are often stored rotated with an EXIF hint.
private static void _ApplyExifOrientation(Image image)
{
const int orientationId = 0x0112;
if (!image.PropertyIdList.Contains(orientationId))
{
return;
}
var rotate = RotateFlipType.RotateNoneFlipNone;
switch (image.GetPropertyItem(orientationId).Value[0])
{
case 3:
rotate = RotateFlipType.Rotate180FlipNone;
break;
case 6:
rotate = RotateFlipType.Rotate90FlipNone;
break;
case 8:
rotate = RotateFlipType.Rotate270FlipNone;
break;
}
image.RotateFlip(rotate);
}
private static string _Hash(string text)
{
using (var sha = SHA256.Create())
{
return string.Concat(sha.ComputeHash(Encoding.UTF8.GetBytes(text)).Select(b => b.ToString("x2")));
}
}
}
}
+134
View File
@@ -0,0 +1,134 @@
#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
namespace VocaluxeWeb.Services
{
/// Pushes events to all connected WebApps using Server-Sent Events (text/event-stream).
/// Data only flows from the server to the clients; changes are made with the REST API.
/// Browsers reconnect on their own when the connection drops.
public sealed class CLiveEvents
{
private static readonly TimeSpan _Heartbeat = TimeSpan.FromSeconds(15);
private static readonly JsonSerializerSettings _Json = new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
Converters = { new StringEnumConverter() }
};
private sealed class CSubscriber
{
public readonly ConcurrentQueue<string> Messages = new ConcurrentQueue<string>();
public readonly SemaphoreSlim Signal = new SemaphoreSlim(0);
}
private readonly ConcurrentDictionary<Guid, CSubscriber> _Subscribers = new ConcurrentDictionary<Guid, CSubscriber>();
public int ClientCount => _Subscribers.Count;
public void Publish(string type, object data)
{
var message = _Format(type, data);
foreach (var subscriber in _Subscribers.Values)
{
subscriber.Messages.Enqueue(message);
subscriber.Signal.Release();
}
}
/// Streams events to one client until it disconnects or <paramref name="isValid" /> returns false (e.g. after logout).
public async Task StreamAsync(HttpResponse response, IEnumerable<(string Type, object Data)> initial, Func<bool> isValid, CancellationToken cancel)
{
response.ContentType = "text/event-stream";
response.Headers["Cache-Control"] = "no-cache";
response.Headers["X-Accel-Buffering"] = "no";
var id = Guid.NewGuid();
var subscriber = new CSubscriber();
_Subscribers[id] = subscriber;
try
{
//Ask the browser to wait 3 s before reconnecting
await _WriteAsync(response, "retry: 3000\n\n", cancel);
foreach (var (type, data) in initial)
{
await _WriteAsync(response, _Format(type, data), cancel);
}
while (!cancel.IsCancellationRequested)
{
if (!await subscriber.Signal.WaitAsync(_Heartbeat, cancel))
{
if (!isValid())
{
return;
}
await _WriteAsync(response, ": ping\n\n", cancel);
continue;
}
var builder = new StringBuilder();
while (subscriber.Messages.TryDequeue(out var message))
{
builder.Append(message);
}
if (builder.Length > 0)
{
await _WriteAsync(response, builder.ToString(), cancel);
}
}
}
catch (OperationCanceledException)
{
//The client disconnected
}
catch (Exception) when (cancel.IsCancellationRequested)
{
//Writing failed because the client disconnected
}
finally
{
_Subscribers.TryRemove(id, out _);
}
}
private static string _Format(string type, object data)
{
return "event: " + type + "\ndata: " + JsonConvert.SerializeObject(data, _Json) + "\n\n";
}
private static async Task _WriteAsync(HttpResponse response, string text, CancellationToken cancel)
{
var bytes = Encoding.UTF8.GetBytes(text);
await response.Body.WriteAsync(bytes, 0, bytes.Length, cancel);
await response.Body.FlushAsync(cancel);
}
}
}
+110
View File
@@ -0,0 +1,110 @@
#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System;
using System.Threading.Tasks;
using VocaluxeWeb.Remote;
namespace VocaluxeWeb.Services
{
/// Watches the game from the main thread and turns changes into live events:
/// "state" when the screen or the current song changes, "queue" when the queue playlist was changed in the game.
/// When a song starts to be sung, its wish is removed from the queue.
public sealed class CLiveMonitor
{
public const string SingScreen = "Sing";
private static readonly TimeSpan _StateInterval = TimeSpan.FromMilliseconds(250);
private static readonly TimeSpan _QueueInterval = TimeSpan.FromSeconds(1);
private readonly IVocaluxeRemote _Remote;
private readonly CLiveEvents _Events;
private readonly CQueueService _Queue;
private readonly CSongLibrary _Songs;
private readonly Action<Exception> _OnError;
private DateTime _NextStateCheck;
private DateTime _NextQueueCheck;
private RemoteState _Last;
public CLiveMonitor(IVocaluxeRemote remote, CLiveEvents events, CQueueService queue, CSongLibrary songs, Action<Exception> onError)
{
_Remote = remote;
_Events = events;
_Queue = queue;
_Songs = songs;
_OnError = onError;
}
/// Called once per frame from the main thread. The queue is only watched while a WebApp is connected.
public void Tick()
{
var now = DateTime.UtcNow;
if (now >= _NextStateCheck)
{
_NextStateCheck = now + _StateInterval;
_CheckState();
}
if (now >= _NextQueueCheck && _Events.ClientCount > 0)
{
_NextQueueCheck = now + _QueueInterval;
if (_Queue.HasExternalChange())
{
_Run(_Queue.PublishAsync);
}
}
}
private void _CheckState()
{
var state = _Remote.GetState();
if (_Last != null && state.Screen == _Last.Screen && state.SongId == _Last.SongId)
{
return;
}
var startedSinging = state.Screen == SingScreen && state.SongId.HasValue && (_Last?.Screen != SingScreen || _Last.SongId != state.SongId);
_Last = state;
_Run(async () =>
{
if (_Events.ClientCount > 0)
{
_Events.Publish("state", await _Songs.CreateStateAsync(state));
}
if (startedSinging)
{
await _Queue.RemoveSungAsync(state.SongId.Value);
}
});
}
private void _Run(Func<Task> work)
{
Task.Run(async () =>
{
try
{
await work();
}
catch (Exception e)
{
_OnError?.Invoke(e);
}
});
}
}
}
+95
View File
@@ -0,0 +1,95 @@
#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System.Security.Cryptography;
using System.Text;
namespace VocaluxeWeb.Services
{
/// PINs are hashed with PBKDF2 (16 byte salt). Profiles from the old webserver use a single salted SHA-256 (32 byte salt);
/// those are still accepted and upgraded on the next successful login.
public static class CPinHasher
{
private const int _SaltSize = 16;
private const int _LegacySaltSize = 32;
private const int _HashSize = 32;
private const int _Iterations = 100000;
public static void Hash(string pin, out byte[] hash, out byte[] salt)
{
salt = new byte[_SaltSize];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(salt);
}
hash = _Pbkdf2(pin, salt);
}
public static bool Verify(string pin, byte[] hash, byte[] salt)
{
if (hash == null || salt == null || pin == null)
{
return false;
}
var computed = salt.Length == _LegacySaltSize ? _LegacySha256(pin, salt) : _Pbkdf2(pin, salt);
return _FixedTimeEquals(computed, hash);
}
public static bool NeedsUpgrade(byte[] salt)
{
return salt != null && salt.Length == _LegacySaltSize;
}
private static byte[] _Pbkdf2(string pin, byte[] salt)
{
using (var kdf = new Rfc2898DeriveBytes(pin, salt, _Iterations, HashAlgorithmName.SHA256))
{
return kdf.GetBytes(_HashSize);
}
}
private static byte[] _LegacySha256(string pin, byte[] salt)
{
var password = Encoding.UTF8.GetBytes(pin);
var data = new byte[password.Length + salt.Length];
password.CopyTo(data, 0);
salt.CopyTo(data, password.Length);
using (var sha = SHA256.Create())
{
return sha.ComputeHash(data);
}
}
private static bool _FixedTimeEquals(byte[] a, byte[] b)
{
if (a.Length != b.Length)
{
return false;
}
var diff = 0;
for (var i = 0; i < a.Length; i++)
{
diff |= a[i] ^ b[i];
}
return diff == 0;
}
}
}
+416
View File
@@ -0,0 +1,416 @@
#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Remote;
namespace VocaluxeWeb.Services
{
/// The queue is a normal playlist of the game (name from the settings), so it can also be used in the song screen.
/// Game mode and audio mode are stored in the playlist; who wished a song and who should sing it is only known to the webserver.
/// New wishes are inserted fairly: everybody gets one song per round.
/// Every change is pushed to all WebApps as live event "queue".
public sealed class CQueueService
{
private sealed class CMeta
{
public string Id;
public int SongId;
public ProfileRefDto RequestedBy;
public List<ProfileRefDto> Players = new List<ProfileRefDto>();
}
private sealed class CItem
{
public RemotePlaylistEntry Entry;
public CMeta Meta;
}
public sealed class CForbiddenException : Exception { }
private readonly IVocaluxeRemote _Remote;
private readonly CMainThreadDispatcher _Dispatcher;
private readonly CWebSettingsStore _Settings;
private readonly CSongLibrary _Songs;
private readonly CLiveEvents _Events;
private readonly object _Lock = new object();
private List<CMeta> _Metas = new List<CMeta>();
private string _Fingerprint;
private int? _StartedSongId;
public CQueueService(IVocaluxeRemote remote, CMainThreadDispatcher dispatcher, CWebSettingsStore settings, CSongLibrary songs, CLiveEvents events)
{
_Remote = remote;
_Dispatcher = dispatcher;
_Settings = settings;
_Songs = songs;
_Events = events;
}
public async Task<IList<QueueEntryDto>> GetAsync()
{
return await _ToDtosAsync((await _ChangeAsync(items => false)).Items);
}
public Task<IList<QueueEntryDto>> AddAsync(int songId, ESongGameMode gameMode, ESongAudioMode audioMode, List<ProfileRefDto> players, CSession session)
{
return _ChangeAndPublishAsync(items =>
{
var position = _FairPosition(items, session.ProfileId);
items.Insert(position, new CItem
{
Entry = new RemotePlaylistEntry { SongId = songId, GameMode = (int)gameMode, AudioMode = (int)audioMode },
Meta = new CMeta
{
Id = _NewId(),
SongId = songId,
RequestedBy = new ProfileRefDto { Id = session.ProfileId, Name = session.ProfileName },
Players = players ?? new List<ProfileRefDto>()
}
});
return true;
});
}
/// Adds several songs (e.g. from a playlist) for one person; each one is placed fairly.
public Task<IList<QueueEntryDto>> AddManyAsync(IList<RemotePlaylistEntry> songs, CSession session)
{
return _ChangeAndPublishAsync(items =>
{
foreach (var song in songs)
{
var position = _FairPosition(items, session.ProfileId);
items.Insert(position, new CItem
{
Entry = new RemotePlaylistEntry { SongId = song.SongId, GameMode = song.GameMode, AudioMode = song.AudioMode },
Meta = new CMeta
{
Id = _NewId(),
SongId = song.SongId,
RequestedBy = new ProfileRefDto { Id = session.ProfileId, Name = session.ProfileName }
}
});
}
return songs.Count > 0;
});
}
public Task<IList<QueueEntryDto>> UpdateAsync(string id, ESongGameMode? gameMode, ESongAudioMode? audioMode, List<ProfileRefDto> players, CSession session, bool canManage)
{
return _ChangeAndPublishAsync(items =>
{
var item = _Find(items, id);
_CheckOwner(item, session, canManage);
if (gameMode.HasValue)
{
item.Entry.GameMode = (int)gameMode.Value;
}
if (audioMode.HasValue)
{
item.Entry.AudioMode = (int)audioMode.Value;
}
if (players != null)
{
item.Meta.Players = players;
}
return true;
});
}
public Task<IList<QueueEntryDto>> RemoveAsync(string id, CSession session, bool canManage)
{
return _ChangeAndPublishAsync(items =>
{
var item = _Find(items, id);
_CheckOwner(item, session, canManage);
items.Remove(item);
return true;
});
}
public Task<IList<QueueEntryDto>> MoveAsync(string id, int newPosition)
{
return _ChangeAndPublishAsync(items =>
{
var item = _Find(items, id);
items.Remove(item);
items.Insert(Math.Max(0, Math.Min(newPosition, items.Count)), item);
return true;
});
}
public Task<IList<QueueEntryDto>> ClearAsync()
{
return _ChangeAndPublishAsync(items =>
{
var changed = items.Count > 0;
items.Clear();
return changed;
});
}
/// A song started in the game: its entry leaves the queue.
/// Skipped if the entry was already removed because it was started from the queue (see <see cref="MarkStarted" />).
public Task<IList<QueueEntryDto>> RemoveSungAsync(int songId)
{
return _ChangeAndPublishAsync(items =>
{
lock (_Lock)
{
if (_StartedSongId == songId)
{
_StartedSongId = null;
return false;
}
}
var item = items.FirstOrDefault(i => i.Entry.SongId == songId);
return item != null && items.Remove(item);
});
}
/// Creates the queue playlist if it does not exist yet, so it is available in the game right away. Main thread only.
public void EnsurePlaylist()
{
var name = _Settings.Current.QueuePlaylistName;
if (_Remote.GetPlaylists().All(p => p.Name != name))
{
_Remote.CreatePlaylist(name);
}
}
/// The queue with requesters and singers, for the queue screen of the game. Main thread only.
public IList<RemoteQueueItem> GetGameSnapshot()
{
var name = _Settings.Current.QueuePlaylistName;
var entries = _Remote.GetPlaylists().FirstOrDefault(p => p.Name == name)?.Entries ?? new List<RemotePlaylistEntry>();
lock (_Lock)
{
var items = _Match(entries);
_Metas = items.Select(i => i.Meta).ToList();
return items.Select(i => new RemoteQueueItem
{
SongId = i.Entry.SongId,
GameMode = i.Entry.GameMode,
AudioMode = i.Entry.AudioMode,
RequestedBy = i.Meta.RequestedBy?.Name,
PlayerIds = i.Meta.Players.Select(p => p.Id).ToList(),
PlayerNames = i.Meta.Players.Select(p => p.Name).ToList()
}).ToList();
}
}
/// Position of the entry in the queue, -1 if it is gone. Main thread only.
public int IndexOf(string id)
{
var name = _Settings.Current.QueuePlaylistName;
var entries = _Remote.GetPlaylists().FirstOrDefault(p => p.Name == name)?.Entries ?? new List<RemotePlaylistEntry>();
lock (_Lock)
{
return _Match(entries).FindIndex(i => i.Meta.Id == id);
}
}
/// The entry at this position was started: it leaves the queue right away. Main thread only.
public void MarkStarted(int index)
{
var name = _Settings.Current.QueuePlaylistName;
var playlist = _Remote.GetPlaylists().FirstOrDefault(p => p.Name == name);
if (playlist == null)
{
return;
}
lock (_Lock)
{
var items = _Match(playlist.Entries);
if (index < 0 || index >= items.Count)
{
return;
}
_StartedSongId = items[index].Entry.SongId;
items.RemoveAt(index);
_Remote.SetPlaylistEntries(playlist.Id, items.Select(i => i.Entry).ToList());
_Metas = items.Select(i => i.Meta).ToList();
_Fingerprint = _GetFingerprint(items.Select(i => i.Entry));
}
Task.Run(PublishAsync);
}
/// Called regularly on the main thread: true if the queue playlist was changed in the game.
public bool HasExternalChange()
{
var name = _Settings.Current.QueuePlaylistName;
var playlist = _Remote.GetPlaylists().FirstOrDefault(p => p.Name == name);
var fingerprint = _GetFingerprint(playlist?.Entries ?? new List<RemotePlaylistEntry>());
lock (_Lock)
{
return _Fingerprint != null && fingerprint != _Fingerprint;
}
}
/// Sends the current queue to all WebApps.
public async Task PublishAsync()
{
_Events.Publish("queue", await GetAsync());
}
private async Task<IList<QueueEntryDto>> _ChangeAndPublishAsync(Func<List<CItem>, bool> change)
{
var (items, changed) = await _ChangeAsync(change);
var dtos = await _ToDtosAsync(items);
if (changed)
{
_Events.Publish("queue", dtos);
}
return dtos;
}
/// Reads the queue playlist and applies a change in one go on the main thread, so the game cannot interfere.
private Task<(List<CItem> Items, bool Changed)> _ChangeAsync(Func<List<CItem>, bool> change)
{
var name = _Settings.Current.QueuePlaylistName;
return _Dispatcher.InvokeAsync(() =>
{
var playlist = _Remote.GetPlaylists().FirstOrDefault(p => p.Name == name);
var entries = playlist?.Entries ?? new List<RemotePlaylistEntry>();
lock (_Lock)
{
var items = _Match(entries);
var changed = change(items);
if (changed)
{
var playlistId = playlist?.Id ?? _Remote.CreatePlaylist(name);
_Remote.SetPlaylistEntries(playlistId, items.Select(i => i.Entry).ToList());
}
_Metas = items.Select(i => i.Meta).ToList();
_Fingerprint = _GetFingerprint(items.Select(i => i.Entry));
return (items, changed);
}
});
}
/// Assigns the known metadata to the current playlist entries, which may have been changed in the game.
private List<CItem> _Match(IList<RemotePlaylistEntry> entries)
{
var result = new List<CItem>(entries.Count);
var next = 0;
foreach (var entry in entries)
{
var found = _Metas.FindIndex(next, m => m.SongId == entry.SongId);
CMeta meta;
if (found >= 0)
{
meta = _Metas[found];
next = found + 1;
}
else
{
meta = new CMeta { Id = _NewId(), SongId = entry.SongId };
}
result.Add(new CItem { Entry = new RemotePlaylistEntry { SongId = entry.SongId, GameMode = entry.GameMode, AudioMode = entry.AudioMode }, Meta = meta });
}
return result;
}
private static CItem _Find(List<CItem> items, string id)
{
return items.FirstOrDefault(i => i.Meta.Id == id) ?? throw new KeyNotFoundException("Queue entry not found");
}
private static void _CheckOwner(CItem item, CSession session, bool canManage)
{
if (!canManage && item.Meta.RequestedBy?.Id != session.ProfileId)
{
throw new CForbiddenException();
}
}
/// The n-th wish of a person goes behind everybody's n-th wish.
/// Entries without requester count as a wish of their own.
private static int _FairPosition(IList<CItem> items, Guid profileId)
{
var round = items.Count(i => i.Meta.RequestedBy?.Id == profileId);
var seen = new Dictionary<Guid, int>();
var position = 0;
for (var i = 0; i < items.Count; i++)
{
var entryRound = 0;
var requester = items[i].Meta.RequestedBy;
if (requester != null)
{
seen.TryGetValue(requester.Id, out entryRound);
seen[requester.Id] = entryRound + 1;
}
if (entryRound <= round)
{
position = i + 1;
}
}
return position;
}
private static string _GetFingerprint(IEnumerable<RemotePlaylistEntry> entries)
{
return string.Join(",", entries.Select(e => e.SongId + ":" + e.GameMode + ":" + e.AudioMode));
}
private static string _NewId()
{
return Guid.NewGuid().ToString("N").Substring(0, 12);
}
private async Task<IList<QueueEntryDto>> _ToDtosAsync(List<CItem> items)
{
var result = new List<QueueEntryDto>(items.Count);
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
result.Add(new QueueEntryDto
{
Id = item.Meta.Id,
Position = i,
Song = await _Songs.GetDtoAsync(item.Entry.SongId),
RequestedBy = item.Meta.RequestedBy,
GameMode = (ESongGameMode)item.Entry.GameMode,
AudioMode = (ESongAudioMode)item.Entry.AudioMode,
Players = item.Meta.Players
});
}
return result;
}
}
}
+169
View File
@@ -0,0 +1,169 @@
#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
using VocaluxeWeb.Remote;
namespace VocaluxeWeb.Services
{
public sealed class CSession
{
/// Public id of the session (the token itself is secret).
public string Id;
public string Token;
public string UserAgent;
public string RemoteAddress;
public DateTime Created;
public Guid ProfileId;
public string ProfileName;
public ERemoteRole Role;
public DateTime LastSeen;
}
/// Logged in devices. The token is kept in an HttpOnly cookie. Sessions live in memory only:
/// after a restart of Vocaluxe the WebApp logs in again (automatically for profiles without PIN).
public sealed class CSessionStore
{
public const string CookieName = "vlx_session";
private static readonly TimeSpan _IdleTimeout = TimeSpan.FromHours(12);
private static readonly TimeSpan _LockTime = TimeSpan.FromSeconds(30);
private const int _MaxFailedLogins = 5;
private readonly ConcurrentDictionary<string, CSession> _Sessions = new ConcurrentDictionary<string, CSession>();
private readonly ConcurrentDictionary<Guid, (int Count, DateTime LockedUntil)> _FailedLogins = new ConcurrentDictionary<Guid, (int, DateTime)>();
public CSession Create(HttpContext context, Guid profileId, string profileName, ERemoteRole role)
{
var session = new CSession
{
Id = CWebSettingsStore.CreateToken(6),
Token = CWebSettingsStore.CreateToken(32),
UserAgent = context.Request.Headers["User-Agent"].ToString(),
RemoteAddress = context.Connection.RemoteIpAddress?.ToString(),
Created = DateTime.UtcNow,
ProfileId = profileId,
ProfileName = profileName,
Role = role,
LastSeen = DateTime.UtcNow
};
_Sessions[session.Token] = session;
_FailedLogins.TryRemove(profileId, out _);
context.Response.Cookies.Append(CookieName, session.Token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Secure = context.Request.IsHttps,
Path = "/",
Expires = DateTimeOffset.UtcNow.AddDays(30)
});
return session;
}
public CSession Get(HttpContext context)
{
if (!context.Request.Cookies.TryGetValue(CookieName, out var token) || !_Sessions.TryGetValue(token, out var session))
{
return null;
}
var now = DateTime.UtcNow;
if (now - session.LastSeen > _IdleTimeout)
{
_Sessions.TryRemove(token, out _);
return null;
}
session.LastSeen = now;
return session;
}
public void Remove(HttpContext context)
{
if (context.Request.Cookies.TryGetValue(CookieName, out var token))
{
_Sessions.TryRemove(token, out _);
}
context.Response.Cookies.Delete(CookieName, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Secure = context.Request.IsHttps,
Path = "/"
});
}
/// All active sessions, most recently used first.
public IList<CSession> List()
{
var limit = DateTime.UtcNow - _IdleTimeout;
foreach (var expired in _Sessions.Values.Where(s => s.LastSeen < limit).ToList())
{
_Sessions.TryRemove(expired.Token, out _);
}
return _Sessions.Values.OrderByDescending(s => s.LastSeen).ToList();
}
/// Logs out the device with this session id.
public bool RemoveById(string id)
{
var session = _Sessions.Values.FirstOrDefault(s => s.Id == id);
return session != null && _Sessions.TryRemove(session.Token, out _);
}
/// Logs out all devices of a profile, e.g. after its role changed.
public void RemoveProfile(Guid profileId)
{
foreach (var session in _Sessions.Values.Where(s => s.ProfileId == profileId).ToList())
{
_Sessions.TryRemove(session.Token, out _);
}
}
public void UpdateProfileName(Guid profileId, string name)
{
foreach (var session in _Sessions.Values.Where(s => s.ProfileId == profileId))
{
session.ProfileName = name;
}
}
public bool IsLocked(Guid profileId)
{
return _FailedLogins.TryGetValue(profileId, out var entry) && entry.LockedUntil > DateTime.UtcNow;
}
/// Counts a wrong PIN. After too many attempts the profile is locked for a short time.
public void RegisterFailedLogin(Guid profileId)
{
_FailedLogins.AddOrUpdate(profileId,
_ => (1, DateTime.MinValue),
(_, entry) =>
{
var count = entry.Count + 1;
return count >= _MaxFailedLogins ? (0, DateTime.UtcNow + _LockTime) : (count, entry.LockedUntil);
});
}
}
}
+268
View File
@@ -0,0 +1,268 @@
#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Remote;
namespace VocaluxeWeb.Services
{
public sealed class CSongQuery
{
public string Text;
public string Language;
public string Genre;
public int? Decade;
public bool? Duet;
public ESongSort Sort;
public int Page;
public int PageSize = 50;
}
/// Copy of the game's song list for searching outside the main thread.
/// It is refreshed when the game reports a new library version (checked at most every few seconds).
public sealed class CSongLibrary
{
private static readonly TimeSpan _VersionCheckInterval = TimeSpan.FromSeconds(5);
private sealed class CEntry
{
public RemoteSong Song;
public SongDto Dto;
public string SearchText;
public int? Decade;
}
private readonly IVocaluxeRemote _Remote;
private readonly CMainThreadDispatcher _Dispatcher;
private readonly SemaphoreSlim _RefreshLock = new SemaphoreSlim(1, 1);
private IReadOnlyList<CEntry> _Entries = new CEntry[0];
private Dictionary<int, CEntry> _ById = new Dictionary<int, CEntry>();
private int _Version = -1;
private DateTime _LastCheck = DateTime.MinValue;
public CSongLibrary(IVocaluxeRemote remote, CMainThreadDispatcher dispatcher)
{
_Remote = remote;
_Dispatcher = dispatcher;
}
public async Task<SongPageDto> SearchAsync(CSongQuery query)
{
await _EnsureFreshAsync();
IEnumerable<CEntry> result = _Entries;
var words = _Normalize(query.Text).Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (words.Length > 0)
{
result = result.Where(e => words.All(w => e.SearchText.Contains(w)));
}
if (!string.IsNullOrEmpty(query.Language))
{
result = result.Where(e => e.Song.Languages.Contains(query.Language, StringComparer.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(query.Genre))
{
result = result.Where(e => e.Song.Genres.Contains(query.Genre, StringComparer.OrdinalIgnoreCase));
}
if (query.Decade.HasValue)
{
result = result.Where(e => e.Decade == query.Decade);
}
if (query.Duet.HasValue)
{
result = result.Where(e => e.Song.IsDuet == query.Duet.Value);
}
var sorted = _Sort(result, query.Sort).ToList();
var pageSize = Math.Max(1, Math.Min(query.PageSize, 200));
var page = Math.Max(0, query.Page);
return new SongPageDto
{
Items = sorted.Skip(page * pageSize).Take(pageSize).Select(e => e.Dto).ToList(),
Total = sorted.Count,
Page = page,
PageSize = pageSize
};
}
public async Task<SongFacetsDto> GetFacetsAsync()
{
await _EnsureFreshAsync();
var entries = _Entries;
return new SongFacetsDto
{
Languages = _Count(entries.SelectMany(e => e.Song.Languages)),
Genres = _Count(entries.SelectMany(e => e.Song.Genres)),
Decades = entries.Where(e => e.Decade.HasValue)
.GroupBy(e => e.Decade.Value)
.OrderByDescending(g => g.Key)
.Select(g => new FacetValueDto { Value = g.Key.ToString(CultureInfo.InvariantCulture), Count = g.Count() })
.ToList()
};
}
public async Task<RemoteSong> GetAsync(int songId)
{
await _EnsureFreshAsync();
return _ById.TryGetValue(songId, out var entry) ? entry.Song : null;
}
/// Song for display; songs that are no longer known get a placeholder.
public async Task<SongDto> GetDtoAsync(int songId)
{
await _EnsureFreshAsync();
return _ById.TryGetValue(songId, out var entry)
? entry.Dto
: new SongDto { Id = songId, Title = "?", Artist = "?", GameModes = new List<ESongGameMode>(), AudioModes = new List<ESongAudioMode>() };
}
public async Task<StateDto> CreateStateAsync(RemoteState state)
{
return new StateDto { Screen = state.Screen, CurrentSong = state.SongId.HasValue ? await GetDtoAsync(state.SongId.Value) : null };
}
private async Task _EnsureFreshAsync()
{
if (DateTime.UtcNow - _LastCheck < _VersionCheckInterval && _Version >= 0)
{
return;
}
await _RefreshLock.WaitAsync();
try
{
if (DateTime.UtcNow - _LastCheck < _VersionCheckInterval && _Version >= 0)
{
return;
}
var version = await _Dispatcher.InvokeAsync(() => _Remote.SongLibraryVersion);
if (version != _Version)
{
var songs = await _Dispatcher.InvokeAsync(() => _Remote.GetSongs());
var entries = songs.Select(_CreateEntry).ToList();
_ById = entries.GroupBy(e => e.Song.Id).ToDictionary(g => g.Key, g => g.First());
_Entries = entries;
_Version = version;
}
_LastCheck = DateTime.UtcNow;
}
finally
{
_RefreshLock.Release();
}
}
private static CEntry _CreateEntry(RemoteSong song)
{
return new CEntry
{
Song = song,
Dto = ToDto(song),
SearchText = _Normalize(song.Artist + " " + song.Title + " " + string.Join(" ", song.Editions ?? new string[0])),
Decade = _ParseDecade(song.Year)
};
}
public static SongDto ToDto(RemoteSong song)
{
return new SongDto
{
Id = song.Id,
Title = song.Title,
Artist = song.Artist,
Year = string.IsNullOrEmpty(song.Year) ? null : song.Year,
Language = song.Languages?.FirstOrDefault(),
Genre = song.Genres?.FirstOrDefault(),
IsDuet = song.IsDuet,
HasCover = !string.IsNullOrEmpty(song.CoverPath),
HasAudio = !string.IsNullOrEmpty(song.AudioPath),
PreviewStart = song.PreviewStart,
GameModes = (song.GameModes ?? new int[0]).Select(m => (ESongGameMode)m).ToList(),
AudioModes = (song.AudioModes ?? new int[0]).Select(m => (ESongAudioMode)m).ToList()
};
}
private static IEnumerable<CEntry> _Sort(IEnumerable<CEntry> entries, ESongSort sort)
{
var culture = StringComparer.Create(CultureInfo.InvariantCulture, true);
switch (sort)
{
case ESongSort.Title:
return entries.OrderBy(e => e.Song.Title, culture).ThenBy(e => e.Song.Artist, culture);
case ESongSort.Year:
return entries.OrderByDescending(e => e.Song.Year).ThenBy(e => e.Song.Artist, culture);
default:
return entries.OrderBy(e => e.Song.Artist, culture).ThenBy(e => e.Song.Title, culture);
}
}
private static IList<FacetValueDto> _Count(IEnumerable<string> values)
{
return values.Where(v => !string.IsNullOrWhiteSpace(v))
.GroupBy(v => v.Trim(), StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.ThenBy(g => g.Key)
.Select(g => new FacetValueDto { Value = g.First().Trim(), Count = g.Count() })
.ToList();
}
private static int? _ParseDecade(string year)
{
if (string.IsNullOrEmpty(year) || year.Length < 4 || !int.TryParse(year.Substring(0, 4), out var value) || value < 1900)
{
return null;
}
return value / 10 * 10;
}
/// Lower case without accents, so "beyonce" finds "Beyoncé".
private static string _Normalize(string text)
{
if (string.IsNullOrEmpty(text))
{
return "";
}
var builder = new StringBuilder(text.Length);
foreach (var c in text.Normalize(NormalizationForm.FormD))
{
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)
{
builder.Append(char.ToLowerInvariant(c));
}
}
return builder.ToString();
}
}
}
+71
View File
@@ -0,0 +1,71 @@
#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System.Linq;
namespace VocaluxeWeb.Services
{
/// Short, readable device description from a browser's user agent, e.g. "iPhone · Safari".
public static class CUserAgent
{
/// Checked in this order: the first entry whose markers appear in the user agent wins.
private static readonly (string Name, string[] Markers)[] _Devices =
{
("iPhone", new[] { "iPhone" }),
("iPad", new[] { "iPad" }),
("Android phone", new[] { "Android", "Mobile" }),
("Android tablet", new[] { "Android" }),
("Windows", new[] { "Windows" }),
("Mac", new[] { "Mac OS X" }),
("Linux", new[] { "Linux" })
};
/// Checked in this order, because most browsers also name the engines of others (e.g. Edge contains "Chrome/" and "Safari/").
private static readonly (string Name, string[] Markers)[] _Browsers =
{
("Edge", new[] { "Edg/" }),
("Firefox", new[] { "Firefox/" }),
("Firefox", new[] { "FxiOS" }),
("Samsung Internet", new[] { "SamsungBrowser" }),
("Chrome", new[] { "Chrome/" }),
("Chrome", new[] { "CriOS" }),
("Safari", new[] { "Safari/" })
};
public static string Describe(string userAgent)
{
if (string.IsNullOrEmpty(userAgent))
{
return null;
}
var device = _Find(_Devices, userAgent);
var browser = _Find(_Browsers, userAgent);
if (device == null && browser == null)
{
return userAgent.Length > 40 ? userAgent.Substring(0, 40) + "…" : userAgent;
}
return device != null && browser != null ? device + " · " + browser : device ?? browser;
}
private static string _Find((string Name, string[] Markers)[] candidates, string userAgent)
{
return candidates.Where(c => c.Markers.All(userAgent.Contains)).Select(c => c.Name).FirstOrDefault();
}
}
}
+152
View File
@@ -0,0 +1,152 @@
#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Remote;
namespace VocaluxeWeb.Services
{
/// Settings of the webserver, stored as JSON next to the game's data. Admins always have every permission.
public sealed class CWebSettings
{
public bool AllowProfileCreation = true;
public string QueuePlaylistName = "WebApp Queue";
[JsonProperty(ItemConverterType = typeof(StringEnumConverter))]
public List<EPermission> GuestPermissions = new List<EPermission> { EPermission.AddToQueue };
[JsonProperty(ItemConverterType = typeof(StringEnumConverter))]
public List<EPermission> PlayerPermissions = new List<EPermission>
{
EPermission.AddToQueue,
EPermission.UseKeyboard,
EPermission.EditPlaylists,
EPermission.EditOwnProfile,
EPermission.CreateProfiles
};
public CWebSettings Clone()
{
var clone = (CWebSettings)MemberwiseClone();
clone.GuestPermissions = GuestPermissions.ToList();
clone.PlayerPermissions = PlayerPermissions.ToList();
return clone;
}
public IList<EPermission> GetPermissions(ERemoteRole role)
{
switch (role)
{
case ERemoteRole.Admin:
return (EPermission[])Enum.GetValues(typeof(EPermission));
case ERemoteRole.Player:
return PlayerPermissions;
default:
return GuestPermissions;
}
}
}
public sealed class CWebSettingsStore
{
private readonly string _Path;
private readonly object _Lock = new object();
private CWebSettings _Settings;
public CWebSettingsStore(string path)
{
_Path = path;
_Settings = _Load();
}
/// A copy of the current settings.
public CWebSettings Current
{
get
{
lock (_Lock)
{
return _Settings.Clone();
}
}
}
public void Update(Action<CWebSettings> change)
{
lock (_Lock)
{
var settings = _Settings.Clone();
change(settings);
_Settings = settings;
_Save(settings);
}
}
private CWebSettings _Load()
{
CWebSettings settings = null;
if (!string.IsNullOrEmpty(_Path) && File.Exists(_Path))
{
try
{
settings = JsonConvert.DeserializeObject<CWebSettings>(File.ReadAllText(_Path));
}
catch (Exception)
{
//Broken file, start with the default settings
}
}
return settings ?? new CWebSettings();
}
private void _Save(CWebSettings settings)
{
if (string.IsNullOrEmpty(_Path))
{
return;
}
var directory = Path.GetDirectoryName(_Path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_Path, JsonConvert.SerializeObject(settings, Formatting.Indented));
}
/// Random URL-safe token with the given number of bytes of entropy.
public static string CreateToken(int bytes)
{
var buffer = new byte[bytes];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(buffer);
}
return Convert.ToBase64String(buffer).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}
}