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:
1 parent
e3e9d1d6ae
commit
336d08040e
186 files changed
+14971
-6988
No files matched your search
@@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('/', '_');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user