src/WopiHost.Core/Controllers/FilesController.cs
File `FilesController.cs` has 394 lines of code (exceeds 250 allowed). Consider refactoring.using System.Net.Mime;using System.Security.Claims;using System.Text.Json;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.Http;using Microsoft.AspNetCore.Http.Features;using Microsoft.AspNetCore.Mvc;using Microsoft.Extensions.Caching.Memory;using Microsoft.Extensions.Options;using WopiHost.Abstractions;using WopiHost.Core.Extensions;using WopiHost.Core.Infrastructure;using WopiHost.Core.Models;using WopiHost.Core.Results;using WopiHost.Core.Security.Authorization; namespace WopiHost.Core.Controllers; /// <summary>/// Implementation of WOPI server protocol/// Specification: https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest//// </summary>/// <param name="storageProvider">Storage provider instance for retrieving files and folders.</param>/// <param name="securityHandler">Security handler instance for performing security-related operations.</param>/// <param name="wopiHostOptions">WOPI Host configuration</param>/// <param name="memoryCache">An instance of the memory cache.</param>/// <param name="lockProvider">An instance of the lock provider.</param>/// <param name="cobaltProcessor">An instance of a MS-FSSHTTP processor.</param>[Authorize][ApiController][Route("wopi/[controller]")]public class FilesController( IWopiStorageProvider storageProvider, IWopiSecurityHandler securityHandler, IOptions<WopiHostOptions> wopiHostOptions, IMemoryCache memoryCache, IWopiLockProvider? lockProvider = null, ICobaltProcessor? cobaltProcessor = null) : ControllerBase{ private WopiHostCapabilities HostCapabilities => new() { SupportsCobalt = cobaltProcessor is not null, SupportsGetLock = lockProvider is not null, SupportsLocks = lockProvider is not null, SupportsCoauth = false, SupportsUpdate = true }; private const string UserInfoCacheKey = "UserInfo-{0}"; /// <summary> /// Returns the metadata about a file specified by an identifier. /// Specification: https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo /// Example URL path: /wopi/files/(file_id) /// </summary> /// <param name="id">File identifier.</param> /// <param name="cancellationToken">Cancellation token</param> /// <returns></returns> [HttpGet("{id}", Name = WopiRouteNames.CheckFileInfo)] [WopiAuthorize(WopiResourceType.File, Permission.Read)] public async Task<IActionResult> CheckFileInfo(string id, CancellationToken cancellationToken = default) { // Get file var file = storageProvider.GetWopiFile(id); if (file is null) { return NotFound(); } // build default checkFileInfo var checkFileInfo = await BuildCheckFileInfo(file, cancellationToken); // instead of JsonResult we must .Serialize<object>() to support properties that // might be defined on custom WopiCheckFileInfo objects return new ContentResult() { Content = JsonSerializer.Serialize<object>(checkFileInfo), ContentType = MediaTypeNames.Application.Json, StatusCode = StatusCodes.Status200OK }; } /// <summary> /// Returns contents of a file specified by an identifier. /// Specification: https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/getfile /// Example URL path: /wopi/files/(file_id)/contents /// </summary> /// <param name="id">File identifier.</param> /// <param name="maximumExpectedSize"></param> /// <param name="cancellationToken">Cancellation token</param> /// <returns>FileStreamResult</returns> [HttpGet("{id}/contents")] [WopiAuthorize(WopiResourceType.File, Permission.Read)] public async Task<IActionResult> GetFile( string id, [FromHeader(Name = WopiHeaders.MAX_EXPECTED_SIZE)] int? maximumExpectedSize = null, CancellationToken cancellationToken = default) { // Get file var file = storageProvider.GetWopiFile(id); if (file is null) { return NotFound(); } // Check expected size // https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/getfile#request-headers var size = file.Exists ? file.Length : 0; if (maximumExpectedSize is not null && size > maximumExpectedSize.Value) { // File is larger than X-WOPI-MaxExpectedSize return new PreconditionFailedResult(); } // Returns optional version // https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/getfile#response-headers if (file.Version is not null) { Response.Headers[WopiHeaders.WOPI_ITEM_VERSION] = file.Version; } // Try to read content from a stream return new FileStreamResult(await file.GetReadStream(cancellationToken), MediaTypeNames.Application.Octet); } /// <summary> /// The GetEcosystem operation returns the URI for the WOPI server’s Ecosystem endpoint, given a file ID. /// Specification: https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/getecosystem /// Example URL path: /wopi/files/(container_id)/ecosystem_pointer /// </summary> /// <param name="id">A string that specifies a file ID of a file managed by host. This string must be URL safe.</param> /// <returns>URL response pointing to <see cref="WopiRouteNames.CheckEcosystem"/></returns>Similar blocks of code found in 2 locations. Consider refactoring. [HttpGet("{id}/ecosystem_pointer")] [WopiAuthorize(WopiResourceType.File, Permission.Read)] [Produces(MediaTypeNames.Application.Json)] public IActionResult GetEcosystem(string id) { // Get file var file = storageProvider.GetWopiFile(id); if (file is null) { return NotFound(); } // A URI for the WOPI server’s Ecosystem endpoint, with an access token appended. A GET request to this URL will invoke the CheckEcosystem operation. return new JsonResult<UrlResponse>( new(Url.GetWopiUrl(WopiRouteNames.CheckEcosystem))); } /// <summary> /// The EnumerateAncestors operation enumerates all the parents of a given file, up to and including the root container. /// Specification: https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/enumerateancestors /// Example URL path: /wopi/containers/(container_id)/ancestry /// </summary> /// <param name="id">A string that specifies a container ID of a container managed by host. This string must be URL safe.</param> /// <param name="cancellationToken">cancellation token</param> /// <returns></returns>Similar blocks of code found in 2 locations. Consider refactoring. [HttpGet("{id}/ancestry")] [WopiAuthorize(WopiResourceType.File, Permission.Read)] [Produces(MediaTypeNames.Application.Json)] public async Task<IActionResult> EnumerateAncestors(string id, CancellationToken cancellationToken = default) { // Get file var file = storageProvider.GetWopiFile(id); if (file is null) { return NotFound(); } var ancestors = await storageProvider.GetAncestors(WopiResourceType.File, id, cancellationToken); return new JsonResult( new EnumerateAncestorsResponse(ancestors .Select(a => new ChildContainer(a.Name, Url.GetWopiUrl(WopiResourceType.Container, a.Identifier))) )); } /// <summary> /// Updates a file specified by an identifier. (Only for non-cobalt files.) /// Specification: https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/putfile /// Example URL path: /wopi/files/(file_id)/contents /// </summary> /// <param name="id">File identifier.</param> /// <param name="newLockIdentifier">new lockId</param> /// <param name="cancellationToken">Cancellation token</param> /// <returns>Returns <see cref="StatusCodes.Status200OK"/> if succeeded.</returns>Method `PutFile` has 26 lines of code (exceeds 25 allowed). Consider refactoring. [HttpPut("{id}/contents")] [HttpPost("{id}/contents")] [WopiAuthorize(WopiResourceType.File, Permission.Update)] public async Task<IActionResult> PutFile( string id, [FromHeader(Name = WopiHeaders.LOCK)] string? newLockIdentifier = null, CancellationToken cancellationToken = default) { // https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/online/scenarios/createnew var file = storageProvider.GetWopiFile(id); // If ... missing altogether, the host should respond with a 409 Conflict if (file is null) { return new ConflictResult(); } // When a host receives a PutFile request on a file that's not locked, the host checks the current size of the file. // If it's 0 bytes, the PutFile request should be considered valid and should proceed if (file.Size == 0 && string.IsNullOrEmpty(newLockIdentifier)) { // copy new contents to storage await CopyToWriteStream(); return Ok(); } // Acquire lock var lockResult = ProcessLock(id, wopiOverrideHeader: WopiFileOperations.Lock, newLockIdentifier: newLockIdentifier); if (lockResult is OkResult) { // copy new contents to storage await CopyToWriteStream(); return Ok(); } return lockResult; async Task CopyToWriteStream() { using var stream = await file.GetWriteStream(cancellationToken); await HttpContext.Request.Body.CopyToAsync( stream, cancellationToken); } } /// <summary> /// The PutRelativeFile operation creates a new file on the host based on the current file. /// M365 spec: https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/putrelativefile /// Protocol spec: https://learn.microsoft.com/openspecs/office_protocols/ms-wopi/d12ab554-eab7-480f-bdc7-0bdf14922e6f /// Example URL path: /wopi/files/(file_id) /// </summary> /// <param name="id">File identifier.</param> /// <returns>Returns <see cref="StatusCodes.Status200OK"/> if succeeded.</returns> [HttpPost("{id}"), WopiOverrideHeader(WopiFileOperations.PutRelativeFile)] [WopiAuthorize(WopiResourceType.File, Permission.Update)] public Task<IActionResult> PutRelativeFile(string id) => throw new NotImplementedException($"{nameof(PutRelativeFile)} is not implemented yet."); /// <summary> /// The PutUserInfo operation stores some basic user information on the host. /// M365 spec: https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/putuserinfo /// Example URL path: /wopi/files/(file_id) /// </summary> /// <param name="id">A string that specifies a file ID of a file managed by host. This string must be URL safe.</param> /// <param name="userInfo">A string that specifies the user information to be stored on the host. This string must be URL safe.</param> /// <returns>Returns <see cref="StatusCodes.Status200OK"/> if succeeded.</returns> [HttpPost("{id}"), WopiOverrideHeader(WopiFileOperations.PutUserInfo)] public IActionResult PutUserInfo( string id, [FromStringBody] string userInfo) { // Get file var file = storageProvider.GetWopiFile(id); if (file is null) { return NotFound(); } // The UserInfo string should be associated with a particular user, // and should be passed back to the WOPI client in subsequent CheckFileInfo responses in the UserInfo property. // we store indefinitely in memoryCache to avoid the need for a persistence model - it's called anyway by the Wopi client on every start memoryCache.Set( string.Format(UserInfoCacheKey, User.GetUserId()), userInfo, new MemoryCacheEntryOptions { Priority = CacheItemPriority.NeverRemove, }); return Ok(); } /// <summary> /// Changes the contents of the file in accordance with [MS-FSSHTTP]. /// MS-FSSHTTP Specification: https://learn.microsoft.com/openspecs/sharepoint_protocols/ms-fsshttp/05fa7efd-48ed-48d5-8d85-77995e17cc81 /// Specification: https://learn.microsoft.com/openspecs/office_protocols/ms-wopi/f52e753e-fa08-4ba4-a68b-2f8801992cf0 /// Example URL path: /wopi/files/(file_id) /// </summary> /// <param name="id">File identifier.</param> /// <param name="correlationId"></param> [HttpPost("{id}"), WopiOverrideHeader(WopiFileOperations.Cobalt)] [WopiAuthorize(WopiResourceType.File, Permission.Update)] public async Task<IActionResult> ProcessCobalt( string id, [FromHeader(Name = WopiHeaders.CORRELATION_ID)] string? correlationId = null) { ArgumentNullException.ThrowIfNull(cobaltProcessor); var file = storageProvider.GetWopiFile(id); // TODO: remove workaround https://github.com/aspnet/Announcements/issues/342 (use FileBufferingWriteStream) var syncIoFeature = HttpContext.Features.Get<IHttpBodyControlFeature>(); if (syncIoFeature is not null) { syncIoFeature.AllowSynchronousIO = true; } var responseAction = await cobaltProcessor.ProcessCobalt(file, User, await HttpContext.Request.Body.ReadBytesAsync()); if (!string.IsNullOrEmpty(correlationId)) { HttpContext.Response.Headers.Append(WopiHeaders.CORRELATION_ID, correlationId); HttpContext.Response.Headers.Append("request-id", correlationId); } return new Results.FileResult(responseAction, MediaTypeNames.Application.Octet); } /// <summary> /// Returns a CheckFileInfo model according to https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo /// </summary> /// <param name="file">File properties of which should be returned.</param> /// <param name="cancellationToken">Cancellation token</param> /// <returns>CheckFileInfo model</returns>Method `BuildCheckFileInfo` has 31 lines of code (exceeds 25 allowed). Consider refactoring. private async Task<WopiCheckFileInfo> BuildCheckFileInfo( IWopiFile file, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(file); var checkFileInfo = file.GetWopiCheckFileInfo(HostCapabilities); checkFileInfo.Sha256 = await file.GetEncodedSha256(cancellationToken); if (User?.Identity?.IsAuthenticated == true) { checkFileInfo.UserId = User.GetUserId(); checkFileInfo.HostAuthenticationId = checkFileInfo.UserId; checkFileInfo.UserFriendlyName = User.FindFirst(ClaimTypes.Name)?.Value; checkFileInfo.UserPrincipalName = User.FindFirst(ClaimTypes.Upn)?.Value ?? string.Empty; // try to parse permissions claims var permissions = await securityHandler.GetUserPermissions(User, file, cancellationToken); checkFileInfo.ReadOnly = permissions.HasFlag(WopiUserPermissions.ReadOnly); checkFileInfo.RestrictedWebViewOnly = permissions.HasFlag(WopiUserPermissions.RestrictedWebViewOnly); checkFileInfo.UserCanAttend = permissions.HasFlag(WopiUserPermissions.UserCanAttend); checkFileInfo.UserCanNotWriteRelative = !HostCapabilities.SupportsUpdate || permissions.HasFlag(WopiUserPermissions.UserCanNotWriteRelative); checkFileInfo.UserCanPresent = permissions.HasFlag(WopiUserPermissions.UserCanPresent); checkFileInfo.UserCanRename = permissions.HasFlag(WopiUserPermissions.UserCanRename); checkFileInfo.UserCanWrite = permissions.HasFlag(WopiUserPermissions.UserCanWrite); checkFileInfo.WebEditingDisabled = permissions.HasFlag(WopiUserPermissions.WebEditingDisabled); // The UserInfo ... should be passed back to the WOPI client in subsequent CheckFileInfo responses in the UserInfo property. if (memoryCache.TryGetValue(string.Format(UserInfoCacheKey, checkFileInfo.UserId), out string? userInfo) && userInfo is not null) { checkFileInfo.UserInfo = userInfo; } } else { checkFileInfo.IsAnonymousUser = true; } // allow changes and/or extensions before returning return await wopiHostOptions.Value.OnCheckFileInfo(new WopiCheckFileInfoContext(User, file, checkFileInfo)); } #region "Locking" /// <summary> /// Processes lock-related operations. /// Specification: https://learn.microsoft.com/microsoft-365/cloud-storage-partner-program/rest/files/lock /// Example URL path: /wopi/files/(file_id) /// </summary> /// <param name="id">File identifier.</param> /// <param name="wopiOverrideHeader">A string specifying the requested operation from the WOPI server</param> /// <param name="oldLockIdentifier"></param> /// <param name="newLockIdentifier"></param> [HttpPost("{id}")] [WopiOverrideHeader( WopiFileOperations.Lock, WopiFileOperations.Put, WopiFileOperations.Unlock, WopiFileOperations.RefreshLock, WopiFileOperations.GetLock)] [WopiAuthorize(WopiResourceType.File, Permission.Update)] public IActionResult ProcessLock( string id, [FromHeader(Name = WopiHeaders.WOPI_OVERRIDE)] string? wopiOverrideHeader = null, [FromHeader(Name = WopiHeaders.OLD_LOCK)] string? oldLockIdentifier = null, [FromHeader(Name = WopiHeaders.LOCK)] string? newLockIdentifier = null) { if (lockProvider is null) { return new LockMismatchResult(Response, reason: "Locking is not supported"); } var lockAcquired = lockProvider.TryGetLock(id, out var existingLock); return wopiOverrideHeader switch { WopiFileOperations.GetLock => HandleGetLock(lockAcquired, existingLock), WopiFileOperations.Lock or WopiFileOperations.Put => HandleLockOrPut(id, oldLockIdentifier, newLockIdentifier, lockAcquired, existingLock), WopiFileOperations.Unlock => HandleUnlock(id, newLockIdentifier, lockAcquired, existingLock), WopiFileOperations.RefreshLock => HandleRefreshLock(newLockIdentifier, lockAcquired, existingLock), _ => new NotImplementedResult(), }; } private IActionResult HandleGetLock(bool lockAcquired, WopiLockInfo? existingLock) { if (lockAcquired) { if (existingLock is null) { return new LockMismatchResult(Response, reason: "Missing existing lock"); } Response.Headers[WopiHeaders.LOCK] = existingLock.LockId; } else { Response.Headers[WopiHeaders.LOCK] = string.Empty; } return Ok(); } Method `HandleLockOrPut` has 62 lines of code (exceeds 25 allowed). Consider refactoring.
Method `HandleLockOrPut` has a Cognitive Complexity of 33 (exceeds 20 allowed). Consider refactoring.
Method `HandleLockOrPut` has 5 arguments (exceeds 4 allowed). Consider refactoring. private IActionResult HandleLockOrPut(string id, string? oldLockIdentifier, string? newLockIdentifier, bool lockAcquired, WopiLockInfo? existingLock) { ArgumentNullException.ThrowIfNull(lockProvider); if (oldLockIdentifier is null) { if (string.IsNullOrWhiteSpace(newLockIdentifier)) { return new LockMismatchResult(Response, reason: "Missing new lock identifier"); } if (lockAcquired) { if (existingLock is null) { return new LockMismatchResult(Response, reason: "Missing existing lock"); } return LockOrRefresh(newLockIdentifier, existingLock); } else { if (lockProvider.AddLock(id, newLockIdentifier) != null) { return Ok(); } else {Avoid too many `return` statements within this method. return new LockMismatchResult(Response, "Could not create lock"); } } } else { if (lockAcquired) { if (existingLock is null) {Avoid too many `return` statements within this method. return new LockMismatchResult(Response, reason: "Missing existing lock"); } if (existingLock.LockId == oldLockIdentifier) { if (string.IsNullOrWhiteSpace(newLockIdentifier)) {Avoid too many `return` statements within this method. return new LockMismatchResult(Response, reason: "Missing new lock identifier"); } if (lockProvider.RefreshLock(id, newLockIdentifier)) {Avoid too many `return` statements within this method. return Ok(); } else {Avoid too many `return` statements within this method. return new LockMismatchResult(Response, "Could not create lock"); } } else {Avoid too many `return` statements within this method. return new LockMismatchResult(Response, existingLock.LockId); } } else {Avoid too many `return` statements within this method. return new LockMismatchResult(Response, reason: "File not locked"); } } } Method `HandleUnlock` has 29 lines of code (exceeds 25 allowed). Consider refactoring. private IActionResult HandleUnlock(string id, string? newLockIdentifier, bool lockAcquired, WopiLockInfo? existingLock) { ArgumentNullException.ThrowIfNull(lockProvider); if (lockAcquired) { if (existingLock is null) { return new LockMismatchResult(Response, reason: "Missing existing lock"); } if (existingLock.LockId == newLockIdentifier) { if (lockProvider.RemoveLock(id)) { return Ok(); } else { return new LockMismatchResult(Response, "Could not remove lock"); } } else { return new LockMismatchResult(Response, existingLock.LockId); } } else {Avoid too many `return` statements within this method. return new LockMismatchResult(Response, reason: "File not locked"); } } private IActionResult HandleRefreshLock(string? newLockIdentifier, bool lockAcquired, WopiLockInfo? existingLock) { if (lockAcquired) { if (existingLock is null) { return new LockMismatchResult(Response, reason: "Missing existing lock"); } if (string.IsNullOrWhiteSpace(newLockIdentifier)) { return new LockMismatchResult(Response, reason: "Missing new lock identifier"); } return LockOrRefresh(newLockIdentifier, existingLock); } else { return new LockMismatchResult(Response, reason: "File not locked"); } } private IActionResult LockOrRefresh(string newLock, WopiLockInfo existingLock) { ArgumentNullException.ThrowIfNull(lockProvider); if (existingLock.LockId == newLock) { // File is currently locked and the lock ids match, refresh lock (extend the lock timeout) if (lockProvider.RefreshLock(existingLock.FileId)) { return Ok(); } else { // The lock has expired return new LockMismatchResult(Response, reason: "Could not refresh lock"); } } else { // The existing lock doesn't match the requested one (someone else might have locked the file). Return a lock mismatch error along with the current lock return new LockMismatchResult(Response, existingLock.LockId); } } #endregion}