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

+187
View File
@@ -0,0 +1,187 @@
#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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Hosting;
using VocaluxeWeb.Remote;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
[Route("api/v1/admin")]
[RequireAdmin]
public sealed class AdminController : ApiControllerBase
{
private readonly CWebSettingsStore _Settings;
private readonly CQueueService _Queue;
private readonly CMainThreadDispatcher _Dispatcher;
private readonly IVocaluxeRemote _Remote;
private readonly CSessionStore _Sessions;
private readonly CLiveEvents _Events;
private readonly CWebHost _Host;
public AdminController(CWebSettingsStore settings, CQueueService queue, CMainThreadDispatcher dispatcher, IVocaluxeRemote remote, CSessionStore sessions,
CLiveEvents events, CWebHost host)
{
_Settings = settings;
_Queue = queue;
_Dispatcher = dispatcher;
_Remote = remote;
_Sessions = sessions;
_Events = events;
_Host = host;
}
[HttpGet("settings")]
public ActionResult<AdminSettingsDto> GetSettings()
{
return _ToDto(_Settings.Current);
}
[HttpPut("settings")]
public async Task<ActionResult<AdminSettingsDto>> UpdateSettings([FromBody] AdminSettingsDto request)
{
_Settings.Update(s =>
{
s.AllowProfileCreation = request.AllowProfileCreation.Value;
s.QueuePlaylistName = request.QueuePlaylistName.Trim();
s.GuestPermissions = request.GuestPermissions.Distinct().ToList();
s.PlayerPermissions = request.PlayerPermissions.Distinct().ToList();
});
await _Dispatcher.InvokeAsync(_Queue.EnsurePlaylist);
return _ToDto(_Settings.Current);
}
/// Address, HTTPS and connected WebApps. The port is set in config.xml, HTTPS in the game (Options - Server).
[HttpGet("status")]
public ActionResult<AdminStatusDto> GetStatus()
{
return new AdminStatusDto
{
Version = _Remote.Version,
Address = _Host.Address?.AbsoluteUri ?? "",
Port = _Host.Port,
Https = _Host.UseHttps,
ConnectedClients = _Events.ClientCount
};
}
/// All profiles, including inactive ones.
[HttpGet("profiles")]
public async Task<ActionResult<IList<ProfileDto>>> GetProfiles()
{
var profiles = await _Dispatcher.InvokeAsync(() => _Remote.GetProfiles());
return profiles.Select(ToDto).ToList();
}
/// Activates or deactivates a profile. Inactive profiles can't log in; their devices are logged out.
[HttpPut("profiles/{id}/active")]
public async Task<ActionResult<ProfileDto>> SetActive(Guid id, [FromBody] ActiveDto request)
{
if (id == CurrentSession.ProfileId && !request.Active.Value)
{
return Problem(StatusCodes.Status400BadRequest, "You cannot deactivate your own profile.");
}
var profile = await _Find(id);
if (profile == null)
{
return Problem(StatusCodes.Status404NotFound, "Profile not found.");
}
await _Dispatcher.InvokeAsync(() => _Remote.UpdateProfile(id, new ProfileChange { IsActive = request.Active.Value }));
if (!request.Active.Value)
{
_Sessions.RemoveProfile(id);
}
return ToDto(await _Find(id));
}
/// Deletes a profile in the game (including its file) and logs out its devices.
[HttpDelete("profiles/{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> DeleteProfile(Guid id)
{
if (id == CurrentSession.ProfileId)
{
return Problem(StatusCodes.Status400BadRequest, "You cannot delete your own profile.");
}
if (await _Find(id) == null)
{
return Problem(StatusCodes.Status404NotFound, "Profile not found.");
}
await _Dispatcher.InvokeAsync(() => _Remote.DeleteProfile(id));
_Sessions.RemoveProfile(id);
return NoContent();
}
/// Devices that are logged in.
[HttpGet("sessions")]
public ActionResult<IList<AdminSessionDto>> GetSessions()
{
return _Sessions.List().Select(s => new AdminSessionDto
{
Id = s.Id,
Profile = new ProfileRefDto { Id = s.ProfileId, Name = s.ProfileName },
Role = s.Role,
Device = CUserAgent.Describe(s.UserAgent),
RemoteAddress = s.RemoteAddress,
LastSeen = s.LastSeen,
IsCurrent = s.Id == CurrentSession.Id
}).ToList();
}
/// Logs out a device.
[HttpDelete("sessions/{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult DeleteSession(string id)
{
if (id == CurrentSession.Id)
{
return Problem(StatusCodes.Status400BadRequest, "Use logout to log out this device.");
}
return _Sessions.RemoveById(id) ? NoContent() : (IActionResult)Problem(StatusCodes.Status404NotFound, "Session not found.");
}
private async Task<RemoteProfile> _Find(Guid id)
{
return (await _Dispatcher.InvokeAsync(() => _Remote.GetProfiles())).FirstOrDefault(p => p.Id == id);
}
private static AdminSettingsDto _ToDto(CWebSettings settings)
{
return new AdminSettingsDto
{
AllowProfileCreation = settings.AllowProfileCreation,
QueuePlaylistName = settings.QueuePlaylistName,
GuestPermissions = settings.GuestPermissions,
PlayerPermissions = settings.PlayerPermissions
};
}
}
}
+119
View File
@@ -0,0 +1,119 @@
#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.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Remote;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
[ApiController]
[Produces("application/json")]
public abstract class ApiControllerBase : ControllerBase
{
internal const string SessionItemKey = "vlx.session";
/// The logged in session; set for actions with <see cref="RequireSessionAttribute" />.
protected CSession CurrentSession => HttpContext.Items[SessionItemKey] as CSession;
protected bool HasPermission(EPermission permission)
{
var session = CurrentSession;
var settings = HttpContext.RequestServices.GetRequiredService<CWebSettingsStore>().Current;
return session != null && settings.GetPermissions(session.Role).Contains(permission);
}
protected ObjectResult Problem(int status, string title)
{
return new ObjectResult(new ProblemDetails { Status = status, Title = title }) { StatusCode = status, ContentTypes = { "application/problem+json" } };
}
public static ProfileDto ToDto(RemoteProfile profile)
{
return new ProfileDto
{
Id = profile.Id,
Name = profile.Name,
Role = profile.Role,
HasPin = profile.PinHash != null,
HasAvatar = !string.IsNullOrEmpty(profile.AvatarPath),
Difficulty = profile.Difficulty,
IsActive = profile.IsActive
};
}
}
/// The request needs a logged in profile and, optionally, a permission. Admins have all permissions.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class RequireSessionAttribute : Attribute, IAuthorizationFilter
{
private readonly EPermission? _Permission;
public RequireSessionAttribute() { }
public RequireSessionAttribute(EPermission permission)
{
_Permission = permission;
}
public void OnAuthorization(AuthorizationFilterContext context)
{
var services = context.HttpContext.RequestServices;
var session = services.GetRequiredService<CSessionStore>().Get(context.HttpContext);
if (session == null)
{
context.Result = _Problem(StatusCodes.Status401Unauthorized, "Please log in.");
return;
}
context.HttpContext.Items[ApiControllerBase.SessionItemKey] = session;
if (_Permission.HasValue && !services.GetRequiredService<CWebSettingsStore>().Current.GetPermissions(session.Role).Contains(_Permission.Value))
{
context.Result = _Problem(StatusCodes.Status403Forbidden, "You are not allowed to do this.");
}
}
private static ObjectResult _Problem(int status, string title)
{
return new ObjectResult(new ProblemDetails { Status = status, Title = title }) { StatusCode = status, ContentTypes = { "application/problem+json" } };
}
}
/// Only admins.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class RequireAdminAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
var session = context.HttpContext.RequestServices.GetRequiredService<CSessionStore>().Get(context.HttpContext);
if (session == null || session.Role != ERemoteRole.Admin)
{
var status = session == null ? StatusCodes.Status401Unauthorized : StatusCodes.Status403Forbidden;
context.Result = new ObjectResult(new ProblemDetails { Status = status, Title = "Admins only." }) { StatusCode = status, ContentTypes = { "application/problem+json" } };
return;
}
context.HttpContext.Items[ApiControllerBase.SessionItemKey] = session;
}
}
}
+68
View File
@@ -0,0 +1,68 @@
#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.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
/// Avatars that come with Vocaluxe, to choose from when changing the profile picture.
[Route("api/v1/avatars")]
public sealed class AvatarsController : ApiControllerBase
{
private readonly IVocaluxeRemote _Remote;
private readonly CMainThreadDispatcher _Dispatcher;
private readonly CImageService _Images;
public AvatarsController(IVocaluxeRemote remote, CMainThreadDispatcher dispatcher, CImageService images)
{
_Remote = remote;
_Dispatcher = dispatcher;
_Images = images;
}
[HttpGet]
[RequireSession]
public async Task<ActionResult<IList<AvatarDto>>> GetAll()
{
var avatars = await _Dispatcher.InvokeAsync(() => _Remote.GetAvatars());
return avatars.Select(a => new AvatarDto { Id = a.Id }).ToList();
}
/// The avatar as JPEG. No login needed.
[HttpGet("{id}")]
[Produces("image/jpeg")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
public async Task<IActionResult> GetImage(int id, [FromQuery] int size = 200)
{
var avatar = (await _Dispatcher.InvokeAsync(() => _Remote.GetAvatars())).FirstOrDefault(a => a.Id == id);
var file = _Images.GetScaled(avatar?.Path, size, out var etag);
if (file == null)
{
return NotFound();
}
return CImageResult.Create(this, file, etag);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
#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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
/// Turns unhandled exceptions into ProblemDetails responses.
sealed class CExceptionFilter : IExceptionFilter
{
private readonly Action<Exception> _OnError;
public CExceptionFilter(Action<Exception> onError)
{
_OnError = onError;
}
public void OnException(ExceptionContext context)
{
ProblemDetails problem;
if (context.Exception is TimeoutException)
{
problem = new ProblemDetails { Status = StatusCodes.Status503ServiceUnavailable, Title = "Vocaluxe is busy, please try again." };
}
else if (context.Exception is CQueueService.CForbiddenException)
{
problem = new ProblemDetails { Status = StatusCodes.Status403Forbidden, Title = "You are not allowed to do this." };
}
else if (context.Exception is ArgumentOutOfRangeException || context.Exception is KeyNotFoundException)
{
problem = new ProblemDetails { Status = StatusCodes.Status404NotFound, Title = "Not found." };
}
else
{
_OnError?.Invoke(context.Exception);
problem = new ProblemDetails { Status = StatusCodes.Status500InternalServerError, Title = "Internal server error." };
}
context.Result = new ObjectResult(problem) { StatusCode = problem.Status, ContentTypes = { "application/problem+json" } };
context.ExceptionHandled = true;
}
}
}
+38
View File
@@ -0,0 +1,38 @@
#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 Microsoft.AspNetCore.Mvc;
namespace VocaluxeWeb.Api
{
static class CImageResult
{
/// Serves a cached JPEG with ETag, so browsers only download it once.
public static IActionResult Create(ControllerBase controller, string file, string etag)
{
var response = controller.Response;
response.Headers["Cache-Control"] = "public, max-age=86400";
response.Headers["ETag"] = etag;
if (controller.Request.Headers["If-None-Match"] == etag)
{
return controller.StatusCode(304);
}
return controller.PhysicalFile(file, "image/jpeg");
}
}
}
+59
View File
@@ -0,0 +1,59 @@
#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.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
/// Live events as Server-Sent Events (use EventSource in the browser).
/// Events: "state" (StateDto), "queue" (QueueEntryDto[]), "notification" (NotificationDto).
/// The current state and queue are sent right after connecting.
[Route("api/v1/events")]
[RequireSession]
public sealed class EventsController : ApiControllerBase
{
private readonly CLiveEvents _Events;
private readonly IVocaluxeRemote _Remote;
private readonly CMainThreadDispatcher _Dispatcher;
private readonly CSongLibrary _Songs;
private readonly CQueueService _Queue;
private readonly CSessionStore _Sessions;
public EventsController(CLiveEvents events, IVocaluxeRemote remote, CMainThreadDispatcher dispatcher, CSongLibrary songs, CQueueService queue, CSessionStore sessions)
{
_Events = events;
_Remote = remote;
_Dispatcher = dispatcher;
_Songs = songs;
_Queue = queue;
_Sessions = sessions;
}
[HttpGet]
[Produces("text/event-stream")]
public async Task Stream()
{
var state = await _Songs.CreateStateAsync(await _Dispatcher.InvokeAsync(() => _Remote.GetState()));
var queue = await _Queue.GetAsync();
await _Events.StreamAsync(Response, new (string, object)[] { ("state", state), ("queue", queue) }, () => _Sessions.Get(HttpContext) != null,
HttpContext.RequestAborted);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
#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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Contracts;
namespace VocaluxeWeb.Api
{
/// Remote keyboard.
[Route("api/v1/input")]
[RequireSession(EPermission.UseKeyboard)]
public sealed class InputController : ApiControllerBase
{
private readonly IVocaluxeRemote _Remote;
public InputController(IVocaluxeRemote remote)
{
_Remote = remote;
}
[HttpPost("key")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult SendKey([FromBody] KeyDto request)
{
_Remote.SendKey(request.Key.Value);
return NoContent();
}
[HttpPost("text")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult SendText([FromBody] TextDto request)
{
_Remote.SendText(request.Text);
return NoContent();
}
}
}
+218
View File
@@ -0,0 +1,218 @@
#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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Remote;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
/// The game's playlists. The queue playlist is not listed here, see /queue.
[Route("api/v1/playlists")]
[RequireSession]
public sealed class PlaylistsController : ApiControllerBase
{
private const string _PlaylistNotFound = "Playlist not found.";
private readonly IVocaluxeRemote _Remote;
private readonly CMainThreadDispatcher _Dispatcher;
private readonly CSongLibrary _Songs;
private readonly CWebSettingsStore _Settings;
public PlaylistsController(IVocaluxeRemote remote, CMainThreadDispatcher dispatcher, CSongLibrary songs, CWebSettingsStore settings)
{
_Remote = remote;
_Dispatcher = dispatcher;
_Songs = songs;
_Settings = settings;
}
[HttpGet]
public async Task<ActionResult<IList<PlaylistSummaryDto>>> GetAll()
{
var queueName = _Settings.Current.QueuePlaylistName;
var playlists = await _Dispatcher.InvokeAsync(() => _Remote.GetPlaylists());
return playlists.Where(p => p.Name != queueName)
.OrderBy(p => p.Name, StringComparer.CurrentCultureIgnoreCase)
.Select(p => new PlaylistSummaryDto { Id = p.Id, Name = p.Name, SongCount = p.Entries.Count })
.ToList();
}
[HttpGet("{id}")]
public async Task<ActionResult<PlaylistDto>> Get(int id)
{
var playlist = await _Find(id);
return playlist == null ? Problem(StatusCodes.Status404NotFound, _PlaylistNotFound) : await _ToDto(playlist);
}
[HttpPost]
[RequireSession(EPermission.EditPlaylists)]
public async Task<ActionResult<PlaylistDto>> Create([FromBody] PlaylistNameDto request)
{
var name = request.Name.Trim();
var invalid = _CheckName(name);
if (invalid != null)
{
return invalid;
}
var id = await _Dispatcher.InvokeAsync(() => _Remote.CreatePlaylist(name));
return await _ToDto(await _Find(id));
}
[HttpPut("{id}")]
[RequireSession(EPermission.EditPlaylists)]
public async Task<ActionResult<PlaylistDto>> Rename(int id, [FromBody] PlaylistNameDto request)
{
if (await _Find(id) == null)
{
return Problem(StatusCodes.Status404NotFound, _PlaylistNotFound);
}
var name = request.Name.Trim();
var invalid = _CheckName(name);
if (invalid != null)
{
return invalid;
}
await _Dispatcher.InvokeAsync(() => _Remote.RenamePlaylist(id, name));
return await _ToDto(await _Find(id));
}
[HttpDelete("{id}")]
[RequireSession(EPermission.DeletePlaylists)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Delete(int id)
{
if (await _Find(id) == null)
{
return Problem(StatusCodes.Status404NotFound, _PlaylistNotFound);
}
await _Dispatcher.InvokeAsync(() => _Remote.DeletePlaylist(id));
return NoContent();
}
[HttpPost("{id}/songs")]
[RequireSession(EPermission.EditPlaylists)]
public async Task<ActionResult<PlaylistDto>> AddSong(int id, [FromBody] SongRefDto request)
{
if (await _Songs.GetAsync(request.SongId.Value) == null)
{
return Problem(StatusCodes.Status404NotFound, "Song not found.");
}
return await _Change(id, entries =>
{
var position = Math.Min(request.Position ?? entries.Count, entries.Count);
entries.Insert(position, new RemotePlaylistEntry { SongId = request.SongId.Value });
});
}
[HttpDelete("{id}/songs/{position}")]
[RequireSession(EPermission.EditPlaylists)]
public Task<ActionResult<PlaylistDto>> RemoveSong(int id, int position)
{
return _Change(id, entries =>
{
if (position < 0 || position >= entries.Count)
{
throw new KeyNotFoundException("No song at this position");
}
entries.RemoveAt(position);
});
}
[HttpPut("{id}/songs/{position}/position")]
[RequireSession(EPermission.EditPlaylists)]
public Task<ActionResult<PlaylistDto>> MoveSong(int id, int position, [FromBody] PositionDto request)
{
return _Change(id, entries =>
{
if (position < 0 || position >= entries.Count)
{
throw new KeyNotFoundException("No song at this position");
}
var entry = entries[position];
entries.RemoveAt(position);
entries.Insert(Math.Min(request.Position.Value, entries.Count), entry);
});
}
/// Reads, changes and saves the playlist in one step on the main thread.
private async Task<ActionResult<PlaylistDto>> _Change(int id, Action<List<RemotePlaylistEntry>> change)
{
var queueName = _Settings.Current.QueuePlaylistName;
var playlist = await _Dispatcher.InvokeAsync(() =>
{
var current = _Remote.GetPlaylists().FirstOrDefault(p => p.Id == id && p.Name != queueName);
if (current == null)
{
return null;
}
var entries = current.Entries.ToList();
change(entries);
_Remote.SetPlaylistEntries(id, entries);
current.Entries = entries;
return current;
});
return playlist == null ? Problem(StatusCodes.Status404NotFound, _PlaylistNotFound) : await _ToDto(playlist);
}
/// The name of the queue playlist can't be used for other playlists.
private ObjectResult _CheckName(string name)
{
if (name.Length == 0)
{
return Problem(StatusCodes.Status400BadRequest, "Please enter a name.");
}
return string.Equals(name, _Settings.Current.QueuePlaylistName, StringComparison.OrdinalIgnoreCase)
? Problem(StatusCodes.Status409Conflict, "This name is reserved for the queue.")
: null;
}
private async Task<RemotePlaylist> _Find(int id)
{
var queueName = _Settings.Current.QueuePlaylistName;
return (await _Dispatcher.InvokeAsync(() => _Remote.GetPlaylists())).FirstOrDefault(p => p.Id == id && p.Name != queueName);
}
private async Task<ActionResult<PlaylistDto>> _ToDto(RemotePlaylist playlist)
{
var songs = new List<PlaylistEntryDto>(playlist.Entries.Count);
for (var i = 0; i < playlist.Entries.Count; i++)
{
songs.Add(new PlaylistEntryDto { Position = i, Song = await _Songs.GetDtoAsync(playlist.Entries[i].SongId) });
}
return new PlaylistDto { Id = playlist.Id, Name = playlist.Name, Songs = songs };
}
}
}
+222
View File
@@ -0,0 +1,222 @@
#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.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Remote;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
[Route("api/v1/profiles")]
public sealed class ProfilesController : ApiControllerBase
{
private const int _MaxAvatarUpload = 15 * 1024 * 1024;
private readonly IVocaluxeRemote _Remote;
private readonly CMainThreadDispatcher _Dispatcher;
private readonly CSessionStore _Sessions;
private readonly CWebSettingsStore _Settings;
private readonly CImageService _Images;
public ProfilesController(IVocaluxeRemote remote, CMainThreadDispatcher dispatcher, CSessionStore sessions, CWebSettingsStore settings, CImageService images)
{
_Remote = remote;
_Dispatcher = dispatcher;
_Sessions = sessions;
_Settings = settings;
_Images = images;
}
/// All active profiles, for the "Who are you?" page. No login needed.
[HttpGet]
public async Task<ActionResult<IList<ProfileDto>>> GetAll()
{
var profiles = await _Dispatcher.InvokeAsync(() => _Remote.GetProfiles());
return profiles.Where(p => p.IsActive).Select(ToDto).ToList();
}
/// The avatar as JPEG. No login needed.
[HttpGet("{id}/avatar")]
[Produces("image/jpeg")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
public async Task<IActionResult> GetAvatar(Guid id, [FromQuery] int size = 200)
{
var profile = await _Find(id);
var file = _Images.GetScaled(profile?.AvatarPath, size, out var etag);
if (file == null)
{
return NotFound();
}
return CImageResult.Create(this, file, etag);
}
/// Creates a profile. Allowed without login if profile creation is enabled in the settings.
[HttpPost]
[ProducesResponseType(typeof(ProfileDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ProfileDto>> Create([FromBody] ProfileCreateDto request)
{
var settings = _Settings.Current;
var session = _Sessions.Get(HttpContext);
var allowed = session != null
? settings.GetPermissions(session.Role).Contains(EPermission.CreateProfiles)
: settings.AllowProfileCreation;
if (!allowed)
{
return Problem(StatusCodes.Status403Forbidden, "Creating profiles is not allowed.");
}
var change = new ProfileChange { Name = request.Name.Trim(), Difficulty = request.Difficulty ?? 0, Role = ERemoteRole.Player };
if (!string.IsNullOrEmpty(request.Pin))
{
CPinHasher.Hash(request.Pin, out change.PinHash, out change.PinSalt);
}
var id = await _Dispatcher.InvokeAsync(() => _Remote.CreateProfile(change));
return ToDto(await _Find(id));
}
/// Changes name, difficulty or PIN.
[HttpPut("{id}")]
[RequireSession]
public async Task<ActionResult<ProfileDto>> Update(Guid id, [FromBody] ProfileUpdateDto request)
{
if (!_CanEdit(id))
{
return Problem(StatusCodes.Status403Forbidden, "You are not allowed to edit this profile.");
}
var change = new ProfileChange { Name = request.Name?.Trim(), Difficulty = request.Difficulty };
if (request.Pin != null)
{
if (request.Pin.Length == 0)
{
change.ClearPin = true;
}
else if (request.Pin.Length < 4)
{
return Problem(StatusCodes.Status400BadRequest, "The PIN needs at least 4 characters.");
}
else
{
CPinHasher.Hash(request.Pin, out change.PinHash, out change.PinSalt);
}
}
return await _Apply(id, change);
}
/// Sets a new avatar. The body is the picture (JPEG or PNG), it is cropped to a square.
[HttpPut("{id}/avatar")]
[RequireSession]
[Consumes("image/jpeg", "image/png", "image/webp", "application/octet-stream")]
public async Task<ActionResult<ProfileDto>> UpdateAvatar(Guid id)
{
if (!_CanEdit(id))
{
return Problem(StatusCodes.Status403Forbidden, "You are not allowed to edit this profile.");
}
byte[] jpeg;
try
{
using (var buffer = new MemoryStream())
{
await Request.Body.CopyToAsync(buffer);
if (buffer.Length == 0 || buffer.Length > _MaxAvatarUpload)
{
return Problem(StatusCodes.Status400BadRequest, "Please send a picture up to 15 MB.");
}
jpeg = CImageService.CreateAvatar(buffer.ToArray());
}
}
catch (ArgumentException)
{
return Problem(StatusCodes.Status400BadRequest, "This picture format is not supported.");
}
return await _Apply(id, new ProfileChange { AvatarJpeg = jpeg });
}
/// Uses one of the avatars of the game, see GET /api/v1/avatars.
[HttpPut("{id}/game-avatar")]
[RequireSession]
public async Task<ActionResult<ProfileDto>> SelectAvatar(Guid id, [FromBody] AvatarSelectDto request)
{
if (!_CanEdit(id))
{
return Problem(StatusCodes.Status403Forbidden, "You are not allowed to edit this profile.");
}
var avatars = await _Dispatcher.InvokeAsync(() => _Remote.GetAvatars());
if (avatars.All(a => a.Id != request.AvatarId.Value))
{
return Problem(StatusCodes.Status404NotFound, "Avatar not found.");
}
return await _Apply(id, new ProfileChange { AvatarId = request.AvatarId });
}
/// Changes the role of a profile (admins only). Logs out the devices of that profile.
[HttpPut("{id}/role")]
[RequireAdmin]
public async Task<ActionResult<ProfileDto>> UpdateRole(Guid id, [FromBody] RoleUpdateDto request)
{
if (id == CurrentSession.ProfileId && request.Role.Value != ERemoteRole.Admin)
{
return Problem(StatusCodes.Status400BadRequest, "You cannot remove your own admin role.");
}
var result = await _Apply(id, new ProfileChange { Role = request.Role });
_Sessions.RemoveProfile(id);
return result;
}
private bool _CanEdit(Guid id)
{
return HasPermission(EPermission.EditAllProfiles) || (id == CurrentSession.ProfileId && HasPermission(EPermission.EditOwnProfile));
}
private async Task<ActionResult<ProfileDto>> _Apply(Guid id, ProfileChange change)
{
if (await _Find(id) == null)
{
return Problem(StatusCodes.Status404NotFound, "Profile not found.");
}
await _Dispatcher.InvokeAsync(() => _Remote.UpdateProfile(id, change));
var profile = await _Find(id);
_Sessions.UpdateProfileName(id, profile.Name);
return ToDto(profile);
}
private async Task<RemoteProfile> _Find(Guid id)
{
return (await _Dispatcher.InvokeAsync(() => _Remote.GetProfiles())).FirstOrDefault(p => p.Id == id);
}
}
}
+239
View File
@@ -0,0 +1,239 @@
#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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Remote;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
/// Song wishes. Everybody gets one song per round. Entries can be changed and removed by their requester
/// or by people allowed to manage the queue; only admins can clear the whole queue.
[Route("api/v1/queue")]
[RequireSession]
public sealed class QueueController : ApiControllerBase
{
public const int MaxPlayers = 6;
private readonly CQueueService _Queue;
private readonly CSongLibrary _Songs;
private readonly CLiveEvents _Events;
private readonly IVocaluxeRemote _Remote;
private readonly CMainThreadDispatcher _Dispatcher;
public QueueController(CQueueService queue, CSongLibrary songs, CLiveEvents events, IVocaluxeRemote remote, CMainThreadDispatcher dispatcher)
{
_Queue = queue;
_Songs = songs;
_Events = events;
_Remote = remote;
_Dispatcher = dispatcher;
}
[HttpGet]
public async Task<ActionResult<IList<QueueEntryDto>>> Get()
{
return Ok(await _Queue.GetAsync());
}
/// Wishes a song. Without game mode the first mode the song supports is used.
[HttpPost]
[RequireSession(EPermission.AddToQueue)]
public async Task<ActionResult<IList<QueueEntryDto>>> Add([FromBody] QueueAddDto request)
{
var song = await _Songs.GetAsync(request.SongId.Value);
if (song == null)
{
return Problem(StatusCodes.Status404NotFound, "Song not found.");
}
var dto = CSongLibrary.ToDto(song);
var gameMode = request.GameMode ?? dto.GameModes.DefaultIfEmpty(ESongGameMode.Normal).First();
var audioMode = request.AudioMode ?? ESongAudioMode.Normal;
var error = _Validate(dto, gameMode, audioMode);
var players = await _ResolvePlayers(request.PlayerIds);
if (error != null || players == null)
{
return Problem(StatusCodes.Status400BadRequest, error ?? "Unknown players.");
}
var result = await _Queue.AddAsync(dto.Id, gameMode, audioMode, players, CurrentSession);
_Events.Publish("notification", new NotificationDto
{
Type = "queueAdded",
Profile = new ProfileRefDto { Id = CurrentSession.ProfileId, Name = CurrentSession.ProfileName },
Song = dto
});
return Ok(result);
}
/// Adds the songs of a playlist (all or the given positions) to the queue, keeping their game and audio mode.
[HttpPost("import")]
[RequireSession(EPermission.AddToQueue)]
public async Task<ActionResult<IList<QueueEntryDto>>> Import([FromBody] QueueImportDto request)
{
var playlist = (await _Dispatcher.InvokeAsync(() => _Remote.GetPlaylists())).FirstOrDefault(p => p.Id == request.PlaylistId.Value);
if (playlist == null)
{
return Problem(StatusCodes.Status404NotFound, "Playlist not found.");
}
var entries = request.Positions == null
? playlist.Entries.ToList()
: request.Positions.Where(p => p >= 0 && p < playlist.Entries.Count).Distinct().Select(p => playlist.Entries[p]).ToList();
if (entries.Count == 0)
{
return Problem(StatusCodes.Status400BadRequest, "No songs to add.");
}
var result = await _Queue.AddManyAsync(entries, CurrentSession);
var first = await _Songs.GetDtoAsync(entries[0].SongId);
_Events.Publish("notification", new NotificationDto
{
Type = entries.Count == 1 ? "queueAdded" : "queueAddedMany",
Profile = new ProfileRefDto { Id = CurrentSession.ProfileId, Name = CurrentSession.ProfileName },
Song = first,
Count = entries.Count
});
return Ok(result);
}
/// Starts singing this entry in the game right away, with its singers. Fails with 409 while the game is busy (e.g. singing).
[HttpPost("{id}/start")]
[RequireSession(EPermission.StartSongs)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<ActionResult<IList<QueueEntryDto>>> Start(string id)
{
var entry = (await _Queue.GetAsync()).FirstOrDefault(e => e.Id == id);
if (entry == null)
{
return Problem(StatusCodes.Status404NotFound, "Queue entry not found.");
}
var started = await _Dispatcher.InvokeAsync(() =>
{
var index = _Queue.IndexOf(id);
if (index < 0)
{
return false;
}
var result = _Remote.StartSong(new RemoteStartRequest
{
SongId = entry.Song.Id,
GameMode = (int)entry.GameMode,
AudioMode = (int)entry.AudioMode,
PlayerIds = entry.Players.Select(p => p.Id).ToList()
});
//With player selection the entry stays until singing really starts (the selection can be cancelled)
if (result == ERemoteStartResult.Singing)
{
_Queue.MarkStarted(index);
}
return result != ERemoteStartResult.Busy;
});
if (!started)
{
return Problem(StatusCodes.Status409Conflict, "Vocaluxe cannot start a song right now.");
}
return Ok(await _Queue.GetAsync());
}
/// Changes game mode, audio mode or players of a wish.
[HttpPut("{id}")]
public async Task<ActionResult<IList<QueueEntryDto>>> Update(string id, [FromBody] QueueUpdateDto request)
{
var entry = (await _Queue.GetAsync()).FirstOrDefault(e => e.Id == id);
if (entry == null)
{
return Problem(StatusCodes.Status404NotFound, "Queue entry not found.");
}
var error = _Validate(entry.Song, request.GameMode ?? entry.GameMode, request.AudioMode ?? entry.AudioMode);
var players = request.PlayerIds == null ? null : await _ResolvePlayers(request.PlayerIds);
if (error != null || (request.PlayerIds != null && players == null))
{
return Problem(StatusCodes.Status400BadRequest, error ?? "Unknown players.");
}
return Ok(await _Queue.UpdateAsync(id, request.GameMode, request.AudioMode, players, CurrentSession, HasPermission(EPermission.ManageQueue)));
}
[HttpDelete("{id}")]
public async Task<ActionResult<IList<QueueEntryDto>>> Remove(string id)
{
return Ok(await _Queue.RemoveAsync(id, CurrentSession, HasPermission(EPermission.ManageQueue)));
}
[HttpPut("{id}/position")]
[RequireSession(EPermission.ManageQueue)]
public async Task<ActionResult<IList<QueueEntryDto>>> Move(string id, [FromBody] PositionDto request)
{
return Ok(await _Queue.MoveAsync(id, request.Position.Value));
}
/// Removes all wishes (admins only).
[HttpDelete]
[RequireAdmin]
public async Task<ActionResult<IList<QueueEntryDto>>> Clear()
{
return Ok(await _Queue.ClearAsync());
}
private static string _Validate(SongDto song, ESongGameMode gameMode, ESongAudioMode audioMode)
{
if (song.GameModes.Count > 0 && !song.GameModes.Contains(gameMode))
{
return "The song does not support this game mode.";
}
if (audioMode != ESongAudioMode.Normal && !song.AudioModes.Contains(audioMode))
{
return "The song does not support this audio mode.";
}
return null;
}
/// The profiles with the given ids, or null if one is unknown or there are too many.
private async Task<List<ProfileRefDto>> _ResolvePlayers(IList<Guid> ids)
{
if (ids == null || ids.Count == 0)
{
return new List<ProfileRefDto>();
}
if (ids.Count > MaxPlayers)
{
return null;
}
var profiles = await _Dispatcher.InvokeAsync(() => _Remote.GetProfiles());
var result = ids.Distinct().Select(id => profiles.FirstOrDefault(p => p.Id == id)).ToList();
return result.Contains(null) ? null : result.Select(p => new ProfileRefDto { Id = p.Id, Name = p.Name }).ToList();
}
}
}
+64
View File
@@ -0,0 +1,64 @@
#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.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
[Route("api/v1")]
public sealed class ServerController : ApiControllerBase
{
private readonly IVocaluxeRemote _Remote;
private readonly CMainThreadDispatcher _Dispatcher;
private readonly CWebSettingsStore _Settings;
private readonly CSongLibrary _Songs;
public ServerController(IVocaluxeRemote remote, CMainThreadDispatcher dispatcher, CWebSettingsStore settings, CSongLibrary songs)
{
_Remote = remote;
_Dispatcher = dispatcher;
_Settings = settings;
_Songs = songs;
}
/// Name and version of the server and how logging in works.
[HttpGet("server")]
public ActionResult<ServerInfoDto> GetServerInfo()
{
var settings = _Settings.Current;
return new ServerInfoDto
{
Name = "Vocaluxe",
Version = _Remote.Version,
AllowProfileCreation = settings.AllowProfileCreation,
QueuePlaylistName = settings.QueuePlaylistName
};
}
/// Current state of the game.
[HttpGet("state")]
[RequireSession]
public async Task<ActionResult<StateDto>> GetState()
{
return await _Songs.CreateStateAsync(await _Dispatcher.InvokeAsync(() => _Remote.GetState()));
}
}
}
+115
View File
@@ -0,0 +1,115 @@
#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;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Remote;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
/// Logging in by picking a profile ("Who are you?"), with PIN if the profile has one.
[Route("api/v1/session")]
public sealed class SessionController : ApiControllerBase
{
private readonly IVocaluxeRemote _Remote;
private readonly CMainThreadDispatcher _Dispatcher;
private readonly CSessionStore _Sessions;
private readonly CWebSettingsStore _Settings;
public SessionController(IVocaluxeRemote remote, CMainThreadDispatcher dispatcher, CSessionStore sessions, CWebSettingsStore settings)
{
_Remote = remote;
_Dispatcher = dispatcher;
_Sessions = sessions;
_Settings = settings;
}
/// The current session.
[HttpGet]
[RequireSession]
public async Task<ActionResult<SessionDto>> Get()
{
var session = CurrentSession;
var profile = (await _Dispatcher.InvokeAsync(() => _Remote.GetProfiles())).FirstOrDefault(p => p.Id == session.ProfileId);
if (profile == null || !profile.IsActive)
{
_Sessions.Remove(HttpContext);
return Problem(StatusCodes.Status401Unauthorized, "Please log in.");
}
session.Role = profile.Role;
session.ProfileName = profile.Name;
return CreateDto(profile, _Settings.Current);
}
/// Logs in as the given profile.
[HttpPost]
[ProducesResponseType(typeof(SessionDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status429TooManyRequests)]
public async Task<ActionResult<SessionDto>> Create([FromBody] SessionCreateDto request)
{
if (_Sessions.IsLocked(request.ProfileId.Value))
{
return Problem(StatusCodes.Status429TooManyRequests, "Too many wrong PINs, please wait a moment.");
}
var profile = (await _Dispatcher.InvokeAsync(() => _Remote.GetProfiles())).FirstOrDefault(p => p.Id == request.ProfileId.Value);
if (profile == null || !profile.IsActive)
{
return Problem(StatusCodes.Status404NotFound, "Profile not found.");
}
if (profile.PinHash != null)
{
if (!CPinHasher.Verify(request.Pin ?? "", profile.PinHash, profile.PinSalt))
{
_Sessions.RegisterFailedLogin(profile.Id);
return Problem(StatusCodes.Status403Forbidden, "Wrong PIN.");
}
if (CPinHasher.NeedsUpgrade(profile.PinSalt))
{
CPinHasher.Hash(request.Pin, out var hash, out var salt);
await _Dispatcher.InvokeAsync(() => _Remote.UpdateProfile(profile.Id, new ProfileChange { PinHash = hash, PinSalt = salt }));
}
}
_Sessions.Create(HttpContext, profile.Id, profile.Name, profile.Role);
return CreateDto(profile, _Settings.Current);
}
/// Logs out this device.
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult Delete()
{
_Sessions.Remove(HttpContext);
return NoContent();
}
internal static SessionDto CreateDto(RemoteProfile profile, CWebSettings settings)
{
return new SessionDto { Profile = ToDto(profile), Permissions = settings.GetPermissions(profile.Role).ToList() };
}
}
}
+130
View File
@@ -0,0 +1,130 @@
#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.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Api
{
[Route("api/v1/songs")]
[RequireSession]
public sealed class SongsController : ApiControllerBase
{
private readonly CSongLibrary _Songs;
private readonly CImageService _Images;
public SongsController(CSongLibrary songs, CImageService images)
{
_Songs = songs;
_Images = images;
}
/// Searches the song library. All words of the query must appear in artist, title or edition.
[HttpGet]
public async Task<ActionResult<SongPageDto>> Search([FromQuery] SongSearchDto request)
{
return await _Songs.SearchAsync(new CSongQuery
{
Text = request.Query,
Language = request.Language,
Genre = request.Genre,
Decade = request.Decade,
Duet = request.Duet,
Sort = request.Sort,
Page = request.Page,
PageSize = request.PageSize
});
}
/// Available languages, genres and decades with the number of songs, for filters.
[HttpGet("facets")]
public async Task<ActionResult<SongFacetsDto>> GetFacets()
{
return await _Songs.GetFacetsAsync();
}
[HttpGet("{id}")]
public async Task<ActionResult<SongDto>> Get(int id)
{
var song = await _Songs.GetAsync(id);
if (song == null)
{
return Problem(StatusCodes.Status404NotFound, "Song not found.");
}
return CSongLibrary.ToDto(song);
}
/// The audio file of the song, for previews. Supports range requests, so the browser can jump to the preview start.
[HttpGet("{id}/audio")]
[Produces("audio/mpeg", "audio/ogg", "audio/mp4", "audio/wav", "audio/flac")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
public async Task<IActionResult> GetAudio(int id)
{
var song = await _Songs.GetAsync(id);
if (string.IsNullOrEmpty(song?.AudioPath) || !System.IO.File.Exists(song.AudioPath))
{
return NotFound();
}
Response.Headers["Cache-Control"] = "private, max-age=3600";
return PhysicalFile(song.AudioPath, _GetAudioType(song.AudioPath), true);
}
private static string _GetAudioType(string path)
{
switch (System.IO.Path.GetExtension(path).ToLowerInvariant())
{
case ".ogg":
case ".oga":
case ".opus":
return "audio/ogg";
case ".m4a":
case ".mp4":
case ".aac":
return "audio/mp4";
case ".wav":
return "audio/wav";
case ".flac":
return "audio/flac";
case ".webm":
return "audio/webm";
default:
return "audio/mpeg";
}
}
/// The cover as JPEG, scaled to fit the given size (96, 200 or 400).
[HttpGet("{id}/cover")]
[Produces("image/jpeg")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
public async Task<IActionResult> GetCover(int id, [FromQuery] int size = 200)
{
var song = await _Songs.GetAsync(id);
var file = _Images.GetScaled(song?.CoverPath, size, out var etag);
if (file == null)
{
return NotFound();
}
return CImageResult.Create(this, file, etag);
}
}
}
+345
View File
@@ -0,0 +1,345 @@
#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.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using VocaluxeWeb.Remote;
namespace VocaluxeWeb.Contracts
{
public enum EPermission
{
AddToQueue,
ManageQueue,
UseKeyboard,
EditPlaylists,
DeletePlaylists,
EditOwnProfile,
EditAllProfiles,
CreateProfiles,
StartSongs
}
#region profiles & session
public sealed class ProfileDto
{
[Required] public Guid Id { get; set; }
[Required] public string Name { get; set; }
[Required] public ERemoteRole Role { get; set; }
[Required] public bool HasPin { get; set; }
[Required] public bool HasAvatar { get; set; }
/// 0 = easy, 1 = normal, 2 = hard
[Required] public int Difficulty { get; set; }
/// Inactive profiles are hidden on the login page and in the game's player selection.
[Required] public bool IsActive { get; set; }
}
public sealed class ActiveDto
{
[Required] public bool? Active { get; set; }
}
public sealed class ProfileCreateDto
{
[Required, StringLength(32, MinimumLength = 1)]
public string Name { get; set; }
[StringLength(32, MinimumLength = 4)]
public string Pin { get; set; }
[Range(0, 2)]
public int? Difficulty { get; set; }
}
public sealed class ProfileUpdateDto
{
[StringLength(32, MinimumLength = 1)]
public string Name { get; set; }
[Range(0, 2)]
public int? Difficulty { get; set; }
/// New PIN. Use an empty string to remove the PIN.
[StringLength(32)]
public string Pin { get; set; }
}
public sealed class RoleUpdateDto
{
[Required] public ERemoteRole? Role { get; set; }
}
public sealed class AvatarDto
{
[Required] public int Id { get; set; }
}
public sealed class AvatarSelectDto
{
[Required] public int? AvatarId { get; set; }
}
public sealed class SessionCreateDto
{
[Required] public Guid? ProfileId { get; set; }
public string Pin { get; set; }
}
public sealed class SessionDto
{
[Required] public ProfileDto Profile { get; set; }
[Required] public IList<EPermission> Permissions { get; set; }
}
#endregion
#region songs
public sealed class SongDto
{
[Required] public int Id { get; set; }
[Required] public string Title { get; set; }
[Required] public string Artist { get; set; }
public string Year { get; set; }
public string Language { get; set; }
public string Genre { get; set; }
[Required] public bool IsDuet { get; set; }
[Required] public bool HasCover { get; set; }
[Required] public bool HasAudio { get; set; }
/// Start of the preview in seconds; when missing, use a quarter of the song length.
public float? PreviewStart { get; set; }
[Required] public IList<ESongGameMode> GameModes { get; set; }
/// Empty if the song has no instrumental track (then only the normal mode is possible).
[Required] public IList<ESongAudioMode> AudioModes { get; set; }
}
/// Same order as the game's EGameMode.
public enum ESongGameMode
{
Normal,
Medley,
Duet,
Short
}
/// Same order as the game's EAudioMode.
public enum ESongAudioMode
{
Normal,
Instrumental,
Vocals,
Karaoke
}
/// Search parameters for GET /api/v1/songs. All words of the query must appear in artist, title or edition.
public sealed class SongSearchDto
{
[FromQuery(Name = "query")] public string Query { get; set; }
[FromQuery(Name = "language")] public string Language { get; set; }
[FromQuery(Name = "genre")] public string Genre { get; set; }
[FromQuery(Name = "decade")] public int? Decade { get; set; }
[FromQuery(Name = "duet")] public bool? Duet { get; set; }
[FromQuery(Name = "sort")] public ESongSort Sort { get; set; } = ESongSort.Artist;
[FromQuery(Name = "page")] public int Page { get; set; }
[FromQuery(Name = "pageSize")] public int PageSize { get; set; } = 50;
}
public sealed class SongPageDto
{
[Required] public IList<SongDto> Items { get; set; }
[Required] public int Total { get; set; }
[Required] public int Page { get; set; }
[Required] public int PageSize { get; set; }
}
public sealed class FacetValueDto
{
[Required] public string Value { get; set; }
[Required] public int Count { get; set; }
}
public sealed class SongFacetsDto
{
[Required] public IList<FacetValueDto> Languages { get; set; }
[Required] public IList<FacetValueDto> Genres { get; set; }
[Required] public IList<FacetValueDto> Decades { get; set; }
}
public enum ESongSort
{
Artist,
Title,
Year
}
#endregion
#region playlists & queue
public sealed class PlaylistSummaryDto
{
[Required] public int Id { get; set; }
[Required] public string Name { get; set; }
[Required] public int SongCount { get; set; }
}
public sealed class PlaylistEntryDto
{
[Required] public int Position { get; set; }
[Required] public SongDto Song { get; set; }
}
public sealed class PlaylistDto
{
[Required] public int Id { get; set; }
[Required] public string Name { get; set; }
[Required] public IList<PlaylistEntryDto> Songs { get; set; }
}
public sealed class PlaylistNameDto
{
[Required, StringLength(64, MinimumLength = 1)]
public string Name { get; set; }
}
public sealed class SongRefDto
{
[Required] public int? SongId { get; set; }
/// Insert position; appended when missing.
public int? Position { get; set; }
}
public sealed class PositionDto
{
[Required, Range(0, int.MaxValue)]
public int? Position { get; set; }
}
public sealed class ProfileRefDto
{
[Required] public Guid Id { get; set; }
[Required] public string Name { get; set; }
}
public sealed class QueueEntryDto
{
/// Stable id of the entry while it is in the queue.
[Required] public string Id { get; set; }
[Required] public int Position { get; set; }
[Required] public SongDto Song { get; set; }
/// Who wished the song; null if unknown (e.g. added in the game).
public ProfileRefDto RequestedBy { get; set; }
[Required] public ESongGameMode GameMode { get; set; }
[Required] public ESongAudioMode AudioMode { get; set; }
/// Who should sing; empty if the players are chosen in the game.
[Required] public IList<ProfileRefDto> Players { get; set; }
}
public sealed class QueueAddDto
{
[Required] public int? SongId { get; set; }
public ESongGameMode? GameMode { get; set; }
public ESongAudioMode? AudioMode { get; set; }
public IList<Guid> PlayerIds { get; set; }
}
/// Adds songs of a playlist to the queue, all of them or only the given positions.
public sealed class QueueImportDto
{
[Required] public int? PlaylistId { get; set; }
public IList<int> Positions { get; set; }
}
/// Fields that are missing stay unchanged.
public sealed class QueueUpdateDto
{
public ESongGameMode? GameMode { get; set; }
public ESongAudioMode? AudioMode { get; set; }
public IList<Guid> PlayerIds { get; set; }
}
/// Sent as live event "notification".
public sealed class NotificationDto
{
/// "queueAdded" or "queueAddedMany" (several songs of a playlist; Song is the first one)
[Required] public string Type { get; set; }
[Required] public ProfileRefDto Profile { get; set; }
public SongDto Song { get; set; }
public int Count { get; set; } = 1;
}
#endregion
#region input & admin
public sealed class KeyDto
{
[Required] public ERemoteKey? Key { get; set; }
}
public sealed class TextDto
{
[Required, StringLength(200, MinimumLength = 1)]
public string Text { get; set; }
}
public sealed class AdminStatusDto
{
[Required] public string Version { get; set; }
/// Address of the WebApp in the network.
[Required] public string Address { get; set; }
[Required] public int Port { get; set; }
[Required] public bool Https { get; set; }
/// Number of WebApps that are connected right now.
[Required] public int ConnectedClients { get; set; }
}
public sealed class AdminSessionDto
{
[Required] public string Id { get; set; }
[Required] public ProfileRefDto Profile { get; set; }
[Required] public ERemoteRole Role { get; set; }
public string Device { get; set; }
public string RemoteAddress { get; set; }
[Required] public DateTime LastSeen { get; set; }
[Required] public bool IsCurrent { get; set; }
}
public sealed class AdminSettingsDto
{
/// New profiles can be created on the login page.
[Required] public bool? AllowProfileCreation { get; set; }
/// Name of the playlist used as queue.
[Required, StringLength(64, MinimumLength = 1)]
public string QueuePlaylistName { get; set; }
[Required] public IList<EPermission> GuestPermissions { get; set; }
[Required] public IList<EPermission> PlayerPermissions { get; set; }
}
#endregion
}
+40
View File
@@ -0,0 +1,40 @@
#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.ComponentModel.DataAnnotations;
namespace VocaluxeWeb.Contracts
{
public sealed class StateDto
{
/// Name of the screen that is currently shown, e.g. "Main", "Song", "Sing".
[Required] public string Screen { get; set; }
/// The song that is currently loaded, or null.
public SongDto CurrentSong { get; set; }
}
public sealed class ServerInfoDto
{
[Required] public string Name { get; set; }
[Required] public string Version { get; set; }
[Required] public bool AllowProfileCreation { get; set; }
/// Name of the playlist that is the queue in the game.
[Required] public string QueuePlaylistName { get; set; }
}
}
@@ -0,0 +1,83 @@
#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.Threading;
using System.Threading.Tasks;
namespace VocaluxeWeb.Dispatching
{
/// Runs work from request threads on the game's main thread.
/// Requests enqueue work, the game calls <see cref="ProcessPending" /> once per frame.
public sealed class CMainThreadDispatcher
{
private readonly ConcurrentQueue<Action> _Queue = new ConcurrentQueue<Action>();
private readonly TimeSpan _Timeout;
public CMainThreadDispatcher(TimeSpan timeout)
{
_Timeout = timeout;
}
public Task<T> InvokeAsync<T>(Func<T> func)
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var cts = new CancellationTokenSource(_Timeout);
var registration = cts.Token.Register(() => tcs.TrySetException(new TimeoutException("The game did not respond in time.")));
_Queue.Enqueue(() =>
{
registration.Dispose();
cts.Dispose();
if (tcs.Task.IsCompleted)
{
return; //Timed out, the caller is gone
}
try
{
tcs.TrySetResult(func());
}
catch (Exception e)
{
tcs.TrySetException(e);
}
});
return tcs.Task;
}
public Task InvokeAsync(Action action)
{
return InvokeAsync<object>(() =>
{
action();
return null;
});
}
/// Executes all queued work. Must be called from the main thread.
public void ProcessPending()
{
while (_Queue.TryDequeue(out var action))
{
action();
}
}
}
}
@@ -0,0 +1,96 @@
#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.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace VocaluxeWeb.Hosting
{
/// Self-signed certificate for the optional HTTPS mode. Stored as PFX file, no certificate store or admin rights needed.
/// A new one is created when the file is missing, expired or does not cover the current addresses.
static class CCertificateProvider
{
private const string _SubjectAlternativeNameOid = "2.5.29.17";
public static X509Certificate2 GetOrCreate(string path, string hostName, IList<IPAddress> addresses)
{
if (File.Exists(path))
{
try
{
var existing = new X509Certificate2(File.ReadAllBytes(path), (string)null, X509KeyStorageFlags.UserKeySet);
if (existing.NotAfter > DateTime.Now.AddDays(7) && _Covers(existing, hostName, addresses))
{
return existing;
}
}
catch (CryptographicException)
{
//Broken file, a new certificate is created below
}
}
var pfx = _Create(hostName, addresses);
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllBytes(path, pfx);
return new X509Certificate2(pfx, (string)null, X509KeyStorageFlags.UserKeySet);
}
private static bool _Covers(X509Certificate2 cert, string hostName, IEnumerable<IPAddress> addresses)
{
var san = cert.Extensions[_SubjectAlternativeNameOid]?.Format(false) ?? "";
return san.Contains(hostName) && addresses.All(a => san.Contains(a.ToString()));
}
private static byte[] _Create(string hostName, IEnumerable<IPAddress> addresses)
{
using (var rsa = RSA.Create(2048))
{
var request = new CertificateRequest("CN=Vocaluxe (" + hostName + ")", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var san = new SubjectAlternativeNameBuilder();
san.AddDnsName(hostName);
san.AddDnsName("localhost");
san.AddIpAddress(IPAddress.Loopback);
foreach (var address in addresses)
{
san.AddIpAddress(address);
}
request.CertificateExtensions.Add(san.Build());
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); //Server authentication
using (var cert = request.CreateSelfSigned(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddYears(2)))
{
return cert.Export(X509ContentType.Pfx);
}
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
#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.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace VocaluxeWeb.Hosting
{
static class CNetworkAddress
{
/// IPv4 addresses of this machine in the local network, best candidate first.
/// Interfaces with a default gateway (the "real" network) are preferred over virtual adapters.
public static IList<IPAddress> GetLanAddresses()
{
return (from nic in NetworkInterface.GetAllNetworkInterfaces()
where nic.OperationalStatus == OperationalStatus.Up
&& nic.NetworkInterfaceType != NetworkInterfaceType.Loopback
&& nic.NetworkInterfaceType != NetworkInterfaceType.Tunnel
let props = nic.GetIPProperties()
let hasGateway = props.GatewayAddresses.Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork && !g.Address.Equals(IPAddress.Any))
from address in props.UnicastAddresses
where address.Address.AddressFamily == AddressFamily.InterNetwork
orderby hasGateway descending
select address.Address).ToList();
}
}
}
+146
View File
@@ -0,0 +1,146 @@
#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.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.OpenApi.Models;
using Newtonsoft.Json.Converters;
using VocaluxeWeb.Api;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Hosting
{
static class CStartup
{
private const string _IndexFile = "index.html";
public static void ConfigureServices(IServiceCollection services, CWebHost host, IVocaluxeRemote remote, CMainThreadDispatcher dispatcher, CWebSettingsStore settings,
CWebServerOptions options)
{
services.AddSingleton(host);
services.AddSingleton(remote);
services.AddSingleton(dispatcher);
services.AddSingleton(settings);
services.AddSingleton<CSessionStore>();
services.AddSingleton<CSongLibrary>();
services.AddSingleton<CLiveEvents>();
services.AddSingleton<CQueueService>();
services.AddSingleton(provider => new CLiveMonitor(remote, provider.GetRequiredService<CLiveEvents>(), provider.GetRequiredService<CQueueService>(),
provider.GetRequiredService<CSongLibrary>(), options.OnError));
services.AddSingleton(new CImageService(options.CachePath));
services.AddResponseCompression(compression =>
{
compression.EnableForHttps = true;
compression.Providers.Add<GzipCompressionProvider>();
});
// The entry assembly is Vocaluxe.exe, so MVC would not find our controllers on its own
services.AddMvcCore(mvc => mvc.Filters.Add(new CExceptionFilter(options.OnError)))
.AddApplicationPart(typeof(CStartup).Assembly)
.AddApiExplorer()
.AddDataAnnotations()
.AddJsonFormatters(json => json.Converters.Add(new StringEnumConverter()))
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.AddSwaggerGen(swagger =>
{
swagger.SwaggerDoc("v1", new OpenApiInfo { Title = "Vocaluxe API", Version = "v1" });
foreach (var folder in new[] { AppDomain.CurrentDomain.BaseDirectory, Path.GetDirectoryName(typeof(CStartup).Assembly.Location) })
{
var xmlDoc = Path.Combine(folder, "VocaluxeWeb.xml");
if (File.Exists(xmlDoc))
{
swagger.IncludeXmlComments(xmlDoc);
break;
}
}
});
services.AddSwaggerGenNewtonsoftSupport();
}
public static void Configure(IApplicationBuilder app, CWebServerOptions options)
{
app.UseResponseCompression();
app.UseSwagger();
app.UseSwaggerUI(ui => ui.SwaggerEndpoint("/swagger/v1/swagger.json", "Vocaluxe API v1"));
app.UseMvc();
if (!string.IsNullOrEmpty(options.WebRoot) && Directory.Exists(options.WebRoot))
{
var files = new PhysicalFileProvider(Path.GetFullPath(options.WebRoot));
var contentTypes = new FileExtensionContentTypeProvider();
contentTypes.Mappings[".webmanifest"] = "application/manifest+json";
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = files,
ContentTypeProvider = contentTypes,
OnPrepareResponse = ctx => _SetCacheHeaders(ctx.Context.Response, ctx.File.Name)
});
app.Run(context => _ServeIndex(context, files));
}
else
{
app.Run(context => _NotFound(context));
}
}
/// Vite puts a hash into every asset name, so those can be cached forever. index.html must always be revalidated.
private static void _SetCacheHeaders(HttpResponse response, string fileName)
{
response.Headers["Cache-Control"] = fileName == _IndexFile ? "no-cache" : "public, max-age=31536000, immutable";
}
/// Single page app fallback: every unknown path outside the API gets index.html, the WebApp's router handles the rest.
private static Task _ServeIndex(HttpContext context, IFileProvider files)
{
var index = files.GetFileInfo(_IndexFile);
if (!index.Exists || _IsApiPath(context.Request.Path) || context.Request.Method != HttpMethods.Get)
{
return _NotFound(context);
}
context.Response.ContentType = "text/html; charset=utf-8";
_SetCacheHeaders(context.Response, _IndexFile);
return context.Response.SendFileAsync(index, context.RequestAborted);
}
private static bool _IsApiPath(PathString path)
{
return path.StartsWithSegments("/api") || path.StartsWithSegments("/swagger");
}
private static Task _NotFound(HttpContext context)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return Task.CompletedTask;
}
}
}
+177
View File
@@ -0,0 +1,177 @@
#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.Net;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Remote;
using VocaluxeWeb.Services;
namespace VocaluxeWeb.Hosting
{
/// The embedded webserver (Kestrel). Serves the WebApp and the REST API.
public sealed class CWebHost : IDisposable
{
private readonly IVocaluxeRemote _Remote;
private readonly CWebServerOptions _Options;
private IWebHost _Host;
private CLiveMonitor _Monitor;
private CQueueService _Queue;
private bool _QueuePlaylistReady;
public CMainThreadDispatcher Dispatcher { get; }
public CWebSettingsStore Settings { get; }
/// Address other devices in the network use to reach the server, e.g. http://192.168.0.10:3000/
public Uri Address { get; private set; }
public bool IsRunning => _Host != null;
public bool UseHttps => _Options.UseHttps;
public int Port => _Options.Port;
public CWebHost(IVocaluxeRemote remote, CWebServerOptions options)
{
_Remote = remote;
_Options = options;
Dispatcher = new CMainThreadDispatcher(options.MainThreadTimeout);
Settings = new CWebSettingsStore(options.SettingsPath);
}
public void Start()
{
if (_Host != null)
{
return;
}
var hostName = Dns.GetHostName();
var lanAddresses = CNetworkAddress.GetLanAddresses();
var certificate = _Options.UseHttps ? CCertificateProvider.GetOrCreate(_Options.CertificatePath, hostName, lanAddresses) : null;
var host = new WebHostBuilder()
.UseKestrel(kestrel => _ConfigureKestrel(kestrel, certificate))
.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
.ConfigureServices(services => CStartup.ConfigureServices(services, this, _Remote, Dispatcher, Settings, _Options))
.Configure(app => CStartup.Configure(app, _Options))
.Build();
try
{
host.StartAsync().GetAwaiter().GetResult();
}
catch
{
host.Dispose();
throw;
}
_Host = host;
_Monitor = (CLiveMonitor)host.Services.GetService(typeof(CLiveMonitor));
_Queue = (CQueueService)host.Services.GetService(typeof(CQueueService));
_QueuePlaylistReady = false;
var shownHost = lanAddresses.Count > 0 ? lanAddresses[0].ToString() : hostName;
Address = new UriBuilder(_Options.UseHttps ? "https" : "http", shownHost, _Options.Port).Uri;
}
/// Name of the playlist that is used as queue.
public string QueuePlaylistName => Settings.Current.QueuePlaylistName;
/// The queue with requesters and singers, for the queue screen of the game. Main thread only.
public IList<RemoteQueueItem> GetQueueForGame()
{
return _Queue?.GetGameSnapshot() ?? new List<RemoteQueueItem>();
}
/// The queue entry at this position was started in the game and leaves the queue. Main thread only.
public void MarkQueueEntryStarted(int index)
{
_Queue?.MarkStarted(index);
}
/// Runs pending requests and watches the game for live events. Must be called once per frame from the main thread.
public void Tick()
{
Dispatcher.ProcessPending();
if (_Queue == null || !_Remote.IsReady)
{
return;
}
try
{
//The playlists are loaded after the server started, so the queue playlist is created here
if (!_QueuePlaylistReady)
{
_QueuePlaylistReady = true;
_Queue.EnsurePlaylist();
}
_Monitor?.Tick();
}
catch (Exception e)
{
_Options.OnError?.Invoke(e);
}
}
public void Stop()
{
if (_Host == null)
{
return;
}
try
{
_Host.StopAsync(TimeSpan.FromSeconds(2)).GetAwaiter().GetResult();
}
finally
{
_Host.Dispose();
_Host = null;
_Monitor = null;
_Queue = null;
}
}
public void Dispose()
{
Stop();
}
private void _ConfigureKestrel(KestrelServerOptions kestrel, X509Certificate2 certificate)
{
kestrel.AddServerHeader = false;
kestrel.Limits.MaxRequestBodySize = 20 * 1024 * 1024; //Photos
kestrel.Listen(IPAddress.Any, _Options.Port, listen =>
{
if (certificate != null)
{
listen.UseHttps(certificate);
}
});
}
}
}
+47
View File
@@ -0,0 +1,47 @@
#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;
namespace VocaluxeWeb.Hosting
{
public sealed class CWebServerOptions
{
public int Port = 3000;
/// Serve HTTPS with a self-signed certificate instead of plain HTTP.
public bool UseHttps;
/// Folder with the built WebApp (index.html, assets).
public string WebRoot;
/// Where the self-signed certificate is stored (only used with HTTPS).
public string CertificatePath;
/// JSON file with the webserver settings (permissions, join token, ...).
public string SettingsPath;
/// Folder for scaled covers and avatars.
public string CachePath;
/// How long a request waits for the game's main thread.
public TimeSpan MainThreadTimeout = TimeSpan.FromSeconds(5);
/// Called for unhandled exceptions while processing a request.
public Action<Exception> OnError;
}
}
+92
View File
@@ -0,0 +1,92 @@
#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 VocaluxeWeb.Remote;
namespace VocaluxeWeb
{
/// The only view the webserver has on the game. Implemented by Vocaluxe.
/// Methods marked as main-thread-only must be called through <see cref="Dispatching.CMainThreadDispatcher" />.
public interface IVocaluxeRemote
{
/// Full version text of the running Vocaluxe. Thread-safe.
string Version { get; }
/// False while the game is still loading (songs, playlists). Main thread only.
bool IsReady { get; }
/// Current state of the game. Main thread only.
RemoteState GetState();
#region profiles
/// All profiles, sorted by name. Main thread only.
IList<RemoteProfile> GetProfiles();
/// Creates a profile and returns its id. Main thread only.
Guid CreateProfile(ProfileChange change);
/// Changes the given fields of a profile. Main thread only.
void UpdateProfile(Guid profileId, ProfileChange change);
/// Deletes a profile including its file. Main thread only.
void DeleteProfile(Guid profileId);
/// Avatars that come with Vocaluxe or were put into the profile folder (not the ones uploaded with the WebApp). Main thread only.
IList<RemoteAvatar> GetAvatars();
#endregion
#region songs
/// Changes whenever songs are added or (re)loaded. Main thread only.
int SongLibraryVersion { get; }
/// All songs. Main thread only.
IList<RemoteSong> GetSongs();
#endregion
#region playlists
/// All playlists with their songs. Main thread only.
IList<RemotePlaylist> GetPlaylists();
/// Creates an empty playlist and returns its id. Main thread only.
int CreatePlaylist(string name);
/// Main thread only.
void RenamePlaylist(int playlistId, string name);
/// Main thread only.
void DeletePlaylist(int playlistId);
/// Replaces all songs of a playlist and saves it. Main thread only.
void SetPlaylistEntries(int playlistId, IList<RemotePlaylistEntry> entries);
#endregion
#region game
/// Starts singing a song with the given players (skipping the player selection if possible). Main thread only.
ERemoteStartResult StartSong(RemoteStartRequest request);
#endregion
#region input
/// Sends a key press to the game as if it came from a keyboard. Thread-safe.
void SendKey(ERemoteKey key);
/// Types text into the game. Thread-safe.
void SendText(string text);
#endregion
}
}
+97
View File
@@ -0,0 +1,97 @@
# Vocaluxe WebApp and webserver
Vocaluxe has a built-in webserver. Guests open the WebApp on their phones (scan the QR code shown under
`Options → Server`), add songs to the queue, manage playlists and profiles and use the phone as a remote.
| Folder | What it is |
|---------------------|------------------------------------------------------------------------------------------------|
| `VocaluxeWeb` | The webserver: ASP.NET Core 2.3 (Kestrel) on .NET Framework 4.8.1, REST API, live events |
| `WebApp` | The WebApp: Vue 3, TypeScript and Vite |
| `Vocaluxe/Base/Web` | The game side: starts the server (`CWebServer`) and implements `IVocaluxeRemote` (`CVocaluxeRemote`) |
## Architecture
* `VocaluxeWeb` does not reference the game. Everything it needs from Vocaluxe goes through the interface
`IVocaluxeRemote`, which `CVocaluxeRemote` implements in the game.
* Requests run on Kestrel threads, but the game is not thread-safe. Calls into the game are queued with
`CMainThreadDispatcher` and executed once per frame on the main thread (`CWebHost.Tick`, called from
`CWebServer.ProcessTasks`). A call that is not executed within 5 seconds fails with `503`.
* The queue is a normal playlist in the game (default name `WebApp Queue`). `CQueueService` keeps who requested
a song and who sings it. Songs are inserted fairly: the n-th song of a person goes behind everybody's n-th song.
The queue can also be opened and started in the game (song menu → options → playlist group → WebApp queue).
* Live updates (queue, game state, notifications) are sent with Server-Sent Events (`GET /api/v1/events`).
* Logins are kept in memory (HttpOnly cookie `vlx_session`, logged out after 12 hours without use).
Profiles without PIN log in again automatically after a restart of Vocaluxe.
### Roles and permissions
Every profile has a role: `Guest`, `Player` or `Admin`. Admins may do everything; what guests and players may do is set
in the WebApp under `More → Settings` (stored in `WebServer.json`). Admins can also
* see and log out logged-in devices,
* deactivate and delete profiles,
* see the address, port and HTTPS state of the server (the port is set in `config.xml` under `Server/ServerPort`, default 3000;
HTTPS is switched in the game under `Options → Server`).
### Profile pictures
A profile picture is either an own photo (uploaded from the phone, saved as `WebApp_*.jpg` in the profile folder) or one of
the avatars that come with Vocaluxe or were put into the profile folder (`GET /api/v1/avatars`). Uploaded photos of other
people are not offered for selection.
### Files
Stored in the Vocaluxe data folder:
| File | Content |
|--------------------|---------------------------------------------------------------|
| `WebServer.json` | WebApp settings: permissions, queue playlist name |
| `WebServer.pfx` | Self-signed certificate for HTTPS (created on first use) |
| `WebCache` | Resized covers and avatars |
The built WebApp is copied to the `Website` folder next to `Vocaluxe.exe`.
## Building
[Node.js](https://nodejs.org) (LTS, 22.12 or newer) is required. Building the project `VocaluxeWeb` (or the
whole solution) runs `npm ci` (only when `package.json` or `package-lock.json` changed) and `npm run build` in `WebApp`.
If Node.js is missing, the build stops with a hint. Restart Visual Studio after installing Node.js.
## Developing the WebApp
Start Vocaluxe with the webserver switched on, then:
```
cd WebApp
npm run dev
```
Vite serves the WebApp with hot reload and forwards `/api` and `/swagger` to Vocaluxe at `http://localhost:3000`.
For another address use `VOCALUXE_URL=http://192.168.0.10:3000 npm run dev`.
## REST API
* All endpoints are under `/api/v1`. Errors are returned as problem details (`{ "title": "...", "status": 403 }`).
* Swagger UI: `http://<address>:3000/swagger`, OpenAPI document: `/swagger/v1/swagger.json`.
* The TypeScript types of the WebApp (`WebApp/src/api/schema.d.ts`) are generated from the OpenAPI document.
After changing the API, start Vocaluxe (or set `VOCALUXE_URL`) and run `npm run api:generate` in `WebApp`.
## HTTPS
HTTP is the default. With `Options → Server → Server encryption` switched on, Vocaluxe uses HTTPS with a self-signed
certificate for the computer's name and IP addresses. Browsers do not trust it and show a warning the first time
("Your connection is not private"); the connection is encrypted nevertheless. The WebApp explains this on its help page.
Some browser features (camera, installing the WebApp on the home screen) only work over HTTPS. With the
self-signed certificate they depend on the browser: some browsers allow them after accepting the warning, others don't.
## Translations
The texts of the WebApp are in `WebApp/src/locales/<language>.json` (flat key/value JSON, `en.json` is the source).
They are translated on [Transifex](https://www.transifex.com/projects/p/vocaluxe/) like the game
(see `transifex.yaml`, format `KEYVALUEJSON`). New languages are picked up automatically when a file is added.
Placeholders like `{name}` must stay unchanged.
## Tests
The NUnit tests in `Tests/VocaluxeWeb` cover PIN hashing, settings and permissions, the fair queue order and the
device names shown to admins. They run with the other tests of the `Tests` project.
+158
View File
@@ -0,0 +1,158 @@
#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;
// Data exchanged between the game and the webserver. Never sent to clients directly (contains file paths and PIN hashes).
namespace VocaluxeWeb.Remote
{
public enum ERemoteRole
{
Guest,
Player,
Admin
}
public enum ERemoteKey
{
Up,
Down,
Left,
Right,
Enter,
Escape,
Tab,
Backspace,
Space,
PageUp,
PageDown
}
public sealed class RemoteState
{
public string Screen;
public int? SongId;
}
public sealed class RemoteProfile
{
public Guid Id;
public string Name;
public ERemoteRole Role;
public int Difficulty;
public bool IsActive;
public string AvatarPath;
public byte[] PinHash;
public byte[] PinSalt;
}
/// Fields that are null stay unchanged.
public sealed class ProfileChange
{
public string Name;
public int? Difficulty;
public ERemoteRole? Role;
public bool? IsActive;
/// New PIN hash and salt. Set <see cref="ClearPin" /> to remove the PIN.
public byte[] PinHash;
public byte[] PinSalt;
public bool ClearPin;
/// New avatar as JPEG.
public byte[] AvatarJpeg;
/// Avatar of the game, see <see cref="IVocaluxeRemote.GetAvatars" />.
public int? AvatarId;
}
public sealed class RemoteAvatar
{
public int Id;
public string Path;
}
public sealed class RemoteSong
{
public int Id;
public string Title;
public string Artist;
public string Year;
public IList<string> Languages;
public IList<string> Genres;
public IList<string> Editions;
public bool IsDuet;
/// Values of the game's EGameMode / EAudioMode that can be used with this song.
public IList<int> GameModes;
public IList<int> AudioModes;
public string CoverPath;
public string AudioPath;
/// Start of the preview in seconds, null if the song does not define one (then a quarter of the song is used).
public float? PreviewStart;
public int NumPlayed;
public DateTime DateAdded;
}
public sealed class RemotePlaylistEntry
{
public int SongId;
public int GameMode;
public int AudioMode;
}
/// A queue entry as the game screen shows it: the playlist entry plus what only the webserver knows.
public sealed class RemoteQueueItem
{
public int SongId;
public int GameMode;
public int AudioMode;
public string RequestedBy;
public IList<Guid> PlayerIds;
public IList<string> PlayerNames;
}
public enum ERemoteStartResult
{
/// The game cannot start a song right now (singing, party mode, ...).
Busy,
/// Singing starts right away.
Singing,
/// The player selection is shown first (no singers chosen or microphones missing).
PlayerSelection
}
public sealed class RemoteStartRequest
{
public int SongId;
public int GameMode;
public int AudioMode;
public IList<Guid> PlayerIds;
}
public sealed class RemotePlaylist
{
public int Id;
public string Name;
public IList<RemotePlaylistEntry> Entries;
}
}
+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('/', '_');
}
}
}
+64
View File
@@ -0,0 +1,64 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net481</TargetFramework>
<LangVersion>9.0</LangVersion>
<RootNamespace>VocaluxeWeb</RootNamespace>
<AssemblyName>VocaluxeWeb</AssemblyName>
<Configurations>DebugWin;ReleaseWin;InstallerWin;DebugLinux;ReleaseLinux</Configurations>
<Platforms>AnyCPU</Platforms>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<WebAppDir>$(MSBuildThisFileDirectory)..\WebApp\</WebAppDir>
</PropertyGroup>
<PropertyGroup Condition="$(Configuration.StartsWith('Release')) Or $(Configuration.StartsWith('Installer'))">
<Optimize>true</Optimize>
<DebugType>pdbonly</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" />
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.3.13" />
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Https" Version="2.3.13" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.13" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.ApiExplorer" Version="2.3.13" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.DataAnnotations" Version="2.3.13" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Formatters.Json" Version="2.3.13" />
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.13" />
<PackageReference Include="Microsoft.AspNetCore.ResponseCompression" Version="2.3.13" />
<!-- ASP.NET Core 2.3 pulls Newtonsoft.Json 11.0.2 (known vulnerability); use the version Vocaluxe uses -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.9.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="6.9.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.9.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Drawing" />
</ItemGroup>
<!-- The WebApp (Vue/TypeScript) is built with Node.js as part of this project.
The result lands in WebApp\dist and is copied to Output\Website by Vocaluxe.csproj. -->
<ItemGroup>
<WebAppSource Include="$(WebAppDir)src\**\*;$(WebAppDir)public\**\*;$(WebAppDir)index.html;$(WebAppDir)package.json;$(WebAppDir)package-lock.json;$(WebAppDir)vite.config.ts;$(WebAppDir)tsconfig.json" />
</ItemGroup>
<Target Name="CheckNode" Condition="'$(DesignTimeBuild)' != 'true'">
<Exec Command="node --version" IgnoreExitCode="true" StandardOutputImportance="low" StandardErrorImportance="low">
<Output TaskParameter="ExitCode" PropertyName="NodeExitCode" />
</Exec>
<Error Condition="'$(NodeExitCode)' != '0'" Text="Node.js was not found. The WebApp needs Node.js (LTS) to build: install it from https://nodejs.org and restart Visual Studio." />
</Target>
<Target Name="InstallWebAppPackages" Condition="'$(DesignTimeBuild)' != 'true'" Inputs="$(WebAppDir)package.json;$(WebAppDir)package-lock.json" Outputs="$(WebAppDir)node_modules\.package-lock.json">
<Exec Command="npm ci --no-audit --no-fund" WorkingDirectory="$(WebAppDir)" Condition="Exists('$(WebAppDir)package-lock.json')" />
<Exec Command="npm install --no-audit --no-fund" WorkingDirectory="$(WebAppDir)" Condition="!Exists('$(WebAppDir)package-lock.json')" />
</Target>
<Target Name="BuildWebApp" BeforeTargets="BeforeBuild" Condition="'$(DesignTimeBuild)' != 'true'" DependsOnTargets="CheckNode;InstallWebAppPackages" Inputs="@(WebAppSource)" Outputs="$(WebAppDir)dist\index.html">
<Exec Command="npm run build" WorkingDirectory="$(WebAppDir)" />
</Target>
</Project>