petrsvihlik/WopiHost

View on GitHub
src/WopiHost.Core/Controllers/FilesController.cs

Summary

Maintainability
D
1 day
Test Coverage
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using WopiHost.Abstractions;
using WopiHost.Core.Models;
using WopiHost.Core.Results;
using WopiHost.Core.Security;

namespace WopiHost.Core.Controllers;

/// <summary>
/// Implementation of WOPI server protocol
/// Specification: https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/
/// </summary>
[Route("wopi/[controller]")]
public class FilesController : WopiControllerBase
{
    private readonly IAuthorizationService _authorizationService;

    /// <summary>
    /// Service that can process MS-FSSHTTP requests.
    /// </summary>
    public ICobaltProcessor CobaltProcessor { get; set; }

    private HostCapabilities HostCapabilities => new()
    {
        SupportsCobalt = CobaltProcessor is not null,
        SupportsGetLock = true,
        SupportsLocks = true,
        SupportsExtendedLockLength = true,
        SupportsFolders = true,//?
        SupportsCoauth = true,//?
        SupportsUpdate = true //TODO: PutRelativeFile - usercannotwriterelative
    };

    /// <summary>
    /// Collection holding information about locks. Should be persistent.
    /// </summary>
    private static IDictionary<string, LockInfo> _lockStorage;

    /// <summary>
    /// A string specifying the requested operation from the WOPI server
    /// </summary>
    private string WopiOverrideHeader => HttpContext.Request.Headers[WopiHeaders.WOPI_OVERRIDE];

    /// <summary>
    /// Creates an instance of <see cref="FilesController"/>.
    /// </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="authorizationService">An instance of authorization service capable of resource-based authorization.</param>
    /// <param name="lockStorage">An instance of a storage for lock information.</param>
    /// <param name="cobaltProcessor">An instance of a MS-FSSHTTP processor.</param>
    public FilesController(IWopiStorageProvider storageProvider, IWopiSecurityHandler securityHandler, IOptionsSnapshot<WopiHostOptions> wopiHostOptions, IAuthorizationService authorizationService, IDictionary<string, LockInfo> lockStorage, ICobaltProcessor cobaltProcessor = null) : base(storageProvider, securityHandler, wopiHostOptions)
    {
        _authorizationService = authorizationService;
        _lockStorage = lockStorage;
        CobaltProcessor = cobaltProcessor;
    }

    /// <summary>
    /// Returns the metadata about a file specified by an identifier.
    /// Specification: https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo
    /// Example URL path: /wopi/files/(file_id)
    /// </summary>
    /// <param name="id">File identifier.</param>
    /// <returns></returns>
    [HttpGet("{id}")]
    public async Task<IActionResult> GetCheckFileInfo(string id)
    {
        if (!(await _authorizationService.AuthorizeAsync(User, new FileResource(id), WopiOperations.Read)).Succeeded)
        {
            return Unauthorized();
        }
        return new JsonResult(StorageProvider.GetWopiFile(id)?.GetCheckFileInfo(User, HostCapabilities), null);
    }

    /// <summary>
    /// Returns contents of a file specified by an identifier.
    /// Specification: https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile
    /// Example URL path: /wopi/files/(file_id)/contents
    /// </summary>
    /// <param name="id">File identifier.</param>
    /// <returns></returns>
    [HttpGet("{id}/contents")]
    public async Task<IActionResult> GetFile(string id)
    {
        // Check permissions
        if (!(await _authorizationService.AuthorizeAsync(User, new FileResource(id), WopiOperations.Read)).Succeeded)
        {
            return Unauthorized();
        }

        // Get file
        var file = StorageProvider.GetWopiFile(id);

        // Check expected size
        var maximumExpectedSize = HttpContext.Request.Headers[WopiHeaders.MAX_EXPECTED_SIZE].ToString().ToNullableInt();
        if (maximumExpectedSize is not null && file.GetCheckFileInfo(User, HostCapabilities).Size > maximumExpectedSize.Value)
        {
            return new PreconditionFailedResult();
        }

        // Try to read content from a stream
        return new FileStreamResult(file.GetReadStream(), "application/octet-stream");
    }

    /// <summary>
    /// Updates a file specified by an identifier. (Only for non-cobalt files.)
    /// Specification: https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile
    /// Example URL path: /wopi/files/(file_id)/contents
    /// </summary>
    /// <param name="id">File identifier.</param>
    /// <returns>Returns <see cref="StatusCodes.Status200OK"/> if succeeded.</returns>
    [HttpPut("{id}/contents")]
    [HttpPost("{id}/contents")]
    public async Task<IActionResult> PutFile(string id)
    {
        // Check permissions
        var authorizationResult = await _authorizationService.AuthorizeAsync(User, new FileResource(id), WopiOperations.Update);

        if (!authorizationResult.Succeeded)
        {
            return Unauthorized();
        }

        // Acquire lock
        var lockResult = ProcessLock(id);

        if (lockResult is OkResult)
        {
            // Get file
            var file = StorageProvider.GetWopiFile(id);

            // Save file contents
            var newContent = await HttpContext.Request.Body.ReadBytesAsync();
            await using (var stream = file.GetWriteStream())
            {
                await stream.WriteAsync(newContent.AsMemory(0, newContent.Length));
            }

            return new OkResult();
        }
        return lockResult;
    }

    /// <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(["PUT_RELATIVE"])]
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
    public async Task<IActionResult> PutRelativeFile(string id) => throw new NotImplementedException($"{nameof(PutRelativeFile)} is not implemented yet.");
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously

    /// <summary>
    /// Changes the contents of the file in accordance with [MS-FSSHTTP].
    /// MS-FSSHTTP Specification: https://msdn.microsoft.com/en-us/library/dd943623.aspx
    /// Specification: https://msdn.microsoft.com/en-us/library/hh659581.aspx
    /// Example URL path: /wopi/files/(file_id)
    /// </summary>
    /// <param name="id">File identifier.</param>
    [HttpPost("{id}"), WopiOverrideHeader(["COBALT"])]
    public async Task<IActionResult> ProcessCobalt(string id)
    {
        // Check permissions
        if (!(await _authorizationService.AuthorizeAsync(User, new FileResource(id), WopiOperations.Update)).Succeeded)
        {
            return Unauthorized();
        }

        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 = CobaltProcessor.ProcessCobalt(file, User, await HttpContext.Request.Body.ReadBytesAsync());
        HttpContext.Response.Headers.Append(WopiHeaders.CORRELATION_ID, HttpContext.Request.Headers[WopiHeaders.CORRELATION_ID]);
        HttpContext.Response.Headers.Append("request-id", HttpContext.Request.Headers[WopiHeaders.CORRELATION_ID]);
        return new Results.FileResult(responseAction, "application/octet-stream");
    }

    #region "Locking"

    /// <summary>
    /// Processes lock-related operations.
    /// Specification: https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock
    /// Example URL path: /wopi/files/(file_id)
    /// </summary>
    /// <param name="id">File identifier.</param>
    [HttpPost("{id}"), WopiOverrideHeader(["LOCK", "UNLOCK", "REFRESH_LOCK", "GET_LOCK"])]
    public IActionResult ProcessLock(string id)
    {
        string oldLock = Request.Headers[WopiHeaders.OLD_LOCK];
        string newLock = Request.Headers[WopiHeaders.LOCK];

        lock (_lockStorage)
        {
            var lockAcquired = TryGetLock(id, out var existingLock);
            switch (WopiOverrideHeader)
            {
                case "GET_LOCK":
                    if (lockAcquired)
                    {
                        Response.Headers[WopiHeaders.LOCK] = existingLock.Lock;
                    }
                    return new OkResult();

                case "LOCK":
                case "PUT":
                    if (oldLock is null)
                    {
                        // Lock / put
                        if (lockAcquired)
                        {
                            return LockOrRefresh(newLock, existingLock);
                        }
                        else
                        {
                            // The file is not currently locked, create and store new lock information
                            _lockStorage[id] = new LockInfo { DateCreated = DateTime.UtcNow, Lock = newLock };
                            return new OkResult();
                        }
                    }
                    else
                    {
                        // Unlock and re-lock (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlockandrelock)
                        if (lockAcquired)
                        {
                            if (existingLock.Lock == oldLock)
                            {
                                // Replace the existing lock with the new one
                                _lockStorage[id] = new LockInfo { DateCreated = DateTime.UtcNow, Lock = newLock };
                                return new OkResult();
                            }
                            else
                            {
                                // The existing lock doesn't match the requested one. Return a lock mismatch error along with the current lock
                                return ReturnLockMismatch(Response, existingLock.Lock);
                            }
                        }
                        else
                        {
                            // The requested lock does not exist which should result in a lock mismatch error.
                            return ReturnLockMismatch(Response, reason: "File not locked");
                        }
                    }

                case "UNLOCK":
                    if (lockAcquired)
                    {
                        if (existingLock.Lock == newLock)
                        {
                            // Remove valid lock
                            _lockStorage.Remove(id);
                            return new OkResult();
                        }
                        else
                        {
                            // The existing lock doesn't match the requested one. Return a lock mismatch error along with the current lock
                            return ReturnLockMismatch(Response, existingLock.Lock);
                        }
                    }
                    else
                    {
                        // The requested lock does not exist.
                        return ReturnLockMismatch(Response, reason: "File not locked");
                    }

                case "REFRESH_LOCK":
                    if (lockAcquired)
                    {
                        return LockOrRefresh(newLock, existingLock);
                    }
                    else
                    {
                        // The requested lock does not exist. That's also a lock mismatch error.
                        return ReturnLockMismatch(Response, reason: "File not locked");
                    }
            }
        }

        return new OkResult();

        IActionResult LockOrRefresh(string newLock, LockInfo existingLock)
        {
            if (existingLock.Lock == newLock)
            {
                // File is currently locked and the lock ids match, refresh lock (extend the lock timeout)
                existingLock.DateCreated = DateTime.UtcNow;
                return new OkResult();
            }
            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 ReturnLockMismatch(Response, existingLock.Lock);
            }
        }

        StatusCodeResult ReturnLockMismatch(HttpResponse response, string existingLock = null, string reason = null)
        {
            response.Headers[WopiHeaders.LOCK] = existingLock ?? string.Empty;
            if (!string.IsNullOrEmpty(reason))
            {
                response.Headers[WopiHeaders.LOCK_FAILURE_REASON] = reason;
            }
            return new ConflictResult();
        }

        bool TryGetLock(string fileId, out LockInfo lockInfo)
        {
            if (_lockStorage.TryGetValue(fileId, out lockInfo))
            {
                if (lockInfo.Expired)
                {
                    _lockStorage.Remove(fileId);
                    return false;
                }
                return true;
            }

            return false;
        }
    }

    #endregion
}