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