mirror of https://github.com/zulip/zulip.git
upload: Return images for 404/403 responses with image Accept: headers.
If the request's `Accept:` header signals a preference for serving images over text, return an image representing the 404/403 instead of serving a `text/html` response. Fixes: #23739.
This commit is contained in:
parent
451ddf4c84
commit
f9884af114
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
|
@ -326,6 +326,26 @@ class FileUploadTest(UploadSerializeMixin, ZulipTestCase):
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
self.assert_in_response("<p>You are not authorized to view this file.</p>", response)
|
self.assert_in_response("<p>You are not authorized to view this file.</p>", response)
|
||||||
|
|
||||||
|
def test_image_download_unauthed(self) -> None:
|
||||||
|
"""
|
||||||
|
As the above, but with an Accept header that prefers images.
|
||||||
|
"""
|
||||||
|
self.login("hamlet")
|
||||||
|
fp = StringIO("zulip!")
|
||||||
|
fp.name = "zulip.txt"
|
||||||
|
result = self.client_post("/json/user_uploads", {"file": fp})
|
||||||
|
response_dict = self.assert_json_success(result)
|
||||||
|
url = response_dict["uri"]
|
||||||
|
|
||||||
|
self.logout()
|
||||||
|
response = self.client_get(
|
||||||
|
url,
|
||||||
|
# This is what Chrome sends for <img> tags
|
||||||
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertEqual(response.headers["Content-Type"], "image/png")
|
||||||
|
|
||||||
def test_removed_file_download(self) -> None:
|
def test_removed_file_download(self) -> None:
|
||||||
"""
|
"""
|
||||||
Trying to download deleted files should return 404 error
|
Trying to download deleted files should return 404 error
|
||||||
|
@ -354,6 +374,29 @@ class FileUploadTest(UploadSerializeMixin, ZulipTestCase):
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assert_in_response("This file does not exist or has been deleted.", response)
|
self.assert_in_response("This file does not exist or has been deleted.", response)
|
||||||
|
|
||||||
|
def test_non_existing_image_download(self) -> None:
|
||||||
|
"""
|
||||||
|
As the above method, but with an Accept header that prefers images to text
|
||||||
|
"""
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
|
self.login_user(hamlet)
|
||||||
|
response = self.client_get(
|
||||||
|
f"http://{hamlet.realm.host}/user_uploads/{hamlet.realm_id}/ff/gg/abc.png",
|
||||||
|
# This is what Chrome sends for <img> tags
|
||||||
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertEqual(response.headers["Content-Type"], "image/png")
|
||||||
|
|
||||||
|
response = self.client_get(
|
||||||
|
f"http://{hamlet.realm.host}/user_uploads/{hamlet.realm_id}/ff/gg/abc.png",
|
||||||
|
# Ask for something neither image nor text -- you get text as a default
|
||||||
|
headers={"Accept": "audio/*,application/octet-stream"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertEqual(response.headers["Content-Type"], "text/html; charset=utf-8")
|
||||||
|
self.assert_in_response("This file does not exist or has been deleted.", response)
|
||||||
|
|
||||||
def test_attachment_url_without_upload(self) -> None:
|
def test_attachment_url_without_upload(self) -> None:
|
||||||
hamlet = self.example_user("hamlet")
|
hamlet = self.example_user("hamlet")
|
||||||
self.login_user(hamlet)
|
self.login_user(hamlet)
|
||||||
|
|
|
@ -3,7 +3,7 @@ import binascii
|
||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from mimetypes import guess_type
|
from mimetypes import guess_type
|
||||||
from typing import Optional, Union
|
from typing import List, Optional, Union
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -20,13 +20,14 @@ from django.http import (
|
||||||
)
|
)
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.cache import patch_cache_control
|
from django.utils.cache import patch_cache_control, patch_vary_headers
|
||||||
from django.utils.http import content_disposition_header
|
from django.utils.http import content_disposition_header
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from zerver.context_processors import get_valid_realm_from_request
|
from zerver.context_processors import get_valid_realm_from_request
|
||||||
from zerver.lib.exceptions import JsonableError
|
from zerver.lib.exceptions import JsonableError
|
||||||
from zerver.lib.response import json_success
|
from zerver.lib.response import json_success
|
||||||
|
from zerver.lib.storage import static_path
|
||||||
from zerver.lib.upload import (
|
from zerver.lib.upload import (
|
||||||
check_upload_within_quota,
|
check_upload_within_quota,
|
||||||
get_public_upload_root_url,
|
get_public_upload_root_url,
|
||||||
|
@ -167,6 +168,23 @@ def serve_file_url_backend(
|
||||||
return serve_file(request, user_profile, realm_id_str, filename, url_only=True)
|
return serve_file(request, user_profile, realm_id_str, filename, url_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
def preferred_accept(request: HttpRequest, served_types: List[str]) -> Optional[str]:
|
||||||
|
# Returns the first of the served_types which the browser will
|
||||||
|
# accept, based on the browser's stated quality preferences.
|
||||||
|
# Returns None if none of the served_types are accepted by the
|
||||||
|
# browser.
|
||||||
|
accepted_types = sorted(
|
||||||
|
request.accepted_types,
|
||||||
|
key=lambda e: float(e.params.get("q", "1.0")),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for potential_type in accepted_types:
|
||||||
|
for served_type in served_types:
|
||||||
|
if potential_type.match(served_type):
|
||||||
|
return served_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def serve_file(
|
def serve_file(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
maybe_user_profile: Union[UserProfile, AnonymousUser],
|
maybe_user_profile: Union[UserProfile, AnonymousUser],
|
||||||
|
@ -179,10 +197,28 @@ def serve_file(
|
||||||
realm = get_valid_realm_from_request(request)
|
realm = get_valid_realm_from_request(request)
|
||||||
is_authorized = validate_attachment_request(maybe_user_profile, path_id, realm)
|
is_authorized = validate_attachment_request(maybe_user_profile, path_id, realm)
|
||||||
|
|
||||||
|
def serve_image_error(status: int, image_path: str) -> HttpResponseBase:
|
||||||
|
# We cannot use X-Accel-Redirect to offload the serving of
|
||||||
|
# this image to nginx, because it does not preserve the status
|
||||||
|
# code of this response, nor the Vary: header.
|
||||||
|
return FileResponse(open(static_path(image_path), "rb"), status=status) # noqa: SIM115
|
||||||
|
|
||||||
if is_authorized is None:
|
if is_authorized is None:
|
||||||
return HttpResponseNotFound(_("<p>This file does not exist or has been deleted.</p>"))
|
if preferred_accept(request, ["text/html", "image/png"]) == "image/png":
|
||||||
|
response = serve_image_error(404, "images/errors/image-not-exist.png")
|
||||||
|
else:
|
||||||
|
response = HttpResponseNotFound(
|
||||||
|
_("<p>This file does not exist or has been deleted.</p>")
|
||||||
|
)
|
||||||
|
patch_vary_headers(response, ("Accept",))
|
||||||
|
return response
|
||||||
if not is_authorized:
|
if not is_authorized:
|
||||||
return HttpResponseForbidden(_("<p>You are not authorized to view this file.</p>"))
|
if preferred_accept(request, ["text/html", "image/png"]) == "image/png":
|
||||||
|
response = serve_image_error(403, "images/errors/image-no-auth.png")
|
||||||
|
else:
|
||||||
|
response = HttpResponseForbidden(_("<p>You are not authorized to view this file.</p>"))
|
||||||
|
patch_vary_headers(response, ("Accept",))
|
||||||
|
return response
|
||||||
if url_only:
|
if url_only:
|
||||||
url = generate_unauthed_file_access_url(path_id)
|
url = generate_unauthed_file_access_url(path_id)
|
||||||
return json_success(request, data=dict(url=url))
|
return json_success(request, data=dict(url=url))
|
||||||
|
|
Loading…
Reference in New Issue