/// <summary> /// Processes a GetFile request /// </summary> /// <remarks> /// For full documentation on GetFile, see /// https://wopi.readthedocs.io/projects/wopirest/en/latest/files/GetFile.html /// </remarks> private void HandleGetFileRequest(HttpContext context, WopiRequest requestData) { if (!ValidateAccess(requestData, writeAccessRequired: false)) { ReturnInvalidToken(context.Response); return; } if (!File.Exists(requestData.FullPath)) { ReturnFileUnknown(context.Response); return; } try { context.Response.AddHeader(WopiHeaders.ItemVersion, GetFileVersion(requestData.FullPath)); // transmit file from local storage to the response stream. ReturnSuccess(context.Response); context.Response.TransmitFile(requestData.FullPath); } catch (UnauthorizedAccessException ex) { ConsoleTo.Log(ex); ReturnFileUnknown(context.Response); } catch (FileNotFoundException ex) { ConsoleTo.Log(ex); ReturnFileUnknown(context.Response); } }
/// <summary> /// Validate that the provided access token is valid to get access to requested resource. /// </summary> /// <param name="requestData">Request information, including requested file Id</param> /// <param name="writeAccessRequired">Whether write permission is requested or not.</param> /// <returns>true when access token is correct and user has access to document, false otherwise.</returns> private static bool ValidateAccess(WopiRequest requestData, bool writeAccessRequired) { // TODO: Access token validation is not implemented in this sample. // For more details on access tokens, see the documentation // https://wopi.readthedocs.io/projects/wopirest/en/latest/concepts.html#term-access-token // "INVALID" is used by the WOPIValidator. return(!string.IsNullOrWhiteSpace(requestData.AccessToken) && (requestData.AccessToken != "INVALID")); }
/// <summary> /// Processes a Lock request /// </summary> /// <remarks> /// For full documentation on Lock, see /// https://wopi.readthedocs.io/projects/wopirest/en/latest/files/Lock.html /// </remarks> private void HandleLockRequest(HttpContext context, WopiRequest requestData) { if (!ValidateAccess(requestData, writeAccessRequired: true)) { ReturnInvalidToken(context.Response); return; } if (!File.Exists(requestData.FullPath)) { ReturnFileUnknown(context.Response); return; } string newLock = context.Request.Headers[WopiHeaders.Lock]; lock (Locks) { bool fLocked = TryGetLock(requestData.Id, out LockInfo existingLock); if (fLocked && existingLock.Lock != newLock) { // There is a valid existing lock on the file and it doesn't match the requested lockstring. // This is a fairly common case and shouldn't be tracked as an error. Office Online can store // information about a current session in the lock value and expects to conflict when there's // an existing session to join. ReturnLockMismatch(context.Response, existingLock.Lock); } else { // The file is not currently locked or the lock has already expired if (fLocked) { Locks.Remove(requestData.Id); } // Create and store new lock information // TODO: In a real implementation the lock should be stored in a persisted and shared system. Locks[requestData.Id] = new LockInfo() { DateCreated = DateTime.UtcNow, Lock = newLock }; context.Response.AddHeader(WopiHeaders.ItemVersion, GetFileVersion(requestData.FullPath)); // Return success ReturnSuccess(context.Response); } } }
/// <summary> /// Processes a UnlockAndRelock request /// </summary> /// <remarks> /// For full documentation on UnlockAndRelock, see /// https://wopi.readthedocs.io/projects/wopirest/en/latest/files/UnlockAndRelock.html /// </remarks> private void HandleUnlockAndRelockRequest(HttpContext context, WopiRequest requestData) { if (!ValidateAccess(requestData, writeAccessRequired: true)) { ReturnInvalidToken(context.Response); return; } if (!File.Exists(requestData.FullPath)) { ReturnFileUnknown(context.Response); return; } string newLock = context.Request.Headers[WopiHeaders.Lock]; string oldLock = context.Request.Headers[WopiHeaders.OldLock]; lock (Locks) { if (TryGetLock(requestData.Id, out LockInfo existingLock)) { if (existingLock.Lock == oldLock) { // There is a valid lock on the file and the existing lock matches the provided one // Replace the existing lock with the new one Locks[requestData.Id] = new LockInfo() { DateCreated = DateTime.UtcNow, Lock = newLock }; context.Response.Headers[WopiHeaders.OldLock] = newLock; ReturnSuccess(context.Response); } else { // The existing lock doesn't match the requested one. Return a lock mismatch error // along with the current lock ReturnLockMismatch(context.Response, existingLock.Lock); } } else { // The requested lock does not exist. That's also a lock mismatch error. ReturnLockMismatch(context.Response, reason: "File not locked"); } } }
/// <summary> /// Processes a Unlock request /// </summary> /// <remarks> /// For full documentation on Unlock, see /// https://wopi.readthedocs.io/projects/wopirest/en/latest/files/Unlock.html /// </remarks> private void HandleUnlockRequest(HttpContext context, WopiRequest requestData) { if (!ValidateAccess(requestData, writeAccessRequired: true)) { ReturnInvalidToken(context.Response); return; } if (!File.Exists(requestData.FullPath)) { ReturnFileUnknown(context.Response); return; } string newLock = context.Request.Headers[WopiHeaders.Lock]; lock (Locks) { if (TryGetLock(requestData.Id, out LockInfo existingLock)) { if (existingLock.Lock == newLock) { // There is a valid lock on the file and the existing lock matches the provided one // Remove the current lock Locks.Remove(requestData.Id); context.Response.AddHeader(WopiHeaders.ItemVersion, GetFileVersion(requestData.FullPath)); ReturnSuccess(context.Response); } else { // The existing lock doesn't match the requested one. Return a lock mismatch error // along with the current lock ReturnLockMismatch(context.Response, existingLock.Lock); } } else { // The requested lock does not exist. That's also a lock mismatch error. ReturnLockMismatch(context.Response, reason: "File not locked"); } } }
/// <summary> /// Begins processing the incoming WOPI request. /// </summary> public void ProcessRequest(HttpContext context) { // WOPI ProofKey validation is an optional way that a WOPI host can ensure that the request // is coming from the Office Online server that they expect to be talking to. if (!ValidateWopiProofKey(context.Request)) { ReturnServerError(context.Response); } // Parse the incoming WOPI request WopiRequest requestData = ParseRequest(context.Request); // Call the appropriate handler for the WOPI request we received switch (requestData.Type) { case RequestType.CheckFileInfo: HandleCheckFileInfoRequest(context, requestData); break; case RequestType.Lock: HandleLockRequest(context, requestData); break; case RequestType.Unlock: HandleUnlockRequest(context, requestData); break; case RequestType.RefreshLock: HandleRefreshLockRequest(context, requestData); break; case RequestType.UnlockAndRelock: HandleUnlockAndRelockRequest(context, requestData); break; case RequestType.GetFile: HandleGetFileRequest(context, requestData); break; case RequestType.PutFile: HandlePutFileRequest(context, requestData); break; // These request types are not implemented in this sample. // Of these, only PutRelativeFile would be implemented by a typical WOPI host. case RequestType.PutRelativeFile: // If this is implemented, the UserCanNotWriteRelative field in CheckFileInfo needs to be updated. case RequestType.EnumerateChildren: case RequestType.CheckFolderInfo: case RequestType.DeleteFile: case RequestType.ExecuteCobaltRequest: case RequestType.GetRestrictedLink: case RequestType.ReadSecureStore: case RequestType.RevokeRestrictedLink: ReturnUnsupported(context.Response); break; default: ReturnServerError(context.Response); break; } }
/// <summary> /// Processes a PutFile request /// </summary> /// <remarks> /// For full documentation on PutFile, see /// https://wopi.readthedocs.io/projects/wopirest/en/latest/files/PutFile.html /// </remarks> private void HandlePutFileRequest(HttpContext context, WopiRequest requestData) { if (!ValidateAccess(requestData, writeAccessRequired: true)) { ReturnInvalidToken(context.Response); return; } if (!File.Exists(requestData.FullPath)) { ReturnFileUnknown(context.Response); return; } string newLock = context.Request.Headers[WopiHeaders.Lock]; LockInfo existingLock; bool hasExistingLock; lock (Locks) { hasExistingLock = TryGetLock(requestData.Id, out existingLock); } if (hasExistingLock && existingLock.Lock != newLock) { // lock mismatch/locked by another interface ReturnLockMismatch(context.Response, existingLock.Lock); return; } FileInfo putTargetFileInfo = new FileInfo(requestData.FullPath); // The WOPI spec allows for a PutFile to succeed on a non-locked file if the file is currently zero bytes in length. // This allows for a more efficient Create New File flow that saves the Lock roundtrips. if (!hasExistingLock && putTargetFileInfo.Length != 0) { // With no lock and a non-zero file, a PutFile could potentially result in data loss by clobbering // existing content. Therefore, return a lock mismatch error. ReturnLockMismatch(context.Response, reason: "PutFile on unlocked file with current size != 0"); } // Either the file has a valid lock that matches the lock in the request, or the file is unlocked // and is zero bytes. Either way, proceed with the PutFile. try { // TODO: Should be replaced with proper file save logic to a real storage system and ensures write atomicity using (var fileStream = File.Open(requestData.FullPath, FileMode.Truncate, FileAccess.Write, FileShare.None)) { context.Request.InputStream.CopyTo(fileStream); } context.Response.AddHeader(WopiHeaders.ItemVersion, GetFileVersion(requestData.FullPath)); } catch (UnauthorizedAccessException ex) { ConsoleTo.Log(ex); ReturnFileUnknown(context.Response); } catch (IOException ex) { ConsoleTo.Log(ex); ReturnServerError(context.Response); } }
/// <summary> /// Processes a CheckFileInfo request /// </summary> /// <remarks> /// For full documentation on CheckFileInfo, see /// https://wopi.readthedocs.io/projects/wopirest/en/latest/files/CheckFileInfo.html /// </remarks> private void HandleCheckFileInfoRequest(HttpContext context, WopiRequest requestData) { if (!ValidateAccess(requestData, writeAccessRequired: false)) { ReturnInvalidToken(context.Response); return; } if (!File.Exists(requestData.FullPath)) { ReturnFileUnknown(context.Response); return; } try { FileInfo fileInfo = new FileInfo(requestData.FullPath); if (!fileInfo.Exists) { ReturnFileUnknown(context.Response); return; } // For more info on CheckFileInfoResponse fields, see // https://wopi.readthedocs.io/projects/wopirest/en/latest/files/CheckFileInfo.html#response CheckFileInfoResponse responseData = new CheckFileInfoResponse() { // required CheckFileInfo properties BaseFileName = Path.GetFileName(requestData.FullPath), OwnerId = requestData.UserId, Size = (int)fileInfo.Length, UserId = requestData.UserId, Version = fileInfo.LastWriteTimeUtc.ToString("O" /* ISO 8601 DateTime format string */), // Using the file write time is an arbitrary choice. // optional CheckFileInfo properties BreadcrumbBrandName = WopiConfig.BreadcrumbBrandName, //BreadcrumbFolderName = fileInfo.Directory != null ? fileInfo.Directory.Name : "", //BreadcrumbDocName = Path.GetFileNameWithoutExtension(requestData.FullPath), //BreadcrumbBrandUrl = context.Request.Url.Scheme + "://" + context.Request.Url.Host, //BreadcrumbFolderUrl = context.Request.Url.Scheme + "://" + context.Request.Url.Host, UserFriendlyName = requestData.UserName, SupportsLocks = true, SupportsUpdate = true, UserCanNotWriteRelative = true, /* Because this host does not support PutRelativeFile */ ReadOnly = fileInfo.IsReadOnly, UserCanWrite = !fileInfo.IsReadOnly, }; string jsonString = JsonConvert.SerializeObject(responseData); context.Response.Write(jsonString); ReturnSuccess(context.Response); } catch (UnauthorizedAccessException ex) { ConsoleTo.Log(ex); ReturnFileUnknown(context.Response); } }
/// <summary> /// Parse the request determine the request type, access token, and file id. /// For more details, see the [MS-WOPI] Web Application Open Platform Interface Protocol specification. /// </summary> /// <remarks> /// Can be extended to parse client version, machine name, etc. /// </remarks> private static WopiRequest ParseRequest(HttpRequest request) { // Initilize wopi request data object with default values WopiRequest requestData = new WopiRequest() { Type = RequestType.None, AccessToken = request.QueryString["access_token"] ?? Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString("N"), UserId = request.QueryString["UserId"]?.ToString() ?? "0", UserName = request.QueryString["UserName"]?.ToString() ?? "Hi" }; // request.Url pattern: // http(s)://server/<...>/wopi/[files|folders]/<id>?access_token=<token> // or // https(s)://server/<...>/wopi/files/<id>/contents?access_token=<token> // or // https(s)://server/<...>/wopi/folders/<id>/children?access_token=<token> // Get request path, e.g. /<...>/wopi/files/<id> string requestPath = request.Url.AbsolutePath; // remove /<...>/wopi/ string wopiPath = requestPath.Substring(WopiConfig.WopiPath.Length); if (wopiPath.StartsWith(WopiConfig.FilesRequestPath)) { // A file-related request // remove /files/ from the beginning of wopiPath string rawId = wopiPath.Substring(WopiConfig.FilesRequestPath.Length); if (rawId.EndsWith(WopiConfig.ContentsRequestPath)) { // The rawId ends with /contents so this is a request to read/write the file contents // Remove /contents from the end of rawId to get the actual file id requestData.Id = rawId.Substring(0, rawId.Length - WopiConfig.ContentsRequestPath.Length); if (request.HttpMethod == "GET") { requestData.Type = RequestType.GetFile; } if (request.HttpMethod == "POST") { requestData.Type = RequestType.PutFile; } } else { requestData.Id = rawId; if (request.HttpMethod == "GET") { // a GET to the file is always a CheckFileInfo request requestData.Type = RequestType.CheckFileInfo; } else if (request.HttpMethod == "POST") { // For a POST to the file we need to use the X-WOPI-Override header to determine the request type string wopiOverride = request.Headers[WopiHeaders.RequestType]; switch (wopiOverride) { case "PUT_RELATIVE": requestData.Type = RequestType.PutRelativeFile; break; case "LOCK": // A lock could be either a lock or an unlock and relock, determined based on whether // the request sends an OldLock header. if (request.Headers[WopiHeaders.OldLock] != null) { requestData.Type = RequestType.UnlockAndRelock; } else { requestData.Type = RequestType.Lock; } break; case "UNLOCK": requestData.Type = RequestType.Unlock; break; case "REFRESH_LOCK": requestData.Type = RequestType.RefreshLock; break; case "COBALT": requestData.Type = RequestType.ExecuteCobaltRequest; break; case "DELETE": requestData.Type = RequestType.DeleteFile; break; case "READ_SECURE_STORE": requestData.Type = RequestType.ReadSecureStore; break; case "GET_RESTRICTED_LINK": requestData.Type = RequestType.GetRestrictedLink; break; case "REVOKE_RESTRICTED_LINK": requestData.Type = RequestType.RevokeRestrictedLink; break; } } } } else if (wopiPath.StartsWith(WopiConfig.FoldersRequestPath)) { // A folder-related request. // remove /folders/ from the beginning of wopiPath string rawId = wopiPath.Substring(WopiConfig.FoldersRequestPath.Length); if (rawId.EndsWith(WopiConfig.ChildrenRequestPath)) { // rawId ends with /children, so it's an EnumerateChildren request. // remove /children from the end of rawId requestData.Id = rawId.Substring(0, rawId.Length - WopiConfig.ChildrenRequestPath.Length); requestData.Type = RequestType.EnumerateChildren; } else { // rawId doesn't end with /children, so it's a CheckFolderInfo. requestData.Id = rawId; requestData.Type = RequestType.CheckFolderInfo; } } else { // An unknown request. requestData.Type = RequestType.None; } return(requestData); }