mirror of https://github.com/zulip/zulip.git
avatars: Serve /user_avatars/ through Django, which offloads to nginx.
Moving `/user_avatars/` to being served partially through Django removes the need for the `no_serve_uploads` nginx reconfiguring when switching between S3 and local backends. This is important because a subsequent commit will move S3 attachments to being served through nginx, which would make `no_serve_uploads` entirely nonsensical of a name. Serve the files through Django, with an offload for the actual image response to an internal nginx route. In development, serve the files directly in Django. We do _not_ mark the contents as immutable for caching purposes, since the path for avatar images is hashed only by their user-id and a salt, and as such are reused when a user's avatar is updated.
This commit is contained in:
parent
f0f4aa66e0
commit
ed6d62a9e7
|
@ -661,15 +661,6 @@ SSL/TLS termination.
|
||||||
Set to the port number if you [prefer to listen on a port other than
|
Set to the port number if you [prefer to listen on a port other than
|
||||||
443](#using-an-alternate-port).
|
443](#using-an-alternate-port).
|
||||||
|
|
||||||
#### `no_serve_uploads`
|
|
||||||
|
|
||||||
To enable the [the S3 uploads backend][s3-uploads], one needs to both
|
|
||||||
configure `settings.py` and set this to true to configure
|
|
||||||
`nginx`. Remove this field to return to the local uploads backend (any
|
|
||||||
non-empty value is currently equivalent to true).
|
|
||||||
|
|
||||||
[s3-uploads]: upload-backends.md#s3-backend-configuration
|
|
||||||
|
|
||||||
#### `queue_workers_multiprocess`
|
#### `queue_workers_multiprocess`
|
||||||
|
|
||||||
By default, Zulip automatically detects whether the system has enough
|
By default, Zulip automatically detects whether the system has enough
|
||||||
|
|
|
@ -45,26 +45,6 @@ backend. To enable this backend, you need to do the following:
|
||||||
For certain AWS regions, you may need to set the `S3_REGION`
|
For certain AWS regions, you may need to set the `S3_REGION`
|
||||||
setting to your default AWS region's code (e.g. `"eu-central-1"`).
|
setting to your default AWS region's code (e.g. `"eu-central-1"`).
|
||||||
|
|
||||||
1. You will need to configure `nginx` to direct requests for uploaded
|
|
||||||
files to the Zulip server (which will then serve a redirect to the
|
|
||||||
appropriate place in S3), rather than serving them directly.
|
|
||||||
|
|
||||||
With Zulip 1.9.0 and newer, you can do this automatically with the
|
|
||||||
following commands run as root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
crudini --set /etc/zulip/zulip.conf application_server no_serve_uploads true
|
|
||||||
/home/zulip/deployments/current/scripts/zulip-puppet-apply
|
|
||||||
```
|
|
||||||
|
|
||||||
(The first line will update your `/etc/zulip/zulip.conf`).
|
|
||||||
|
|
||||||
With older Zulip, you need to edit
|
|
||||||
`/etc/nginx/sites-available/zulip-enterprise` to comment out the
|
|
||||||
`nginx` configuration block for `/user_avatars` and the
|
|
||||||
`include /etc/nginx/zulip-include/uploads.route` line and then
|
|
||||||
reload the `nginx` service (`service nginx reload`).
|
|
||||||
|
|
||||||
1. Finally, restart the Zulip server so that your settings changes
|
1. Finally, restart the Zulip server so that your settings changes
|
||||||
take effect
|
take effect
|
||||||
(`/home/zulip/deployments/current/scripts/restart-server`).
|
(`/home/zulip/deployments/current/scripts/restart-server`).
|
||||||
|
|
|
@ -6,7 +6,8 @@ location /internal/uploads {
|
||||||
alias /home/zulip/uploads/files;
|
alias /home/zulip/uploads/files;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /user_avatars {
|
location /internal/user_avatars {
|
||||||
|
internal;
|
||||||
include /etc/nginx/zulip-include/headers;
|
include /etc/nginx/zulip-include/headers;
|
||||||
add_header Content-Security-Policy "default-src 'none' img-src 'self'";
|
add_header Content-Security-Policy "default-src 'none' img-src 'self'";
|
||||||
include /etc/nginx/zulip-include/uploads.types;
|
include /etc/nginx/zulip-include/uploads.types;
|
|
@ -69,37 +69,23 @@ class zulip::app_frontend_base {
|
||||||
notify => Service['nginx'],
|
notify => Service['nginx'],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Configuration for how uploaded files and profile pictures are
|
file { '/etc/nginx/zulip-include/app.d/uploads-internal.conf':
|
||||||
# served. The default is to serve uploads using using the `nginx`
|
ensure => file,
|
||||||
# `internal` feature via X-Accel-Redirect, which basically does an
|
require => Package[$zulip::common::nginx],
|
||||||
# internal redirect and returns the file content from nginx in an
|
owner => 'root',
|
||||||
# HttpResponse that would otherwise have been a redirect. Profile
|
group => 'root',
|
||||||
# pictures are served directly off disk.
|
mode => '0644',
|
||||||
#
|
notify => Service['nginx'],
|
||||||
# For installations using S3 to serve uploaded files, we want Django
|
source => 'puppet:///modules/zulip/nginx/zulip-include-frontend/uploads-internal.conf',
|
||||||
# to handle the /internal/uploads and /user_avatars routes, so that it
|
|
||||||
# can serve a redirect (after doing authentication, for uploads).
|
|
||||||
$no_serve_uploads = zulipconf('application_server', 'no_serve_uploads', false)
|
|
||||||
if $no_serve_uploads {
|
|
||||||
file { '/etc/nginx/zulip-include/app.d/uploads-internal.conf':
|
|
||||||
ensure => absent,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
file { '/etc/nginx/zulip-include/app.d/uploads-internal.conf':
|
|
||||||
ensure => file,
|
|
||||||
require => Package[$zulip::common::nginx],
|
|
||||||
owner => 'root',
|
|
||||||
group => 'root',
|
|
||||||
mode => '0644',
|
|
||||||
notify => Service['nginx'],
|
|
||||||
source => 'puppet:///modules/zulip/nginx/zulip-include-maybe/uploads-internal.conf',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# TODO/compatibility: Removed 2021-04 in Zulip 4.0; these lines can
|
file { [
|
||||||
# be removed once one must have upgraded through Zulip 4.0 or higher
|
# TODO/compatibility: Removed 2021-04 in Zulip 4.0; these lines can
|
||||||
# to get to the next release.
|
# be removed once one must have upgraded through Zulip 4.0 or higher
|
||||||
file { ['/etc/nginx/zulip-include/uploads.route', '/etc/nginx/zulip-include/app.d/thumbor.conf']:
|
# to get to the next release.
|
||||||
|
'/etc/nginx/zulip-include/uploads.route',
|
||||||
|
'/etc/nginx/zulip-include/app.d/thumbor.conf',
|
||||||
|
]:
|
||||||
ensure => absent,
|
ensure => absent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -208,14 +208,6 @@ def init_worker(
|
||||||
create_test_databases(_worker_id)
|
create_test_databases(_worker_id)
|
||||||
initialize_worker_path(_worker_id)
|
initialize_worker_path(_worker_id)
|
||||||
|
|
||||||
# We manually update the upload directory path in the URL regex.
|
|
||||||
from zproject.dev_urls import avatars_url
|
|
||||||
|
|
||||||
assert settings.LOCAL_UPLOADS_DIR is not None
|
|
||||||
assert settings.LOCAL_AVATARS_DIR is not None
|
|
||||||
assert avatars_url.default_args is not None
|
|
||||||
avatars_url.default_args["document_root"] = settings.LOCAL_AVATARS_DIR
|
|
||||||
|
|
||||||
|
|
||||||
class ParallelTestSuite(django_runner.ParallelTestSuite):
|
class ParallelTestSuite(django_runner.ParallelTestSuite):
|
||||||
run_subsuite = run_subsuite
|
run_subsuite = run_subsuite
|
||||||
|
|
|
@ -8,6 +8,7 @@ import urllib
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import botocore.exceptions
|
import botocore.exceptions
|
||||||
import orjson
|
import orjson
|
||||||
|
@ -1853,6 +1854,39 @@ class LocalStorageTest(UploadSerializeMixin, ZulipTestCase):
|
||||||
path_id = re.sub("/user_uploads/", "", response_dict["uri"])
|
path_id = re.sub("/user_uploads/", "", response_dict["uri"])
|
||||||
self.assertTrue(delete_message_image(path_id))
|
self.assertTrue(delete_message_image(path_id))
|
||||||
|
|
||||||
|
def test_avatar_url_local(self) -> None:
|
||||||
|
self.login("hamlet")
|
||||||
|
with get_test_image_file("img.png") as image_file:
|
||||||
|
result = self.client_post("/json/users/me/avatar", {"file": image_file})
|
||||||
|
|
||||||
|
response_dict = self.assert_json_success(result)
|
||||||
|
self.assertIn("avatar_url", response_dict)
|
||||||
|
base = "/user_avatars/"
|
||||||
|
url = self.assert_json_success(result)["avatar_url"]
|
||||||
|
self.assertEqual(base, url[: len(base)])
|
||||||
|
|
||||||
|
# That URL is accessible when logged out
|
||||||
|
self.logout()
|
||||||
|
result = self.client_get(url)
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
|
||||||
|
# We get a resized avatar from it
|
||||||
|
image_data = read_test_image_file("img.png")
|
||||||
|
resized_avatar = resize_avatar(image_data)
|
||||||
|
assert isinstance(result, StreamingHttpResponse)
|
||||||
|
self.assertEqual(resized_avatar, b"".join(result.streaming_content))
|
||||||
|
|
||||||
|
with self.settings(DEVELOPMENT=False):
|
||||||
|
# In production, this is an X-Accel-Redirect to the
|
||||||
|
# on-disk content, which nginx serves
|
||||||
|
result = self.client_get(url)
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
internal_redirect_path = urlparse(url).path.replace(
|
||||||
|
"/user_avatars/", "/internal/user_avatars/"
|
||||||
|
)
|
||||||
|
self.assertEqual(result["X-Accel-Redirect"], internal_redirect_path)
|
||||||
|
self.assertEqual(b"", result.content)
|
||||||
|
|
||||||
def test_ensure_avatar_image_local(self) -> None:
|
def test_ensure_avatar_image_local(self) -> None:
|
||||||
user_profile = self.example_user("hamlet")
|
user_profile = self.example_user("hamlet")
|
||||||
file_path = user_avatar_path(user_profile)
|
file_path = user_avatar_path(user_profile)
|
||||||
|
@ -2099,6 +2133,25 @@ class S3Test(ZulipTestCase):
|
||||||
body = f"First message ...[zulip.txt](http://{hamlet.realm.host}" + uri + ")"
|
body = f"First message ...[zulip.txt](http://{hamlet.realm.host}" + uri + ")"
|
||||||
self.send_stream_message(hamlet, "Denmark", body, "test")
|
self.send_stream_message(hamlet, "Denmark", body, "test")
|
||||||
|
|
||||||
|
@use_s3_backend
|
||||||
|
def test_user_avatars_redirect(self) -> None:
|
||||||
|
create_s3_buckets(settings.S3_AVATAR_BUCKET)[0]
|
||||||
|
self.login("hamlet")
|
||||||
|
with get_test_image_file("img.png") as image_file:
|
||||||
|
result = self.client_post("/json/users/me/avatar", {"file": image_file})
|
||||||
|
|
||||||
|
response_dict = self.assert_json_success(result)
|
||||||
|
self.assertIn("avatar_url", response_dict)
|
||||||
|
base = f"https://{settings.S3_AVATAR_BUCKET}.s3.amazonaws.com/"
|
||||||
|
url = self.assert_json_success(result)["avatar_url"]
|
||||||
|
self.assertEqual(base, url[: len(base)])
|
||||||
|
|
||||||
|
# Try hitting the equivalent `/user_avatars` endpoint
|
||||||
|
wrong_url = "/user_avatars/" + url[len(base) :]
|
||||||
|
result = self.client_get(wrong_url)
|
||||||
|
self.assertEqual(result.status_code, 301)
|
||||||
|
self.assertEqual(result["Location"], url)
|
||||||
|
|
||||||
@use_s3_backend
|
@use_s3_backend
|
||||||
def test_upload_avatar_image(self) -> None:
|
def test_upload_avatar_image(self) -> None:
|
||||||
bucket = create_s3_buckets(settings.S3_AVATAR_BUCKET)[0]
|
bucket = create_s3_buckets(settings.S3_AVATAR_BUCKET)[0]
|
||||||
|
|
|
@ -21,7 +21,11 @@ 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.upload import check_upload_within_quota, upload_message_image_from_request
|
from zerver.lib.upload import (
|
||||||
|
check_upload_within_quota,
|
||||||
|
get_public_upload_root_url,
|
||||||
|
upload_message_image_from_request,
|
||||||
|
)
|
||||||
from zerver.lib.upload.base import INLINE_MIME_TYPES
|
from zerver.lib.upload.base import INLINE_MIME_TYPES
|
||||||
from zerver.lib.upload.local import (
|
from zerver.lib.upload.local import (
|
||||||
assert_is_local_storage_path,
|
assert_is_local_storage_path,
|
||||||
|
@ -181,6 +185,39 @@ def serve_local_file_unauthed(request: HttpRequest, token: str, filename: str) -
|
||||||
return serve_local(request, path_id, url_only=False)
|
return serve_local(request, path_id, url_only=False)
|
||||||
|
|
||||||
|
|
||||||
|
def serve_local_avatar_unauthed(request: HttpRequest, path: str) -> HttpResponseBase:
|
||||||
|
"""Serves avatar images off disk, via nginx (or directly in dev), with no auth.
|
||||||
|
|
||||||
|
This is done unauthed because these need to be accessed from HTML
|
||||||
|
emails, where the client does not have any auth. We rely on the
|
||||||
|
URL being generated using the AVATAR_SALT secret.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if settings.LOCAL_AVATARS_DIR is None:
|
||||||
|
# We do not expect clients to hit this URL when using the S3
|
||||||
|
# backend; however, there is no reason to not serve the
|
||||||
|
# redirect to S3 where the content lives.
|
||||||
|
return redirect(
|
||||||
|
get_public_upload_root_url() + path + "?" + request.GET.urlencode(), permanent=True
|
||||||
|
)
|
||||||
|
|
||||||
|
local_path = os.path.join(settings.LOCAL_AVATARS_DIR, path)
|
||||||
|
assert_is_local_storage_path("avatars", local_path)
|
||||||
|
if not os.path.isfile(local_path):
|
||||||
|
return HttpResponseNotFound("<p>File not found</p>")
|
||||||
|
|
||||||
|
if settings.DEVELOPMENT:
|
||||||
|
response: HttpResponseBase = FileResponse(open(local_path, "rb"))
|
||||||
|
else:
|
||||||
|
response = internal_nginx_redirect(quote(f"/internal/user_avatars/{path}"))
|
||||||
|
|
||||||
|
# We do _not_ mark the contents as immutable for caching purposes,
|
||||||
|
# since the path for avatar images is hashed only by their user-id
|
||||||
|
# and a salt, and as such are reused when a user's avatar is
|
||||||
|
# updated.
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def upload_file_backend(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
|
def upload_file_backend(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
|
||||||
if len(request.FILES) == 0:
|
if len(request.FILES) == 0:
|
||||||
raise JsonableError(_("You must specify a file to upload"))
|
raise JsonableError(_("You must specify a file to upload"))
|
||||||
|
|
|
@ -123,12 +123,3 @@ i18n_urls = [
|
||||||
path("confirmation_key/", confirmation_key),
|
path("confirmation_key/", confirmation_key),
|
||||||
]
|
]
|
||||||
urls += i18n_urls
|
urls += i18n_urls
|
||||||
|
|
||||||
# On a production instance, these files would be served by nginx.
|
|
||||||
if settings.LOCAL_AVATARS_DIR is not None:
|
|
||||||
avatars_url = path(
|
|
||||||
"user_avatars/<path:path>",
|
|
||||||
serve,
|
|
||||||
{"document_root": os.path.join(settings.LOCAL_AVATARS_DIR)},
|
|
||||||
)
|
|
||||||
urls += [avatars_url]
|
|
||||||
|
|
|
@ -166,6 +166,7 @@ from zerver.views.upload import (
|
||||||
serve_file_backend,
|
serve_file_backend,
|
||||||
serve_file_download_backend,
|
serve_file_download_backend,
|
||||||
serve_file_url_backend,
|
serve_file_url_backend,
|
||||||
|
serve_local_avatar_unauthed,
|
||||||
serve_local_file_unauthed,
|
serve_local_file_unauthed,
|
||||||
upload_file_backend,
|
upload_file_backend,
|
||||||
)
|
)
|
||||||
|
@ -668,6 +669,11 @@ urls += [
|
||||||
{"override_api_url_scheme", "allow_anonymous_user_web"},
|
{"override_api_url_scheme", "allow_anonymous_user_web"},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"user_avatars/<path:path>",
|
||||||
|
serve_local_avatar_unauthed,
|
||||||
|
name="local_avatar_unauthed",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# This URL serves as a way to receive CSP violation reports from the users.
|
# This URL serves as a way to receive CSP violation reports from the users.
|
||||||
|
|
Loading…
Reference in New Issue