diff options
| author | David Ullmer <daullmer@gmail.com> | 2020-06-20 20:45:16 +0200 |
|---|---|---|
| committer | David Ullmer <daullmer@gmail.com> | 2020-06-20 20:45:16 +0200 |
| commit | d4c86b82e03352f7bab92f5fe05686891af1cd3e (patch) | |
| tree | 01217f621de90c0d95ae5b5dddfec0e32308ba2c | |
| parent | a5bd7f2d6ee0fef67c34f61db9be36167c30d890 (diff) | |
| parent | 804764e1fbf71e083479fc4069f90bab96faa84f (diff) | |
Merge remote-tracking branch 'remotes/upstream/api-migration' into api-sessionservice
# Conflicts:
# Jellyfin.Api/Helpers/RequestHelpers.cs
23 files changed, 976 insertions, 756 deletions
diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs index b5b9d8904..953acac80 100644 --- a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Net; +using System.Net; using System.Security.Claims; using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 5e5e25e84..ea02e6a0b 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -1,5 +1,3 @@ -#nullable enable - using System.Globalization; using System.Security.Authentication; using System.Security.Claims; diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index 4ae7cf506..ec50fb022 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -1,6 +1,5 @@ -#pragma warning disable CA1801 - using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Data.Entities; @@ -41,6 +40,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns> [HttpGet("Entries")] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "hasUserId", Justification = "Imported from ServiceStack")] public ActionResult<QueryResult<ActivityLogEntry>> GetLogEntries( [FromQuery] int? startIndex, [FromQuery] int? limit, diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs new file mode 100644 index 000000000..697a0baf4 --- /dev/null +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -0,0 +1,81 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Display Preferences Controller. + /// </summary> + [Authorize] + public class DisplayPreferencesController : BaseJellyfinApiController + { + private readonly IDisplayPreferencesRepository _displayPreferencesRepository; + + /// <summary> + /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class. + /// </summary> + /// <param name="displayPreferencesRepository">Instance of <see cref="IDisplayPreferencesRepository"/> interface.</param> + public DisplayPreferencesController(IDisplayPreferencesRepository displayPreferencesRepository) + { + _displayPreferencesRepository = displayPreferencesRepository; + } + + /// <summary> + /// Get Display Preferences. + /// </summary> + /// <param name="displayPreferencesId">Display preferences id.</param> + /// <param name="userId">User id.</param> + /// <param name="client">Client.</param> + /// <response code="200">Display preferences retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns> + [HttpGet("{DisplayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<DisplayPreferences> GetDisplayPreferences( + [FromRoute] string displayPreferencesId, + [FromQuery] [Required] string userId, + [FromQuery] [Required] string client) + { + return _displayPreferencesRepository.GetDisplayPreferences(displayPreferencesId, userId, client); + } + + /// <summary> + /// Update Display Preferences. + /// </summary> + /// <param name="displayPreferencesId">Display preferences id.</param> + /// <param name="userId">User Id.</param> + /// <param name="client">Client.</param> + /// <param name="displayPreferences">New Display Preferences object.</param> + /// <response code="200">Display preferences updated.</response> + /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns> + [HttpPost("{DisplayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ModelStateDictionary), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateDisplayPreferences( + [FromRoute] string displayPreferencesId, + [FromQuery, BindRequired] string userId, + [FromQuery, BindRequired] string client, + [FromBody, BindRequired] DisplayPreferences displayPreferences) + { + if (displayPreferencesId == null) + { + // TODO - refactor so parameter doesn't exist or is actually used. + } + + _displayPreferencesRepository.SaveDisplayPreferences( + displayPreferences, + userId, + client, + CancellationToken.None); + + return Ok(); + } + } +} diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 6a6e6a64a..dc5b0d906 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -1,6 +1,5 @@ -#pragma warning disable CA1801 - -using System; +using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -137,6 +136,7 @@ namespace Jellyfin.Api.Controllers /// <returns>Query filters.</returns> [HttpGet("/Items/Filters2")] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "mediaTypes", Justification = "Imported from ServiceStack")] public ActionResult<QueryFilters> GetQueryFilters( [FromQuery] Guid? userId, [FromQuery] string? parentId, diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index a1df22e41..6a16a89c5 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -1,6 +1,5 @@ -#pragma warning disable CA1801 - using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; @@ -54,6 +53,7 @@ namespace Jellyfin.Api.Controllers [Description("Refreshes metadata for an item.")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "recursive", Justification = "Imported from ServiceStack")] public ActionResult Post( [FromRoute] string id, [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None, diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index ca2905b11..62c547409 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -1,7 +1,6 @@ -#pragma warning disable CA1801 - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -56,6 +55,7 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders([FromQuery] string userId) { return _libraryManager.GetVirtualFolders(true); diff --git a/Jellyfin.Api/Controllers/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs index a1f9b9e8f..01dd23c77 100644 --- a/Jellyfin.Api/Controllers/NotificationsController.cs +++ b/Jellyfin.Api/Controllers/NotificationsController.cs @@ -1,7 +1,6 @@ -#pragma warning disable CA1801 - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using Jellyfin.Api.Models.NotificationDtos; @@ -45,6 +44,10 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="OkResult"/> containing a list of notifications.</returns> [HttpGet("{UserID}")] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isRead", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")] public ActionResult<NotificationResultDto> GetNotifications( [FromRoute] string userId, [FromQuery] bool? isRead, @@ -62,6 +65,7 @@ namespace Jellyfin.Api.Controllers /// <returns>An <cref see="OkResult"/> containing a summary of the users notifications.</returns> [HttpGet("{UserID}/Summary")] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] public ActionResult<NotificationsSummaryDto> GetNotificationsSummary( [FromRoute] string userId) { @@ -136,6 +140,8 @@ namespace Jellyfin.Api.Controllers /// <returns>A <cref see="NoContentResult"/>.</returns> [HttpPost("{UserID}/Read")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "ids", Justification = "Imported from ServiceStack")] public ActionResult SetRead( [FromRoute] string userId, [FromQuery] string ids) @@ -152,6 +158,8 @@ namespace Jellyfin.Api.Controllers /// <returns>A <cref see="NoContentResult"/>.</returns> [HttpPost("{UserID}/Unread")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "ids", Justification = "Imported from ServiceStack")] public ActionResult SetUnread( [FromRoute] string userId, [FromQuery] string ids) diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index fdb2f4c35..6075544cf 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -1,7 +1,6 @@ -#pragma warning disable CA1801 - -using System; +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -46,6 +45,7 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Installed plugins returned.</response> /// <returns>List of currently installed plugins.</returns> [HttpGet] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isAppStoreEnabled", Justification = "Imported from ServiceStack")] public ActionResult<IEnumerable<PluginInfo>> GetPlugins([FromRoute] bool? isAppStoreEnabled) { return Ok(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo())); diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index 69b83379d..74ec5f9b5 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -1,8 +1,7 @@ -#pragma warning disable CA1801 - using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -251,9 +250,9 @@ namespace Jellyfin.Api.Controllers [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] public async Task<ActionResult> GetSubtitlePlaylist( [FromRoute] Guid id, - // TODO: 'int index' is never used: CA1801 is disabled [FromRoute] int index, [FromRoute] string mediaSourceId, [FromQuery, Required] int segmentLength) diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs new file mode 100644 index 000000000..68ab5813c --- /dev/null +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -0,0 +1,552 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.UserDtos; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Users; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// User controller. + /// </summary> + [Route("/Users")] + public class UserController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly ISessionManager _sessionManager; + private readonly INetworkManager _networkManager; + private readonly IDeviceManager _deviceManager; + private readonly IAuthorizationContext _authContext; + private readonly IServerConfigurationManager _config; + + /// <summary> + /// Initializes a new instance of the <see cref="UserController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public UserController( + IUserManager userManager, + ISessionManager sessionManager, + INetworkManager networkManager, + IDeviceManager deviceManager, + IAuthorizationContext authContext, + IServerConfigurationManager config) + { + _userManager = userManager; + _sessionManager = sessionManager; + _networkManager = networkManager; + _deviceManager = deviceManager; + _authContext = authContext; + _config = config; + } + + /// <summary> + /// Gets a list of users. + /// </summary> + /// <param name="isHidden">Optional filter by IsHidden=true or false.</param> + /// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param> + /// <param name="isGuest">Optional filter by IsGuest=true or false.</param> + /// <response code="200">Users returned.</response> + /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns> + [HttpGet] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isGuest", Justification = "Imported from ServiceStack")] + public ActionResult<IEnumerable<UserDto>> GetUsers( + [FromQuery] bool? isHidden, + [FromQuery] bool? isDisabled, + [FromQuery] bool? isGuest) + { + var users = Get(isHidden, isDisabled, false, false); + return Ok(users); + } + + /// <summary> + /// Gets a list of publicly visible users for display on a login screen. + /// </summary> + /// <response code="200">Public users returned.</response> + /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns> + [HttpGet("Public")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<UserDto>> GetPublicUsers() + { + // If the startup wizard hasn't been completed then just return all users + if (!_config.Configuration.IsStartupWizardCompleted) + { + return Ok(Get(false, false, false, false)); + } + + return Ok(Get(false, false, true, true)); + } + + /// <summary> + /// Gets a user by Id. + /// </summary> + /// <param name="id">The user id.</param> + /// <response code="200">User returned.</response> + /// <response code="404">User not found.</response> + /// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns> + [HttpGet("{id}")] + [Authorize(Policy = Policies.IgnoreSchedule)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<UserDto> GetUserById([FromRoute] Guid id) + { + var user = _userManager.GetUserById(id); + + if (user == null) + { + return NotFound("User not found"); + } + + var result = _userManager.GetUserDto(user, HttpContext.Connection.RemoteIpAddress.ToString()); + return result; + } + + /// <summary> + /// Deletes a user. + /// </summary> + /// <param name="id">The user id.</param> + /// <response code="200">User deleted.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns> + [HttpDelete("{id}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteUser([FromRoute] Guid id) + { + var user = _userManager.GetUserById(id); + + if (user == null) + { + return NotFound("User not found"); + } + + _sessionManager.RevokeUserTokens(user.Id, null); + _userManager.DeleteUser(user); + return NoContent(); + } + + /// <summary> + /// Authenticates a user. + /// </summary> + /// <param name="id">The user id.</param> + /// <param name="pw">The password as plain text.</param> + /// <param name="password">The password sha1-hash.</param> + /// <response code="200">User authenticated.</response> + /// <response code="403">Sha1-hashed password only is not allowed.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns> + [HttpPost("{id}/Authenticate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<AuthenticationResult>> AuthenticateUser( + [FromRoute, Required] Guid id, + [FromQuery, BindRequired] string pw, + [FromQuery, BindRequired] string password) + { + var user = _userManager.GetUserById(id); + + if (user == null) + { + return NotFound("User not found"); + } + + if (!string.IsNullOrEmpty(password) && string.IsNullOrEmpty(pw)) + { + return Forbid("Only sha1 password is not allowed."); + } + + // Password should always be null + AuthenticateUserByName request = new AuthenticateUserByName + { + Username = user.Username, + Password = null, + Pw = pw + }; + return await AuthenticateUserByName(request).ConfigureAwait(false); + } + + /// <summary> + /// Authenticates a user by name. + /// </summary> + /// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param> + /// <response code="200">User authenticated.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> + [HttpPost("AuthenticateByName")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, BindRequired] AuthenticateUserByName request) + { + var auth = _authContext.GetAuthorizationInfo(Request); + + try + { + var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest + { + App = auth.Client, + AppVersion = auth.Version, + DeviceId = auth.DeviceId, + DeviceName = auth.Device, + Password = request.Pw, + PasswordSha1 = request.Password, + RemoteEndPoint = HttpContext.Connection.RemoteIpAddress.ToString(), + Username = request.Username + }).ConfigureAwait(false); + + return result; + } + catch (SecurityException e) + { + // rethrow adding IP address to message + throw new SecurityException($"[{HttpContext.Connection.RemoteIpAddress}] {e.Message}", e); + } + } + + /// <summary> + /// Updates a user's password. + /// </summary> + /// <param name="id">The user id.</param> + /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param> + /// <response code="200">Password successfully reset.</response> + /// <response code="403">User is not allowed to update the password.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> + [HttpPost("{id}/Password")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdateUserPassword( + [FromRoute] Guid id, + [FromBody] UpdateUserPassword request) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, id, true)) + { + return Forbid("User is not allowed to update the password."); + } + + var user = _userManager.GetUserById(id); + + if (user == null) + { + return NotFound("User not found"); + } + + if (request.ResetPassword) + { + await _userManager.ResetPassword(user).ConfigureAwait(false); + } + else + { + var success = await _userManager.AuthenticateUser( + user.Username, + request.CurrentPw, + request.CurrentPw, + HttpContext.Connection.RemoteIpAddress.ToString(), + false).ConfigureAwait(false); + + if (success == null) + { + return Forbid("Invalid user or password entered."); + } + + await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); + + var currentToken = _authContext.GetAuthorizationInfo(Request).Token; + + _sessionManager.RevokeUserTokens(user.Id, currentToken); + } + + return NoContent(); + } + + /// <summary> + /// Updates a user's easy password. + /// </summary> + /// <param name="id">The user id.</param> + /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param> + /// <response code="200">Password successfully reset.</response> + /// <response code="403">User is not allowed to update the password.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> + [HttpPost("{id}/EasyPassword")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateUserEasyPassword( + [FromRoute] Guid id, + [FromBody] UpdateUserEasyPassword request) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, id, true)) + { + return Forbid("User is not allowed to update the easy password."); + } + + var user = _userManager.GetUserById(id); + + if (user == null) + { + return NotFound("User not found"); + } + + if (request.ResetPassword) + { + _userManager.ResetEasyPassword(user); + } + else + { + _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword); + } + + return NoContent(); + } + + /// <summary> + /// Updates a user. + /// </summary> + /// <param name="id">The user id.</param> + /// <param name="updateUser">The updated user model.</param> + /// <response code="204">User updated.</response> + /// <response code="400">User information was not supplied.</response> + /// <response code="403">User update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns> + [HttpPost("{id}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> UpdateUser( + [FromRoute] Guid id, + [FromBody] UserDto updateUser) + { + if (updateUser == null) + { + return BadRequest(); + } + + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, id, false)) + { + return Forbid("User update not allowed."); + } + + var user = _userManager.GetUserById(id); + + if (string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) + { + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + _userManager.UpdateConfiguration(user.Id, updateUser.Configuration); + } + else + { + await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + _userManager.UpdateConfiguration(updateUser.Id, updateUser.Configuration); + } + + return NoContent(); + } + + /// <summary> + /// Updates a user policy. + /// </summary> + /// <param name="id">The user id.</param> + /// <param name="newPolicy">The new user policy.</param> + /// <response code="204">User policy updated.</response> + /// <response code="400">User policy was not supplied.</response> + /// <response code="403">User policy update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns> + [HttpPost("{id}/Policy")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult UpdateUserPolicy( + [FromRoute] Guid id, + [FromBody] UserPolicy newPolicy) + { + if (newPolicy == null) + { + return BadRequest(); + } + + var user = _userManager.GetUserById(id); + + // If removing admin access + if (!(newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))) + { + if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) + { + return Forbid("There must be at least one user in the system with administrative access."); + } + } + + // If disabling + if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) + { + return Forbid("Administrators cannot be disabled."); + } + + // If disabling + if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) + { + if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) + { + return Forbid("There must be at least one enabled user in the system."); + } + + var currentToken = _authContext.GetAuthorizationInfo(Request).Token; + _sessionManager.RevokeUserTokens(user.Id, currentToken); + } + + _userManager.UpdatePolicy(id, newPolicy); + + return NoContent(); + } + + /// <summary> + /// Updates a user configuration. + /// </summary> + /// <param name="id">The user id.</param> + /// <param name="userConfig">The new user configuration.</param> + /// <response code="204">User configuration updated.</response> + /// <response code="403">User configuration update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("{id}/Configuration")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult UpdateUserConfiguration( + [FromRoute] Guid id, + [FromBody] UserConfiguration userConfig) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, id, false)) + { + return Forbid("User configuration update not allowed"); + } + + _userManager.UpdateConfiguration(id, userConfig); + + return NoContent(); + } + + /// <summary> + /// Creates a user. + /// </summary> + /// <param name="request">The create user by name request body.</param> + /// <response code="200">User created.</response> + /// <returns>An <see cref="UserDto"/> of the new user.</returns> + [HttpPost("/Users/New")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<UserDto>> CreateUserByName([FromBody] CreateUserByName request) + { + var newUser = _userManager.CreateUser(request.Name); + + // no need to authenticate password for new user + if (request.Password != null) + { + await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); + } + + var result = _userManager.GetUserDto(newUser, HttpContext.Connection.RemoteIpAddress.ToString()); + + return result; + } + + /// <summary> + /// Initiates the forgot password process for a local user. + /// </summary> + /// <param name="enteredUsername">The entered username.</param> + /// <response code="200">Password reset process started.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns> + [HttpPost("ForgotPassword")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody] string enteredUsername) + { + var isLocal = HttpContext.Connection.RemoteIpAddress.Equals(HttpContext.Connection.LocalIpAddress) + || _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString()); + + var result = await _userManager.StartForgotPasswordProcess(enteredUsername, isLocal).ConfigureAwait(false); + + return result; + } + + /// <summary> + /// Redeems a forgot password pin. + /// </summary> + /// <param name="pin">The pin.</param> + /// <response code="200">Pin reset process started.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> + [HttpPost("ForgotPassword/Pin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody] string pin) + { + var result = await _userManager.RedeemPasswordResetPin(pin).ConfigureAwait(false); + return result; + } + + private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + { + var users = _userManager.Users; + + if (isDisabled.HasValue) + { + users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value); + } + + if (isHidden.HasValue) + { + users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value); + } + + if (filterByDevice) + { + var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId; + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); + } + } + + if (filterByNetwork) + { + if (!_networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString())) + { + users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); + } + } + + var result = users + .OrderBy(u => u.Username) + .Select(i => _userManager.GetUserDto(i, HttpContext.Connection.RemoteIpAddress.ToString())); + + return result; + } + } +} diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs new file mode 100644 index 000000000..4c587391f --- /dev/null +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -0,0 +1,162 @@ +using System; +using System.Linq; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Extensions +{ + /// <summary> + /// Dto Extensions. + /// </summary> + public static class DtoExtensions + { + /// <summary> + /// Add Dto Item fields. + /// </summary> + /// <remarks> + /// Converted from IHasItemFields. + /// Legacy order: 1. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="fields">Comma delimited string of fields.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string fields) + { + if (string.IsNullOrEmpty(fields)) + { + dtoOptions.Fields = Array.Empty<ItemFields>(); + } + else + { + dtoOptions.Fields = fields.Split(',') + .Select(v => + { + if (Enum.TryParse(v, true, out ItemFields value)) + { + return (ItemFields?)value; + } + + return null; + }) + .Where(i => i.HasValue) + .Select(i => i!.Value) + .ToArray(); + } + + return dtoOptions; + } + + /// <summary> + /// Add additional fields depending on client. + /// </summary> + /// <remarks> + /// Use in place of GetDtoOptions. + /// Legacy order: 2. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="request">Current request.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddClientFields( + this DtoOptions dtoOptions, HttpRequest request) + { + dtoOptions.Fields ??= Array.Empty<ItemFields>(); + + string? client = ClaimHelpers.GetClient(request.HttpContext.User); + + // No client in claim + if (string.IsNullOrEmpty(client)) + { + return dtoOptions; + } + + if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount)) + { + if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1) + { + int oldLen = dtoOptions.Fields.Length; + var arr = new ItemFields[oldLen + 1]; + dtoOptions.Fields.CopyTo(arr, 0); + arr[oldLen] = ItemFields.RecursiveItemCount; + dtoOptions.Fields = arr; + } + } + + if (!dtoOptions.ContainsField(ItemFields.ChildCount)) + { + if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1) + { + int oldLen = dtoOptions.Fields.Length; + var arr = new ItemFields[oldLen + 1]; + dtoOptions.Fields.CopyTo(arr, 0); + arr[oldLen] = ItemFields.ChildCount; + dtoOptions.Fields = arr; + } + } + + return dtoOptions; + } + + /// <summary> + /// Add additional DtoOptions. + /// </summary> + /// <remarks> + /// Converted from IHasDtoOptions. + /// Legacy order: 3. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="enableImages">Enable images.</param> + /// <param name="enableUserData">Enable user data.</param> + /// <param name="imageTypeLimit">Image type limit.</param> + /// <param name="enableImageTypes">Enable image types.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddAdditionalDtoOptions( + in DtoOptions dtoOptions, + bool? enableImages, + bool? enableUserData, + int? imageTypeLimit, + string enableImageTypes) + { + dtoOptions.EnableImages = enableImages ?? true; + + if (imageTypeLimit.HasValue) + { + dtoOptions.ImageTypeLimit = imageTypeLimit.Value; + } + + if (enableUserData.HasValue) + { + dtoOptions.EnableUserData = enableUserData.Value; + } + + if (!string.IsNullOrWhiteSpace(enableImageTypes)) + { + dtoOptions.ImageTypes = enableImageTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(v => (ImageType)Enum.Parse(typeof(ImageType), v, true)) + .ToArray(); + } + + return dtoOptions; + } + + /// <summary> + /// Check if DtoOptions contains field. + /// </summary> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="field">Field to check.</param> + /// <returns>Field existence.</returns> + internal static bool ContainsField(this DtoOptions dtoOptions, ItemFields field) + => dtoOptions.Fields != null && dtoOptions.Fields.Contains(field); + } +} diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs index a07d4ed82..df235ced2 100644 --- a/Jellyfin.Api/Helpers/ClaimHelpers.cs +++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System; +using System; using System.Linq; using System.Security.Claims; using Jellyfin.Api.Constants; diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 2aa700de3..2ff40a8a5 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -1,4 +1,5 @@ using System; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using Microsoft.AspNetCore.Http; @@ -29,6 +30,30 @@ namespace Jellyfin.Api.Helpers : value.Split(separator); } + /// <summary> + /// Checks if the user can update an entry. + /// </summary> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="requestContext">The <see cref="HttpRequest"/>.</param> + /// <param name="userId">The user id.</param> + /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param> + /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns> + internal static bool AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences) + { + var auth = authContext.GetAuthorizationInfo(requestContext); + + var authenticatedUser = auth.User; + + // If they're going to update the record of another user, they must be an administrator + if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator)) + || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess)) + { + return false; + } + + return true; + } + internal static SessionInfo GetSession(ISessionManager sessionManager, IAuthorizationContext authContext, HttpRequest request) { var authorization = authContext.GetAuthorizationInfo(request); diff --git a/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs new file mode 100644 index 000000000..393627435 --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The authenticate user by name request body. + /// </summary> + public class AuthenticateUserByName + { + /// <summary> + /// Gets or sets the username. + /// </summary> + public string? Username { get; set; } + + /// <summary> + /// Gets or sets the plain text password. + /// </summary> + public string? Pw { get; set; } + + /// <summary> + /// Gets or sets the sha1-hashed password. + /// </summary> + public string? Password { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs b/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs new file mode 100644 index 000000000..1c88d3628 --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The create user by name request body. + /// </summary> + public class CreateUserByName + { + /// <summary> + /// Gets or sets the username. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the password. + /// </summary> + public string? Password { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs new file mode 100644 index 000000000..0a173ea1a --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The update user easy password request body. + /// </summary> + public class UpdateUserEasyPassword + { + /// <summary> + /// Gets or sets the new sha1-hashed password. + /// </summary> + public string? NewPassword { get; set; } + + /// <summary> + /// Gets or sets the new password. + /// </summary> + public string? NewPw { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to reset the password. + /// </summary> + public bool ResetPassword { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs new file mode 100644 index 000000000..8288dbbc4 --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs @@ -0,0 +1,28 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The update user password request body. + /// </summary> + public class UpdateUserPassword + { + /// <summary> + /// Gets or sets the current sha1-hashed password. + /// </summary> + public string? CurrentPassword { get; set; } + + /// <summary> + /// Gets or sets the current plain text password. + /// </summary> + public string? CurrentPw { get; set; } + + /// <summary> + /// Gets or sets the new plain text password. + /// </summary> + public string? NewPw { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to reset the password. + /// </summary> + public bool ResetPassword { get; set; } + } +} diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index dbd5ba416..aad61d042 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -215,6 +215,31 @@ namespace Jellyfin.Server.Extensions Format = "string" }) }); + + /* + * Support BlurHash dictionary + */ + options.MapType<Dictionary<ImageType, Dictionary<string, string>>>(() => + new OpenApiSchema + { + Type = "object", + Properties = typeof(ImageType).GetEnumNames().ToDictionary( + name => name, + name => new OpenApiSchema + { + Type = "object", Properties = new Dictionary<string, OpenApiSchema> + { + { + "string", + new OpenApiSchema + { + Type = "string", + Format = "string" + } + } + } + }) + }); } } } diff --git a/MediaBrowser.Api/DisplayPreferencesService.cs b/MediaBrowser.Api/DisplayPreferencesService.cs deleted file mode 100644 index 62c4ff43f..000000000 --- a/MediaBrowser.Api/DisplayPreferencesService.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Threading; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class UpdateDisplayPreferences - /// </summary> - [Route("/DisplayPreferences/{DisplayPreferencesId}", "POST", Summary = "Updates a user's display preferences for an item")] - public class UpdateDisplayPreferences : DisplayPreferences, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "DisplayPreferencesId", Description = "DisplayPreferences Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string DisplayPreferencesId { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string UserId { get; set; } - } - - [Route("/DisplayPreferences/{Id}", "GET", Summary = "Gets a user's display preferences for an item")] - public class GetDisplayPreferences : IReturn<DisplayPreferences> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string UserId { get; set; } - - [ApiMember(Name = "Client", Description = "Client", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Client { get; set; } - } - - /// <summary> - /// Class DisplayPreferencesService - /// </summary> - [Authenticated] - public class DisplayPreferencesService : BaseApiService - { - /// <summary> - /// The _display preferences manager - /// </summary> - private readonly IDisplayPreferencesRepository _displayPreferencesManager; - /// <summary> - /// The _json serializer - /// </summary> - private readonly IJsonSerializer _jsonSerializer; - - /// <summary> - /// Initializes a new instance of the <see cref="DisplayPreferencesService" /> class. - /// </summary> - /// <param name="jsonSerializer">The json serializer.</param> - /// <param name="displayPreferencesManager">The display preferences manager.</param> - public DisplayPreferencesService( - ILogger<DisplayPreferencesService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IJsonSerializer jsonSerializer, - IDisplayPreferencesRepository displayPreferencesManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _jsonSerializer = jsonSerializer; - _displayPreferencesManager = displayPreferencesManager; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Get(GetDisplayPreferences request) - { - var result = _displayPreferencesManager.GetDisplayPreferences(request.Id, request.UserId, request.Client); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(UpdateDisplayPreferences request) - { - // Serialize to json and then back so that the core doesn't see the request dto type - var displayPreferences = _jsonSerializer.DeserializeFromString<DisplayPreferences>(_jsonSerializer.SerializeToString(request)); - - _displayPreferencesManager.SaveDisplayPreferences(displayPreferences, request.UserId, request.Client, CancellationToken.None); - } - } -} diff --git a/MediaBrowser.Api/UserService.cs b/MediaBrowser.Api/UserService.cs deleted file mode 100644 index 9cb9baf63..000000000 --- a/MediaBrowser.Api/UserService.cs +++ /dev/null @@ -1,605 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.Users; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class GetUsers - /// </summary> - [Route("/Users", "GET", Summary = "Gets a list of users")] - [Authenticated] - public class GetUsers : IReturn<UserDto[]> - { - [ApiMember(Name = "IsHidden", Description = "Optional filter by IsHidden=true or false", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsHidden { get; set; } - - [ApiMember(Name = "IsDisabled", Description = "Optional filter by IsDisabled=true or false", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsDisabled { get; set; } - - [ApiMember(Name = "IsGuest", Description = "Optional filter by IsGuest=true or false", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsGuest { get; set; } - } - - [Route("/Users/Public", "GET", Summary = "Gets a list of publicly visible users for display on a login screen.")] - public class GetPublicUsers : IReturn<UserDto[]> - { - } - - /// <summary> - /// Class GetUser - /// </summary> - [Route("/Users/{Id}", "GET", Summary = "Gets a user by Id")] - [Authenticated(EscapeParentalControl = true)] - public class GetUser : IReturn<UserDto> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class DeleteUser - /// </summary> - [Route("/Users/{Id}", "DELETE", Summary = "Deletes a user")] - [Authenticated(Roles = "Admin")] - public class DeleteUser : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class AuthenticateUser - /// </summary> - [Route("/Users/{Id}/Authenticate", "POST", Summary = "Authenticates a user")] - public class AuthenticateUser : IReturn<AuthenticationResult> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - - [ApiMember(Name = "Pw", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Pw { get; set; } - - /// <summary> - /// Gets or sets the password. - /// </summary> - /// <value>The password.</value> - [ApiMember(Name = "Password", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Password { get; set; } - } - - /// <summary> - /// Class AuthenticateUser - /// </summary> - [Route("/Users/AuthenticateByName", "POST", Summary = "Authenticates a user")] - public class AuthenticateUserByName : IReturn<AuthenticationResult> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Username", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Username { get; set; } - - /// <summary> - /// Gets or sets the password. - /// </summary> - /// <value>The password.</value> - [ApiMember(Name = "Password", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Password { get; set; } - - [ApiMember(Name = "Pw", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Pw { get; set; } - } - - /// <summary> - /// Class UpdateUserPassword - /// </summary> - [Route("/Users/{Id}/Password", "POST", Summary = "Updates a user's password")] - [Authenticated] - public class UpdateUserPassword : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - public Guid Id { get; set; } - - /// <summary> - /// Gets or sets the password. - /// </summary> - /// <value>The password.</value> - public string CurrentPassword { get; set; } - - public string CurrentPw { get; set; } - - public string NewPw { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [reset password]. - /// </summary> - /// <value><c>true</c> if [reset password]; otherwise, <c>false</c>.</value> - public bool ResetPassword { get; set; } - } - - /// <summary> - /// Class UpdateUserEasyPassword - /// </summary> - [Route("/Users/{Id}/EasyPassword", "POST", Summary = "Updates a user's easy password")] - [Authenticated] - public class UpdateUserEasyPassword : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - public Guid Id { get; set; } - - /// <summary> - /// Gets or sets the new password. - /// </summary> - /// <value>The new password.</value> - public string NewPassword { get; set; } - - public string NewPw { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [reset password]. - /// </summary> - /// <value><c>true</c> if [reset password]; otherwise, <c>false</c>.</value> - public bool ResetPassword { get; set; } - } - - /// <summary> - /// Class UpdateUser - /// </summary> - [Route("/Users/{Id}", "POST", Summary = "Updates a user")] - [Authenticated] - public class UpdateUser : UserDto, IReturnVoid - { - } - - /// <summary> - /// Class UpdateUser - /// </summary> - [Route("/Users/{Id}/Policy", "POST", Summary = "Updates a user policy")] - [Authenticated(Roles = "admin")] - public class UpdateUserPolicy : UserPolicy, IReturnVoid - { - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class UpdateUser - /// </summary> - [Route("/Users/{Id}/Configuration", "POST", Summary = "Updates a user configuration")] - [Authenticated] - public class UpdateUserConfiguration : UserConfiguration, IReturnVoid - { - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class CreateUser - /// </summary> - [Route("/Users/New", "POST", Summary = "Creates a user")] - [Authenticated(Roles = "Admin")] - public class CreateUserByName : IReturn<UserDto> - { - [ApiMember(Name = "Name", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Name { get; set; } - - [ApiMember(Name = "Password", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Password { get; set; } - } - - [Route("/Users/ForgotPassword", "POST", Summary = "Initiates the forgot password process for a local user")] - public class ForgotPassword : IReturn<ForgotPasswordResult> - { - [ApiMember(Name = "EnteredUsername", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public string EnteredUsername { get; set; } - } - - [Route("/Users/ForgotPassword/Pin", "POST", Summary = "Redeems a forgot password pin")] - public class ForgotPasswordPin : IReturn<PinRedeemResult> - { - [ApiMember(Name = "Pin", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Pin { get; set; } - } - - /// <summary> - /// Class UsersService - /// </summary> - public class UserService : BaseApiService - { - /// <summary> - /// The user manager. - /// </summary> - private readonly IUserManager _userManager; - private readonly ISessionManager _sessionMananger; - private readonly INetworkManager _networkManager; - private readonly IDeviceManager _deviceManager; - private readonly IAuthorizationContext _authContext; - - public UserService( - ILogger<UserService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ISessionManager sessionMananger, - INetworkManager networkManager, - IDeviceManager deviceManager, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _sessionMananger = sessionMananger; - _networkManager = networkManager; - _deviceManager = deviceManager; - _authContext = authContext; - } - - public object Get(GetPublicUsers request) - { - // If the startup wizard hasn't been completed then just return all users - if (!ServerConfigurationManager.Configuration.IsStartupWizardCompleted) - { - return Get(new GetUsers - { - IsDisabled = false - }); - } - - return Get(new GetUsers - { - IsHidden = false, - IsDisabled = false - }, true, true); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetUsers request) - { - return Get(request, false, false); - } - - private object Get(GetUsers request, bool filterByDevice, bool filterByNetwork) - { - var users = _userManager.Users; - - if (request.IsDisabled.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == request.IsDisabled.Value); - } - - if (request.IsHidden.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == request.IsHidden.Value); - } - - if (filterByDevice) - { - var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId; - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); - } - } - - if (filterByNetwork) - { - if (!_networkManager.IsInLocalNetwork(Request.RemoteIp)) - { - users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); - } - } - - var result = users - .OrderBy(u => u.Username) - .Select(i => _userManager.GetUserDto(i, Request.RemoteIp)) - .ToArray(); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetUser request) - { - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - var result = _userManager.GetUserDto(user, Request.RemoteIp); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Delete(DeleteUser request) - { - return DeleteAsync(request); - } - - public Task DeleteAsync(DeleteUser request) - { - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - _sessionMananger.RevokeUserTokens(user.Id, null); - _userManager.DeleteUser(user); - return Task.CompletedTask; - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Post(AuthenticateUser request) - { - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - if (!string.IsNullOrEmpty(request.Password) && string.IsNullOrEmpty(request.Pw)) - { - throw new MethodNotAllowedException("Hashed-only passwords are not valid for this API."); - } - - // Password should always be null - return Post(new AuthenticateUserByName - { - Username = user.Username, - Password = null, - Pw = request.Pw - }); - } - - public async Task<object> Post(AuthenticateUserByName request) - { - var auth = _authContext.GetAuthorizationInfo(Request); - - try - { - var result = await _sessionMananger.AuthenticateNewSession(new AuthenticationRequest - { - App = auth.Client, - AppVersion = auth.Version, - DeviceId = auth.DeviceId, - DeviceName = auth.Device, - Password = request.Pw, - PasswordSha1 = request.Password, - RemoteEndPoint = Request.RemoteIp, - Username = request.Username - }).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - catch (SecurityException e) - { - // rethrow adding IP address to message - throw new SecurityException($"[{Request.RemoteIp}] {e.Message}", e); - } - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(UpdateUserPassword request) - { - return PostAsync(request); - } - - public async Task PostAsync(UpdateUserPassword request) - { - AssertCanUpdateUser(_authContext, _userManager, request.Id, true); - - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - if (request.ResetPassword) - { - await _userManager.ResetPassword(user).ConfigureAwait(false); - } - else - { - var success = await _userManager.AuthenticateUser( - user.Username, - request.CurrentPw, - request.CurrentPassword, - Request.RemoteIp, - false).ConfigureAwait(false); - - if (success == null) - { - throw new ArgumentException("Invalid user or password entered."); - } - - await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); - - var currentToken = _authContext.GetAuthorizationInfo(Request).Token; - - _sessionMananger.RevokeUserTokens(user.Id, currentToken); - } - } - - public void Post(UpdateUserEasyPassword request) - { - AssertCanUpdateUser(_authContext, _userManager, request.Id, true); - - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - if (request.ResetPassword) - { - _userManager.ResetEasyPassword(user); - } - else - { - _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword); - } - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public async Task Post(UpdateUser request) - { - var id = Guid.Parse(GetPathValue(1)); - - AssertCanUpdateUser(_authContext, _userManager, id, false); - - var dtoUser = request; - - var user = _userManager.GetUserById(id); - - if (string.Equals(user.Username, dtoUser.Name, StringComparison.Ordinal)) - { - await _userManager.UpdateUserAsync(user); - _userManager.UpdateConfiguration(user.Id, dtoUser.Configuration); - } - else - { - await _userManager.RenameUser(user, dtoUser.Name).ConfigureAwait(false); - - _userManager.UpdateConfiguration(dtoUser.Id, dtoUser.Configuration); - } - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public async Task<object> Post(CreateUserByName request) - { - var newUser = _userManager.CreateUser(request.Name); - - // no need to authenticate password for new user - if (request.Password != null) - { - await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); - } - - var result = _userManager.GetUserDto(newUser, Request.RemoteIp); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(ForgotPassword request) - { - var isLocal = Request.IsLocal || _networkManager.IsInLocalNetwork(Request.RemoteIp); - - var result = await _userManager.StartForgotPasswordProcess(request.EnteredUsername, isLocal).ConfigureAwait(false); - - return result; - } - - public async Task<object> Post(ForgotPasswordPin request) - { - var result = await _userManager.RedeemPasswordResetPin(request.Pin).ConfigureAwait(false); - - return result; - } - - public void Post(UpdateUserConfiguration request) - { - AssertCanUpdateUser(_authContext, _userManager, request.Id, false); - - _userManager.UpdateConfiguration(request.Id, request); - } - - public void Post(UpdateUserPolicy request) - { - var user = _userManager.GetUserById(request.Id); - - // If removing admin access - if (!request.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) - { - if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) - { - throw new ArgumentException("There must be at least one user in the system with administrative access."); - } - } - - // If disabling - if (request.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) - { - throw new ArgumentException("Administrators cannot be disabled."); - } - - // If disabling - if (request.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) - { - if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) - { - throw new ArgumentException("There must be at least one enabled user in the system."); - } - - var currentToken = _authContext.GetAuthorizationInfo(Request).Token; - _sessionMananger.RevokeUserTokens(user.Id, currentToken); - } - - _userManager.UpdatePolicy(request.Id, request); - } - } -} diff --git a/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs b/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs index fe5dd6cd4..70c375b8c 100644 --- a/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs +++ b/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs @@ -14,40 +14,27 @@ namespace MediaBrowser.Common.Json.Converters /// <inheritdoc /> public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - static void ThrowFormatException() => throw new FormatException("Invalid format for an integer."); - ReadOnlySpan<byte> span = stackalloc byte[0]; - - if (reader.HasValueSequence) - { - long sequenceLength = reader.ValueSequence.Length; - Span<byte> stackSpan = stackalloc byte[(int)sequenceLength]; - reader.ValueSequence.CopyTo(stackSpan); - span = stackSpan; - } - else + if (reader.TokenType == JsonTokenType.String) { - span = reader.ValueSpan; - } + ReadOnlySpan<byte> span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; + if (Utf8Parser.TryParse(span, out int number, out int bytesConsumed) && span.Length == bytesConsumed) + { + return number; + } - if (!Utf8Parser.TryParse(span, out int number, out _)) - { - ThrowFormatException(); + if (int.TryParse(reader.GetString(), out number)) + { + return number; + } } - return number; + return reader.GetInt32(); } /// <inheritdoc /> public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) { - static void ThrowInvalidOperationException() => throw new InvalidOperationException(); - Span<byte> span = stackalloc byte[16]; - if (Utf8Formatter.TryFormat(value, span, out int bytesWritten)) - { - writer.WriteStringValue(span.Slice(0, bytesWritten)); - } - - ThrowInvalidOperationException(); + writer.WriteNumberValue(value); } } } diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index adc15123b..ec3c45476 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/MediaBrowser.Common/Json/JsonDefaults.cs @@ -28,6 +28,7 @@ namespace MediaBrowser.Common.Json }; options.Converters.Add(new JsonGuidConverter()); + options.Converters.Add(new JsonInt32Converter()); options.Converters.Add(new JsonStringEnumConverter()); options.Converters.Add(new JsonNonStringKeyDictionaryConverterFactory()); |
