Files
lasuite-drive/src/backend/wopi/viewsets.py
T
Nicolas Clerc 9ec728847d 🔒️(backend) scan files written through WOPI
PutFile wrote the content straight to object storage, while malware
detection only ran on the upload endpoint. Editing a document was
therefore a way to store content that was never analysed.

The item stays READY during the analysis: a collaborator cannot open a
file that is not READY, so flipping the state would eject everyone from
the document on every save.
2026-08-07 12:39:16 +02:00

435 lines
16 KiB
Python

"""WOPI viewsets module."""
import logging
import uuid
from os.path import splitext
from django.conf import settings
from django.core.exceptions import RequestDataTooBig
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import transaction
from django.http import StreamingHttpResponse
from lasuite.malware_detection import malware_detection
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from sentry_sdk import capture_exception
from core.api.utils import get_item_file_head_object
from core.models import Item
from wopi.authentication import WopiAccessTokenAuthentication
from wopi.permissions import AccessTokenPermission
from wopi.services.lock import LockService
from wopi.utils import get_wopi_client_config, get_wopi_item_version
logger = logging.getLogger(__name__)
HTTP_X_WOPI_LOCK = "HTTP_X_WOPI_LOCK"
HTTP_X_WOPI_OLD_LOCK = "HTTP_X_WOPI_OLDLOCK"
HTTP_X_WOPI_OVERRIDE = "HTTP_X_WOPI_OVERRIDE"
X_WOPI_INVALIDFILENAMERROR = "X-WOPI-InvalidFileNameError"
X_WOPI_ITEMVERSION = "X-WOPI-ItemVersion"
X_WOPI_LOCK = "X-WOPI-Lock"
S3_VERSION_ID = "VersionId"
ILLEGAL_FILENAME_CHARS = ("/", "\\")
class WopiViewSet(viewsets.ViewSet):
"""
WOPI ViewSet
"""
authentication_classes = [WopiAccessTokenAuthentication]
permission_classes = [AccessTokenPermission]
queryset = Item.objects.all()
detail_post_actions = {
"LOCK": "_lock",
"GET_LOCK": "_get_lock",
"REFRESH_LOCK": "_refresh_lock",
"UNLOCK": "_unlock",
"RENAME_FILE": "_rename_file",
}
def get_file_id(self):
"""Get the file id from the URL path."""
return uuid.UUID(self.kwargs.get("pk"))
# pylint: disable=unused-argument
def retrieve(self, request, pk=None):
"""
Implementation of the Wopi CheckFileInfo file operation
https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo
"""
item = request.auth.item
abilities = item.get_abilities(request.user)
head_object = get_item_file_head_object(item)
wopi_client = get_wopi_client_config(item, request.user)
client_options = {}
if wopi_client:
client_name = wopi_client.get("client", "")
client_config = settings.WOPI_CLIENTS_CONFIGURATION.get(client_name, {})
client_options = client_config.get("options", {})
properties = {
"BaseFileName": item.filename,
"OwnerId": str(item.creator.id),
"IsAnonymousUser": request.user.is_anonymous,
"UserFriendlyName": request.user.full_name if not request.user.is_anonymous else None,
"Size": head_object["ContentLength"],
"UserId": str(request.user.id),
"Version": get_wopi_item_version(head_object),
"UserCanWrite": abilities["update"],
"UserCanRename": abilities["update"],
"UserCanPresent": False,
"UserCanAttend": False,
"UserCanNotWriteRelative": True,
"ReadOnly": not abilities["update"],
"SupportsRename": client_options.get("SupportsRename", True),
"SupportsUpdate": True,
"SupportsDeleteFile": True,
"SupportsCobalt": False,
"SupportsContainers": False,
"SupportsEcosystem": False,
"SupportsGetFileWopiSrc": False,
"SupportsGetLock": True,
"SupportsLocks": True,
"SupportsUserInfo": False,
"DownloadUrl": f"/media/{item.file_key}",
}
return Response(properties, status=200)
@action(detail=True, methods=["get", "post"], url_path="contents")
def file_content(self, request, pk=None):
"""
Operations to get or put the file content.
"""
if request.method == "GET":
return self._get_file_content(request, pk)
if request.method == "POST":
return self._put_file_content(request, pk)
return Response(status=405)
def _get_file_content(self, request, pk=None):
"""
Implementation of the Wopi GetFile file operation
https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile
"""
item = request.auth.item
max_expected_size = request.META.get("HTTP_X_WOPI_MAXEXPECTEDSIZE")
head_object = get_item_file_head_object(item)
if max_expected_size:
if int(head_object["ContentLength"]) > int(max_expected_size):
logger.info(
"get_file_content: file size %s exceeds X-WOPI-MaxExpectedSize header value %s",
int(head_object["ContentLength"]),
int(max_expected_size),
)
return Response(status=412)
s3_client = default_storage.connection.meta.client
file = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=item.file_key,
)
return StreamingHttpResponse(
streaming_content=file["Body"].iter_chunks(),
content_type=item.mimetype,
headers={
"X-WOPI-ItemVersion": get_wopi_item_version(head_object),
"Content-Length": head_object["ContentLength"],
},
status=200,
)
def _put_file_content(self, request, pk=None):
"""
Implementation of the Wopi PutFile file operation
https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile
"""
if request.META.get(HTTP_X_WOPI_OVERRIDE) != "PUT":
return Response(status=404)
item = request.auth.item
abilities = item.get_abilities(request.user)
if not abilities["update"]:
return Response(status=401)
lock_value = request.META.get(HTTP_X_WOPI_LOCK)
if lock_value:
lock_service = LockService(item)
current_lock_value = lock_service.get_lock(default="")
if current_lock_value != lock_value:
return Response(status=409, headers={X_WOPI_LOCK: current_lock_value})
else:
# Check if the body is 0 bytes
body_size = item.size or 0
if body_size > 0:
return Response(status=409, headers={X_WOPI_LOCK: ""})
try:
file = ContentFile(request.body)
except RequestDataTooBig:
return Response(status=413)
s3_client = default_storage.connection.meta.client
default_storage.save(item.file_key, file)
item.size = file.size
# Keep the item READY during re-analysis: non-creators cannot open
# non-READY files in WOPI.
item.save(update_fields=["size", "updated_at"])
malware_detection.analyse_file(item.file_key, item_id=item.id)
head_response = s3_client.head_object(Bucket=default_storage.bucket_name, Key=item.file_key)
return Response(
status=200,
headers={X_WOPI_ITEMVERSION: get_wopi_item_version(head_response)},
)
def detail_post(self, request, pk=None):
"""
A details view acessible using a POST request.
WOPI protocol uses multiple time this POST view for different actions.
The action is determined by the X-WOPI-Override header.
The actions are:
- LOCK: Acquire a lock on the file
- GET_LOCK: Retrieve a lock on the file
- REFRESH_LOCK: Refresh a lock on the file
- UNLOCK: Release a lock on the file
- RENAME_FILE: Rename the file
"""
if not request.META.get(HTTP_X_WOPI_OVERRIDE) in self.detail_post_actions:
return Response(status=404)
item = request.auth.item
abilities = item.get_abilities(request.user)
if not abilities["update"]:
return Response(status=401)
post_action = self.detail_post_actions[request.META.get(HTTP_X_WOPI_OVERRIDE)]
return getattr(self, post_action)(request, pk)
def _lock(self, request, pk=None):
"""
Acquire a lock on the file
https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock
"""
lock_value = request.META.get(HTTP_X_WOPI_LOCK)
if not lock_value:
return Response(status=400)
if request.META.get(HTTP_X_WOPI_OLD_LOCK, False):
return self._unlock_and_relock(request, pk)
item = request.auth.item
lock_service = LockService(item)
if not lock_service.is_locked():
lock_service.lock(lock_value)
return Response(status=200)
if not lock_service.is_lock_valid(lock_value):
return Response(status=409, headers={X_WOPI_LOCK: lock_service.get_lock()})
lock_service.refresh_lock()
return Response(status=200)
def _get_lock(self, request, pk=None):
"""
Retrieve a lock on the file
https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getlock
"""
item = request.auth.item
lock_service = LockService(item)
return Response(status=200, headers={X_WOPI_LOCK: lock_service.get_lock(default="")})
def _refresh_lock(self, request, pk=None):
"""
Refresh a lock on the file
https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock
"""
lock_value = request.META.get(HTTP_X_WOPI_LOCK)
if not lock_value:
return Response(status=400)
item = request.auth.item
lock_service = LockService(item)
current_lock_value = lock_service.get_lock(default="")
if current_lock_value != lock_value:
return Response(status=409, headers={X_WOPI_LOCK: current_lock_value})
lock_service.refresh_lock()
return Response(status=200)
def _unlock(self, request, pk=None):
"""
Release a lock on the file
https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock
"""
lock_value = request.META.get(HTTP_X_WOPI_LOCK)
if not lock_value:
return Response(status=400)
item = request.auth.item
lock_service = LockService(item)
current_lock_value = lock_service.get_lock(default="")
if current_lock_value != lock_value:
return Response(status=409, headers={X_WOPI_LOCK: current_lock_value})
lock_service.unlock()
return Response(status=200)
def _unlock_and_relock(self, request, pk=None):
"""
Release a lock on the file and lock it again.
https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlockandrelock
"""
old_lock_value = request.META.get(HTTP_X_WOPI_OLD_LOCK)
new_lock_value = request.META.get(HTTP_X_WOPI_LOCK)
if not old_lock_value or not new_lock_value:
return Response(status=400)
item = request.auth.item
lock_service = LockService(item)
current_lock_value = lock_service.get_lock(default="")
if current_lock_value != old_lock_value:
return Response(status=409, headers={X_WOPI_LOCK: current_lock_value})
lock_service.unlock()
lock_service.lock(new_lock_value)
return Response(status=200)
def _rename_file(self, request, pk=None):
"""
Rename the file
"""
item = request.auth.item
abilities = item.get_abilities(request.user)
if not abilities["update"]:
return Response(status=401)
new_filename = request.META.get("HTTP_X_WOPI_REQUESTEDNAME")
if not new_filename:
invalid_filename_error = "No filename provided"
else:
# Convert it to utf-7 to avoid issues with special characters
new_filename = new_filename.encode("ascii").decode("utf-7")
new_filename_with_extension = f"{new_filename}{splitext(item.filename)[1]}"
_, target_extension = splitext(new_filename_with_extension)
invalid_filename_error = None
if any(char in new_filename for char in ILLEGAL_FILENAME_CHARS):
invalid_filename_error = "Invalid filename"
elif settings.RESTRICT_UPLOAD_FILE_TYPE and target_extension.lower() not in {
extension.lower() for extension in settings.FILE_EXTENSIONS_ALLOWED
}:
logger.info(
"rename_file: file extension not allowed %r for filename %r",
target_extension,
new_filename_with_extension,
)
invalid_filename_error = "This file extension is not allowed"
if invalid_filename_error:
return Response(
status=400,
headers={X_WOPI_INVALIDFILENAMERROR: invalid_filename_error},
)
lock_service = LockService(item)
if lock_service.is_locked():
current_lock_value = lock_service.get_lock(default="")
lock_value = request.META.get(HTTP_X_WOPI_LOCK)
if current_lock_value != lock_value:
return Response(status=409, headers={X_WOPI_LOCK: current_lock_value})
parent_path = item.path[:-1]
# Filter on siblings with the desired filename
queryset = (
Item.objects.filter(path__descendants=".".join(parent_path))
.filter(path__depth=item.depth)
.filter(filename=new_filename_with_extension)
.exclude(id=item.id)
)
if queryset.exists():
return Response(
status=400,
headers={X_WOPI_INVALIDFILENAMERROR: "Filename already exists"},
)
head_object = get_item_file_head_object(item)
file_key = item.file_key
item.filename = new_filename_with_extension
item.title = new_filename
# ensure renaming the file in the database and on the storage are done atomically
with transaction.atomic():
item.save(update_fields=["filename", "title", "updated_at"])
# Rename the file in the storage
s3_client = default_storage.connection.meta.client
# Don't catch any s3 error, if failing let the exception raises to sentry
# the transaction will be rolled back
s3_client.copy_object(
Bucket=default_storage.bucket_name,
CopySource={
"Bucket": default_storage.bucket_name,
"Key": file_key,
},
Key=item.file_key,
MetadataDirective="COPY",
)
try:
delete_object_args = {
"Bucket": default_storage.bucket_name,
"Key": file_key,
}
version_id = head_object.get(S3_VERSION_ID)
if version_id:
delete_object_args[S3_VERSION_ID] = version_id
s3_client.delete_object(**delete_object_args)
# pylint: disable=broad-exception-caught
except Exception as e: # noqa
capture_exception(e)
logger.warning("Error deleting old file for item %s in the storage: %s", item.id, e)
if "application/json" in request.META.get("HTTP_ACCEPT", ""):
return Response(
data={"Name": new_filename}, status=200, content_type="application/json"
)
return Response(status=200)