diff options
Diffstat (limited to 'Jellyfin.Api')
| -rw-r--r-- | Jellyfin.Api/Controllers/BackupController.cs | 127 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/DynamicHlsController.cs | 9 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/ItemUpdateController.cs | 5 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/LocalizationController.cs | 11 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/MediaSegmentsController.cs | 5 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/PlaylistsController.cs | 39 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/StartupController.cs | 9 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/SyncPlayController.cs | 2 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/SystemController.cs | 1 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/RequestHelpers.cs | 11 | ||||
| -rw-r--r-- | Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs | 20 | ||||
| -rw-r--r-- | Jellyfin.Api/Middleware/LanFilteringMiddleware.cs | 51 |
12 files changed, 212 insertions, 78 deletions
diff --git a/Jellyfin.Api/Controllers/BackupController.cs b/Jellyfin.Api/Controllers/BackupController.cs new file mode 100644 index 000000000..aa908ee30 --- /dev/null +++ b/Jellyfin.Api/Controllers/BackupController.cs @@ -0,0 +1,127 @@ +using System.IO; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations.SystemBackupService; +using MediaBrowser.Common.Api; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.SystemBackupService; +using Microsoft.AspNetCore.Authentication.OAuth.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The backup controller. +/// </summary> +[Authorize(Policy = Policies.RequiresElevation)] +public class BackupController : BaseJellyfinApiController +{ + private readonly IBackupService _backupService; + private readonly IApplicationPaths _applicationPaths; + + /// <summary> + /// Initializes a new instance of the <see cref="BackupController"/> class. + /// </summary> + /// <param name="backupService">Instance of the <see cref="IBackupService"/> interface.</param> + /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + public BackupController(IBackupService backupService, IApplicationPaths applicationPaths) + { + _backupService = backupService; + _applicationPaths = applicationPaths; + } + + /// <summary> + /// Creates a new Backup. + /// </summary> + /// <param name="backupOptions">The backup options.</param> + /// <response code="200">Backup created.</response> + /// <response code="403">User does not have permission to retrieve information.</response> + /// <returns>The created backup manifest.</returns> + [HttpPost("Create")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult<BackupManifestDto>> CreateBackup([FromBody] BackupOptionsDto backupOptions) + { + return Ok(await _backupService.CreateBackupAsync(backupOptions ?? new()).ConfigureAwait(false)); + } + + /// <summary> + /// Restores to a backup by restarting the server and applying the backup. + /// </summary> + /// <param name="archiveRestoreDto">The data to start a restore process.</param> + /// <response code="204">Backup restore started.</response> + /// <response code="403">User does not have permission to retrieve information.</response> + /// <returns>No-Content.</returns> + [HttpPost("Restore")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public IActionResult StartRestoreBackup([FromBody, BindRequired] BackupRestoreRequestDto archiveRestoreDto) + { + var archivePath = SanitizePath(archiveRestoreDto.ArchiveFileName); + if (!System.IO.File.Exists(archivePath)) + { + return NotFound(); + } + + _backupService.ScheduleRestoreAndRestartServer(archivePath); + return NoContent(); + } + + /// <summary> + /// Gets a list of all currently present backups in the backup directory. + /// </summary> + /// <response code="200">Backups available.</response> + /// <response code="403">User does not have permission to retrieve information.</response> + /// <returns>The list of backups.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult<BackupManifestDto[]>> ListBackups() + { + return Ok(await _backupService.EnumerateBackups().ConfigureAwait(false)); + } + + /// <summary> + /// Gets the descriptor from an existing archive is present. + /// </summary> + /// <param name="path">The data to start a restore process.</param> + /// <response code="200">Backup archive manifest.</response> + /// <response code="204">Not a valid jellyfin Archive.</response> + /// <response code="404">Not a valid path.</response> + /// <response code="403">User does not have permission to retrieve information.</response> + /// <returns>The backup manifest.</returns> + [HttpGet("Manifest")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult<BackupManifestDto>> GetBackup([BindRequired] string path) + { + var backupPath = SanitizePath(path); + + if (!System.IO.File.Exists(backupPath)) + { + return NotFound(); + } + + var manifest = await _backupService.GetBackupManifest(backupPath).ConfigureAwait(false); + if (manifest is null) + { + return NoContent(); + } + + return Ok(manifest); + } + + [NonAction] + private string SanitizePath(string path) + { + // sanitize path + var archiveRestorePath = Path.GetFileName(Path.GetFullPath(path)); + var archivePath = Path.Combine(_applicationPaths.BackupPath, archiveRestorePath); + return archivePath; + } +} diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 4cac8ed67..2614fe995 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -46,6 +46,7 @@ public class DynamicHlsController : BaseJellyfinApiController private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0); private readonly Version _minFFmpegX265BframeInFmp4 = new Version(7, 0, 1); + private readonly Version _minFFmpegHlsSegmentOptions = new Version(5, 0); private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; @@ -1606,6 +1607,7 @@ public class DynamicHlsController : BaseJellyfinApiController var segmentFormat = string.Empty; var segmentContainer = outputExtension.TrimStart('.'); var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer); + var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0"; if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase)) { @@ -1621,6 +1623,11 @@ public class DynamicHlsController : BaseJellyfinApiController false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"" }; + var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions; + + // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT + hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; + segmentFormat = "fmp4" + outputFmp4HeaderArg; } else @@ -1642,8 +1649,6 @@ public class DynamicHlsController : BaseJellyfinApiController Path.GetFileNameWithoutExtension(outputPath)); } - var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0"; - return string.Format( CultureInfo.InvariantCulture, "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{11}\" {12} -y \"{13}\"", diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 50eeaeac6..e1d9b6bba 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -158,7 +158,10 @@ public class ItemUpdateController : BaseJellyfinApiController ParentalRatingOptions = _localizationManager.GetParentalRatings().ToList(), ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), Countries = _localizationManager.GetCountries().ToArray(), - Cultures = _localizationManager.GetCultures().ToArray() + Cultures = _localizationManager.GetCultures() + .DistinctBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase) + .OrderBy(c => c.DisplayName) + .ToArray() }; if (!item.IsVirtualItem diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs index bbce5a9e1..dd8f935dc 100644 --- a/Jellyfin.Api/Controllers/LocalizationController.cs +++ b/Jellyfin.Api/Controllers/LocalizationController.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; using MediaBrowser.Common.Api; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -34,7 +36,14 @@ public class LocalizationController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<CultureDto>> GetCultures() { - return Ok(_localization.GetCultures()); + var allCultures = _localization.GetCultures(); + + var distinctCultures = allCultures + .DistinctBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase) + .OrderBy(c => c.DisplayName) + .AsEnumerable(); + + return Ok(distinctCultures); } /// <summary> diff --git a/Jellyfin.Api/Controllers/MediaSegmentsController.cs b/Jellyfin.Api/Controllers/MediaSegmentsController.cs index e30e2b54e..b8836d7cf 100644 --- a/Jellyfin.Api/Controllers/MediaSegmentsController.cs +++ b/Jellyfin.Api/Controllers/MediaSegmentsController.cs @@ -5,9 +5,9 @@ using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Database.Implementations.Enums; -using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Model.MediaSegments; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; @@ -55,7 +55,8 @@ public class MediaSegmentsController : BaseJellyfinApiController return NotFound(); } - var items = await _mediaSegmentManager.GetSegmentsAsync(item, includeSegmentTypes).ConfigureAwait(false); + var libraryOptions = _libraryManager.GetLibraryOptions(item); + var items = await _mediaSegmentManager.GetSegmentsAsync(item, includeSegmentTypes, libraryOptions).ConfigureAwait(false); return Ok(new QueryResult<MediaSegmentDto>(items.ToArray())); } } diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index ec5fdab38..79c71d23a 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -450,22 +450,41 @@ public class PlaylistsController : BaseJellyfinApiController { var callingUserId = User.GetUserId(); - var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(playlistId), callingUserId); - if (playlist is null) + if (!callingUserId.IsEmpty()) { - return NotFound("Playlist not found"); + var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(playlistId), callingUserId); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OwnerUserId.Equals(callingUserId) + || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)); + + if (!isPermitted) + { + return Forbid(); + } } + else + { + var isApiKey = User.GetIsApiKey(); - var isPermitted = playlist.OwnerUserId.Equals(callingUserId) - || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)); + if (!isApiKey) + { + return Forbid(); + } + } - if (!isPermitted) + try { - return Forbid(); + await _playlistManager.RemoveItemFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); + return NoContent(); + } + catch (ArgumentException) + { + return NotFound(); } - - await _playlistManager.RemoveItemFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); - return NoContent(); } /// <summary> diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 09f20558f..3bb68553d 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; @@ -131,16 +132,16 @@ public class StartupController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto) { + ArgumentNullException.ThrowIfNull(startupUserDto.Name); + _userManager.ThrowIfInvalidUsername(startupUserDto.Name); + var user = _userManager.Users.First(); if (string.IsNullOrWhiteSpace(startupUserDto.Password)) { return BadRequest("Password must not be empty"); } - if (startupUserDto.Name is not null) - { - user.Username = startupUserDto.Name; - } + user.Username = startupUserDto.Name; await _userManager.UpdateUserAsync(user).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index fbab2a784..3d6874079 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -125,7 +125,7 @@ public class SyncPlayController : BaseJellyfinApiController { var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); var group = _syncPlayManager.GetGroup(currentSession, id); - return group == null ? NotFound() : Ok(group); + return group is null ? NotFound() : Ok(group); } /// <summary> diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 07a1f7650..450225c37 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -5,7 +5,6 @@ using System.IO; using System.Linq; using System.Net.Mime; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Models.SystemInfoDtos; using MediaBrowser.Common.Api; using MediaBrowser.Common.Configuration; diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index e10e940f2..5072f902d 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -111,7 +111,16 @@ public static class RequestHelpers return user.EnableUserPreferenceAccess; } - internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null) + /// <summary> + /// Get the session based on http request. + /// </summary> + /// <param name="sessionManager">The session manager.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="httpContext">The http context.</param> + /// <param name="userId">The optional userid.</param> + /// <returns>The session.</returns> + /// <exception cref="ResourceNotFoundException">Session not found.</exception> + public static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null) { userId ??= httpContext.User.GetUserId(); User? user = null; diff --git a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs index 842a69dd9..a0ed6c812 100644 --- a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs +++ b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs @@ -1,8 +1,10 @@ using System.Net; using System.Threading.Tasks; +using System.Web; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Middleware; @@ -12,14 +14,17 @@ namespace Jellyfin.Api.Middleware; public class IPBasedAccessValidationMiddleware { private readonly RequestDelegate _next; + private readonly ILogger<IPBasedAccessValidationMiddleware> _logger; /// <summary> /// Initializes a new instance of the <see cref="IPBasedAccessValidationMiddleware"/> class. /// </summary> /// <param name="next">The next delegate in the pipeline.</param> - public IPBasedAccessValidationMiddleware(RequestDelegate next) + /// <param name="logger">The logger to log to.</param> + public IPBasedAccessValidationMiddleware(RequestDelegate next, ILogger<IPBasedAccessValidationMiddleware> logger) { _next = next; + _logger = logger; } /// <summary> @@ -32,16 +37,23 @@ public class IPBasedAccessValidationMiddleware { if (httpContext.IsLocal()) { - // Running locally. + // Accessing from the same machine as the server. await _next(httpContext).ConfigureAwait(false); return; } - var remoteIP = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; + var remoteIP = httpContext.GetNormalizedRemoteIP(); - if (!networkManager.HasRemoteAccess(remoteIP)) + var result = networkManager.ShouldAllowServerAccess(remoteIP); + if (result != RemoteAccessPolicyResult.Allow) { // No access from network, respond with 503 instead of 200. + _logger.LogWarning( + "Blocking request to {Path} by {RemoteIP} due to IP filtering rule, reason: {Reason}", + // url-encode to block log injection + HttpUtility.UrlEncode(httpContext.Request.Path), + remoteIP, + result); httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; return; } diff --git a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs deleted file mode 100644 index 35b0a1dd0..000000000 --- a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Net; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Middleware; - -/// <summary> -/// Validates the LAN host IP based on application configuration. -/// </summary> -public class LanFilteringMiddleware -{ - private readonly RequestDelegate _next; - - /// <summary> - /// Initializes a new instance of the <see cref="LanFilteringMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public LanFilteringMiddleware(RequestDelegate next) - { - _next = next; - } - - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="networkManager">The network manager.</param> - /// <param name="serverConfigurationManager">The server configuration manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager) - { - if (serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess) - { - await _next(httpContext).ConfigureAwait(false); - return; - } - - var host = httpContext.GetNormalizedRemoteIP(); - if (!networkManager.IsInLocalNetwork(host)) - { - // No access from network, respond with 503 instead of 200. - httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; - return; - } - - await _next(httpContext).ConfigureAwait(false); - } -} |
