settings: Add support for customizing the top-left logo.

This adds a new realm_logo field, which is a horizontal-format logo to
be displayed in the top-left corner of the webapp, and any other
places where we might want a wide-format branding of the organization.

Tweaked significantly by tabbott to rebase, fix styling, etc.

Fixing the styling of this feature's loading indicator caused me to
notice the loading indicator for the realm_icon feature was also ugly,
so I fixed that too.

Fixes #7995.
This commit is contained in:
Joshua Pan 2018-08-15 16:26:55 -07:00 committed by Tim Abbott
parent 34f5218a0d
commit ad1df0ebeb
27 changed files with 585 additions and 8 deletions

View File

@ -117,6 +117,7 @@
"pygments_data": false, "pygments_data": false,
"reactions": false, "reactions": false,
"realm_icon": false, "realm_icon": false,
"realm_logo": false,
"recent_senders": false, "recent_senders": false,
"reload": false, "reload": false,
"reload_state": false, "reload_state": false,

View File

@ -55,6 +55,10 @@ const _ui_report = {
}, },
}; };
const _realm_logo = {
build_realm_logo_widget: noop,
};
set_global('channel', _channel); set_global('channel', _channel);
set_global('csrf_token', 'token-stub'); set_global('csrf_token', 'token-stub');
set_global('FormData', _FormData); set_global('FormData', _FormData);
@ -63,6 +67,7 @@ set_global('loading', _loading);
set_global('overlays', _overlays); set_global('overlays', _overlays);
set_global('page_params', _page_params); set_global('page_params', _page_params);
set_global('realm_icon', _realm_icon); set_global('realm_icon', _realm_icon);
set_global('realm_logo', _realm_logo);
set_global('templates', _templates); set_global('templates', _templates);
set_global('ui_report', _ui_report); set_global('ui_report', _ui_report);

View File

@ -49,6 +49,8 @@ exports.build_page = function () {
is_guest: page_params.is_guest, is_guest: page_params.is_guest,
realm_icon_source: page_params.realm_icon_source, realm_icon_source: page_params.realm_icon_source,
realm_icon_url: page_params.realm_icon_url, realm_icon_url: page_params.realm_icon_url,
realm_logo_source: page_params.realm_logo_source,
realm_logo_url: page_params.realm_logo_url,
realm_mandatory_topics: page_params.realm_mandatory_topics, realm_mandatory_topics: page_params.realm_mandatory_topics,
realm_send_welcome_emails: page_params.realm_send_welcome_emails, realm_send_welcome_emails: page_params.realm_send_welcome_emails,
realm_default_twenty_four_hour_time: page_params.realm_default_twenty_four_hour_time, realm_default_twenty_four_hour_time: page_params.realm_default_twenty_four_hour_time,

View File

@ -166,6 +166,7 @@ import "js/templates.js";
import "js/upload_widget.js"; import "js/upload_widget.js";
import "js/avatar.js"; import "js/avatar.js";
import "js/realm_icon.js"; import "js/realm_icon.js";
import "js/realm_logo.js";
import 'js/reminder.js'; import 'js/reminder.js';
import 'js/confirm_dialog.js'; import 'js/confirm_dialog.js';
import "js/settings_account.js"; import "js/settings_account.js";

53
static/js/realm_logo.js Normal file
View File

@ -0,0 +1,53 @@
/* eslint indent: "off" */
var realm_logo = (function () {
var exports = {};
exports.build_realm_logo_widget = function (upload_function) {
var get_file_input = function () {
return $('#realm_logo_file_input').expectOne();
};
if (page_params.realm_logo_source === 'D') {
$("#realm_logo_delete_button").hide();
} else {
$("#realm_logo_delete_button").show();
}
$("#realm_logo_delete_button").on('click', function (e) {
e.preventDefault();
e.stopPropagation();
channel.del({
url: '/json/realm/logo',
});
});
return upload_widget.build_direct_upload_widget(
get_file_input,
$("#realm_logo_file_input_error").expectOne(),
$("#realm_logo_upload_button").expectOne(),
upload_function,
page_params.max_logo_file_size
);
};
exports.rerender = function () {
$("#realm-settings-logo").attr("src", page_params.realm_logo_url);
$("#realm-logo").attr("src", page_params.realm_logo_url);
if (page_params.realm_logo_source === 'U') {
$("#realm_logo_delete_button").show();
} else {
$("#realm_logo_delete_button").hide();
// Need to clear input because of a small edge case
// where you try to upload the same image you just deleted.
var file_input = $("#realm_logo_file_input");
file_input.val('');
}
};
return exports;
}());
if (typeof module !== 'undefined') {
module.exports = realm_logo;
}
window.realm_logo = realm_logo;

View File

@ -160,6 +160,10 @@ exports.dispatch_normal_event = function dispatch_normal_event(event) {
if (electron_bridge !== undefined) { if (electron_bridge !== undefined) {
electron_bridge.send_event('realm_icon_url', event.data.icon_url); electron_bridge.send_event('realm_icon_url', event.data.icon_url);
} }
} else if (event.op === 'update_dict' && event.property === 'logo') {
page_params.realm_logo_url = event.data.logo_url;
page_params.realm_logo_source = event.data.logo_source;
realm_logo.rerender();
} else if (event.op === 'deactivated') { } else if (event.op === 'deactivated') {
window.location.href = "/accounts/deactivated/"; window.location.href = "/accounts/deactivated/";
} }

View File

@ -1054,8 +1054,11 @@ exports.build_page = function () {
form_data.append('file-' + i, file); form_data.append('file-' + i, file);
}); });
var error_field = $("#realm_icon_file_input_error");
error_field.hide();
var spinner = $("#upload_icon_spinner").expectOne(); var spinner = $("#upload_icon_spinner").expectOne();
loading.make_indicator(spinner, {text: i18n.t("Uploading icon.")}); loading.make_indicator(spinner, {text: i18n.t("Uploading icon.")});
$("#upload_icon_button_text").expectOne().hide();
channel.post({ channel.post({
url: '/json/realm/icon', url: '/json/realm/icon',
@ -1065,12 +1068,52 @@ exports.build_page = function () {
contentType: false, contentType: false,
success: function () { success: function () {
loading.destroy_indicator($("#upload_icon_spinner")); loading.destroy_indicator($("#upload_icon_spinner"));
$("#upload_icon_button_text").expectOne().show();
},
error: function (xhr) {
loading.destroy_indicator($("#upload_logo_spinner"));
$("#upload_logo_button_text").expectOne().show();
ui_report.error("", xhr, error_field);
}, },
}); });
} }
realm_icon.build_realm_icon_widget(upload_realm_icon); realm_icon.build_realm_icon_widget(upload_realm_icon);
function upload_realm_logo(file_input) {
var form_data = new FormData();
form_data.append('csrfmiddlewaretoken', csrf_token);
jQuery.each(file_input[0].files, function (i, file) {
form_data.append('file-' + i, file);
});
var error_field = $("#realm_logo_file_input_error");
error_field.hide();
var spinner = $("#upload_logo_spinner").expectOne();
loading.make_indicator(spinner, {text: i18n.t("Uploading logo.")});
$("#upload_logo_button_text").expectOne().hide();
channel.post({
url: '/json/realm/logo',
data: form_data,
cache: false,
processData: false,
contentType: false,
success: function () {
loading.destroy_indicator($("#upload_logo_spinner"));
$("#upload_logo_button_text").expectOne().show();
},
error: function (xhr) {
loading.destroy_indicator($("#upload_logo_spinner"));
$("#upload_logo_button_text").expectOne().show();
ui_report.error("", xhr, error_field);
},
});
}
realm_logo.build_realm_logo_widget(upload_realm_logo);
$('#deactivate_realm_button').on('click', function (e) { $('#deactivate_realm_button').on('click', function (e) {
if (!overlays.is_modal_open()) { if (!overlays.is_modal_open()) {
e.preventDefault(); e.preventDefault();

View File

@ -81,10 +81,15 @@ label {
} }
.user-avatar-section, .user-avatar-section,
.realm-logo-section,
.realm-icon-section { .realm-icon-section {
position: relative; position: relative;
} }
.realm-logo-block {
margin-bottom: 10px;
}
.user-avatar-section { .user-avatar-section {
float: right; float: right;
} }
@ -101,8 +106,9 @@ label {
} }
.user-avatar-section .inline-block, .user-avatar-section .inline-block,
.realm-logo-section .inline-block,
.realm-icon-section .inline-block { .realm-icon-section .inline-block {
margin: 10px 20px 0px 0px; margin: 0px 20px 0px 0px;
vertical-align: top; vertical-align: top;
border-radius: 4px; border-radius: 4px;
@ -458,6 +464,11 @@ input[type=checkbox] + .inline-block {
margin-top: 0px; margin-top: 0px;
} }
.realm-logo-section {
margin-top: 10px;
margin-bottom: 20px;
}
.realm-icon-section { .realm-icon-section {
margin-bottom: 20px; margin-bottom: 20px;
} }
@ -937,6 +948,7 @@ input[type=checkbox].inline-block {
} }
#upload_avatar_spinner, #upload_avatar_spinner,
#upload_logo_spinner,
#upload_icon_spinner { #upload_icon_spinner {
font-size: 14px; font-size: 14px;
margin: auto; margin: auto;
@ -1071,6 +1083,15 @@ input[type=checkbox].inline-block {
height: 100px; height: 100px;
} }
#realm-settings-logo {
border-radius: 5px;
box-shadow: 0px 0px 10px hsla(0, 0%, 0%, 0.2);
/* We allow actual images up to 800x100 in the main display, but the
settings UI looks bad beyond ~730px, so we limit the width here */
height: 100px;
max-width: 720px;
}
.invite-user-link i { .invite-user-link i {
text-decoration: none; text-decoration: none;
margin-right: 5px; margin-right: 5px;
@ -1636,6 +1657,13 @@ input[type=text]#settings_search {
margin: 0 0 0 5px; margin: 0 0 0 5px;
} }
@media (max-width: 1023px) {
#realm-settings-logo {
max-width: 600px;
height: 75px;
}
}
@media (max-width: 953px) { @media (max-width: 953px) {
.user-avatar-section, .user-avatar-section,
.realm-icon-section { .realm-icon-section {
@ -1655,6 +1683,11 @@ input[type=text]#settings_search {
.subsection-failed-status p { .subsection-failed-status p {
margin: 5px 0 0 0; margin: 5px 0 0 0;
} }
#realm-settings-logo {
max-width: 400px;
height: 50px;
}
} }
@media (max-width: 786px) { @media (max-width: 786px) {

View File

@ -2157,6 +2157,27 @@ div.floating_recipient {
display: inline-block; display: inline-block;
} }
.settings-section .realm-icon-section .loading_indicator_text,
.settings-section .realm-logo-section .loading_indicator_text {
font-size: 14px;
font-weight: 400;
vertical-align: middle;
line-height: 20px;
display: inline-block;
float: none;
margin-top: 0px;
margin-left: -12px;
}
.settings-section .realm-logo-section .loading_indicator_spinner,
.settings-section .realm-icon-section .loading_indicator_spinner {
width: 10%;
height: 16px;
margin-top: 2px;
vertical-align: middle;
display: inline-block;
}
.settings-section #default_language { .settings-section #default_language {
text-decoration: none; text-decoration: none;
} }

View File

@ -23,23 +23,47 @@
</div> </div>
<h3>{{t "Organization avatar" }}</h3> <h3>{{t "Organization avatar" }}</h3>
<p>{{t "A square logo used to brand your Zulip organization." }}</p>
<div class="realm-icon-section"> <div class="realm-icon-section">
<div class="inline-block"> <div class="inline-block">
<img id="realm-settings-icon" src="{{ realm_icon_url }}"/> <img id="realm-settings-icon" src="{{ realm_icon_url }}"/>
<input type="file" name="realm_icon_file_input" class="notvisible" <input type="file" name="realm_icon_file_input" class="notvisible"
id="realm_icon_file_input" value="{{t 'Upload icon' }}"/> id="realm_icon_file_input" value="{{t 'Upload icon' }}"/>
<div id="upload_icon_spinner"></div>
</div> </div>
<div class="inline-block avatar-controls"> <div class="inline-block avatar-controls">
<div id="realm_icon_file_input_error" class="alert text-error"></div>
<button class="button rounded sea-green w-200 block input-size" <button class="button rounded sea-green w-200 block input-size"
id="realm_icon_upload_button">{{t 'Upload new icon' }}</button> id="realm_icon_upload_button">
<div id="realm_icon_file_input_error" class="text-error"></div> <span id="upload_icon_button_text">{{t 'Upload new icon' }}</span>
<span id="upload_icon_spinner"></span>
</button>
<button class="button rounded btn-danger w-200 m-t-10 block input-size" <button class="button rounded btn-danger w-200 m-t-10 block input-size"
id="realm_icon_delete_button">{{t 'Delete icon' }}</button> id="realm_icon_delete_button">{{t 'Delete icon' }}</button>
</div> </div>
</div> </div>
<h3>{{t "Organization logo" }}</h3>
<p>{{t "A wide image, replacing the Zulip logo in the upper left corner of the Zulip apps." }}</p>
<div class="realm-logo-section">
<div class="block realm-logo-block">
<img id="realm-settings-logo" src="{{ realm_logo_url }}"/>
<input type="file" name="realm_logo_file_input" class="notvisible"
id="realm_logo_file_input" value="{{t 'Upload logo' }}"/>
</div>
<div class="block avatar-controls">
<div id="realm_logo_file_input_error" class="alert text-error"></div>
<button class="button rounded sea-green w-200 block input-size"
id="realm_logo_upload_button">
<span id="upload_logo_button_text">{{t 'Upload new logo' }}</span>
<span id="upload_logo_spinner"></span>
</button>
<button class="button rounded btn-danger w-200 m-t-10 block input-size"
id="realm_logo_delete_button">{{t 'Delete logo' }}</button>
</div>
</div>
<h3 class="light">{{t "Deactivate organization" }}</h3> <h3 class="light">{{t "Deactivate organization" }}</h3>
<div class="deactivate-realm-section"> <div class="deactivate-realm-section">
<div class="input-group"> <div class="input-group">

View File

@ -50,7 +50,12 @@ curl {{ api_url }}/v1/server_settings \
enabled with a username and password combination. enabled with a username and password combination.
* `realm_uri`: the organization's canonical URI. * `realm_uri`: the organization's canonical URI.
* `realm_name`: the organization's name (for display purposes). * `realm_name`: the organization's name (for display purposes).
* `realm_icon`: the URI of the organization's icon (usually a logo). * `realm_icon`: the URI of the organization's logo as a square image,
used for identifying the organization in small locations in the
mobile and desktop apps.
* `realm_logo`: the URI of the organization's logo as a horizontal
format image (displayed in the top-left corner of the logged-in
webapp).
* `realm_description`: HTML description of the organization, as configured by * `realm_description`: HTML description of the organization, as configured by
the [organization profile](/help/create-your-organization-profile). the [organization profile](/help/create-your-organization-profile).

View File

@ -31,7 +31,7 @@
<nav class="header-main rightside-userlist" id="top_navbar"> <nav class="header-main rightside-userlist" id="top_navbar">
<div class="column-left"> <div class="column-left">
<a class="brand no-style" href="#"> <a class="brand no-style" href="#">
<img src="/static/images/logo/zulip-org-logo.png" alt="" class="nav-logo no-drag"> <img id="realm-logo" src="{{ realm_logo }}" alt="" class="nav-logo no-drag">
</a> </a>
</div> </div>
<div class="column-middle" id="navbar-middle"> <div class="column-middle" id="navbar-middle">

View File

@ -19,6 +19,7 @@ from zerver.lib.bugdown import convert as bugdown_convert
from zerver.lib.send_email import FromAddress from zerver.lib.send_email import FromAddress
from zerver.lib.subdomains import get_subdomain from zerver.lib.subdomains import get_subdomain
from zerver.lib.realm_icon import get_realm_icon_url from zerver.lib.realm_icon import get_realm_icon_url
from zerver.lib.realm_logo import get_realm_logo_url
from version import ZULIP_VERSION, LATEST_RELEASE_VERSION, \ from version import ZULIP_VERSION, LATEST_RELEASE_VERSION, \
LATEST_RELEASE_ANNOUNCEMENT, LATEST_MAJOR_VERSION LATEST_RELEASE_ANNOUNCEMENT, LATEST_MAJOR_VERSION
@ -55,6 +56,7 @@ def zulip_default_context(request: HttpRequest) -> Dict[str, Any]:
realm_uri = settings.ROOT_DOMAIN_URI realm_uri = settings.ROOT_DOMAIN_URI
realm_name = None realm_name = None
realm_icon = None realm_icon = None
realm_logo = None
realm_description = None realm_description = None
realm_invite_required = False realm_invite_required = False
realm_plan_type = 0 realm_plan_type = 0
@ -62,6 +64,7 @@ def zulip_default_context(request: HttpRequest) -> Dict[str, Any]:
realm_uri = realm.uri realm_uri = realm.uri
realm_name = realm.name realm_name = realm.name
realm_icon = get_realm_icon_url(realm) realm_icon = get_realm_icon_url(realm)
realm_logo = get_realm_logo_url(realm)
realm_description_raw = realm.description or "The coolest place in the universe." realm_description_raw = realm.description or "The coolest place in the universe."
realm_description = bugdown_convert(realm_description_raw, message_realm=realm) realm_description = bugdown_convert(realm_description_raw, message_realm=realm)
realm_invite_required = realm.invite_required realm_invite_required = realm.invite_required
@ -116,6 +119,7 @@ def zulip_default_context(request: HttpRequest) -> Dict[str, Any]:
'realm_uri': realm_uri, 'realm_uri': realm_uri,
'realm_name': realm_name, 'realm_name': realm_name,
'realm_icon': realm_icon, 'realm_icon': realm_icon,
'realm_logo': realm_logo,
'realm_description': realm_description, 'realm_description': realm_description,
'realm_plan_type': realm_plan_type, 'realm_plan_type': realm_plan_type,
'root_domain_uri': settings.ROOT_DOMAIN_URI, 'root_domain_uri': settings.ROOT_DOMAIN_URI,

View File

@ -43,6 +43,7 @@ from zerver.lib.message import (
update_first_visible_message_id, update_first_visible_message_id,
) )
from zerver.lib.realm_icon import realm_icon_url from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.realm_logo import realm_logo_url
from zerver.lib.retention import move_messages_to_archive from zerver.lib.retention import move_messages_to_archive
from zerver.lib.send_email import send_email, FromAddress, send_email_to_admins from zerver.lib.send_email import send_email, FromAddress, send_email_to_admins
from zerver.lib.stream_subscription import ( from zerver.lib.stream_subscription import (
@ -3086,6 +3087,22 @@ def do_change_icon_source(realm: Realm, icon_source: str, log: bool=True) -> Non
icon_url=realm_icon_url(realm))), icon_url=realm_icon_url(realm))),
active_user_ids(realm.id)) active_user_ids(realm.id))
def do_change_logo_source(realm: Realm, logo_source: str) -> None:
realm.logo_source = logo_source
realm.logo_version += 1
realm.save(update_fields=["logo_source", "logo_version"])
RealmAuditLog.objects.create(event_type=RealmAuditLog.REALM_LOGO_CHANGED,
realm=realm, event_time=timezone_now())
send_event(realm,
dict(type='realm',
op='update_dict',
property="logo",
data=dict(logo_source=realm.logo_source,
logo_url=realm_logo_url(realm))),
active_user_ids(realm.id))
def do_change_plan_type(realm: Realm, plan_type: int) -> None: def do_change_plan_type(realm: Realm, plan_type: int) -> None:
old_value = realm.plan_type old_value = realm.plan_type
realm.plan_type = plan_type realm.plan_type = plan_type

View File

@ -30,6 +30,7 @@ from zerver.lib.narrow import check_supported_events_narrow_filter
from zerver.lib.push_notifications import push_notifications_enabled from zerver.lib.push_notifications import push_notifications_enabled
from zerver.lib.soft_deactivation import maybe_catch_up_soft_deactivated_user from zerver.lib.soft_deactivation import maybe_catch_up_soft_deactivated_user
from zerver.lib.realm_icon import realm_icon_url from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.realm_logo import realm_logo_url
from zerver.lib.request import JsonableError from zerver.lib.request import JsonableError
from zerver.lib.topic import TOPIC_NAME from zerver.lib.topic import TOPIC_NAME
from zerver.lib.topic_mutes import get_topic_mutes from zerver.lib.topic_mutes import get_topic_mutes
@ -172,6 +173,9 @@ def fetch_initial_state_data(user_profile: UserProfile,
state['realm_icon_url'] = realm_icon_url(realm) state['realm_icon_url'] = realm_icon_url(realm)
state['realm_icon_source'] = realm.icon_source state['realm_icon_source'] = realm.icon_source
state['max_icon_file_size'] = settings.MAX_ICON_FILE_SIZE state['max_icon_file_size'] = settings.MAX_ICON_FILE_SIZE
state['realm_logo_url'] = realm_logo_url(realm)
state['realm_logo_source'] = realm.logo_source
state['max_logo_file_size'] = settings.MAX_LOGO_FILE_SIZE
state['realm_bot_domain'] = realm.get_bot_domain() state['realm_bot_domain'] = realm.get_bot_domain()
state['realm_uri'] = realm.uri state['realm_uri'] = realm.uri
state['realm_available_video_chat_providers'] = realm.VIDEO_CHAT_PROVIDERS state['realm_available_video_chat_providers'] = realm.VIDEO_CHAT_PROVIDERS

13
zerver/lib/realm_logo.py Normal file
View File

@ -0,0 +1,13 @@
from django.conf import settings
from zerver.lib.upload import upload_backend
from zerver.models import Realm
def realm_logo_url(realm: Realm) -> str:
return get_realm_logo_url(realm)
def get_realm_logo_url(realm: Realm) -> str:
if realm.logo_source == 'U':
return upload_backend.get_realm_logo_url(realm.id, realm.logo_version)
else:
return settings.DEFAULT_LOGO_URI+'?version=0'

View File

@ -116,6 +116,19 @@ def resize_avatar(image_data: bytes, size: int=DEFAULT_AVATAR_SIZE) -> bytes:
im.save(out, format='png') im.save(out, format='png')
return out.getvalue() return out.getvalue()
def resize_logo(image_data: bytes) -> bytes:
try:
im = Image.open(io.BytesIO(image_data))
im = exif_rotate(im)
im.thumbnail((8*DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), Image.ANTIALIAS)
except IOError:
raise BadImageError("Could not decode image; did you upload an image file?")
out = io.BytesIO()
if im.mode == 'CMYK':
im = im.convert('RGB')
im.save(out, format='png')
return out.getvalue()
def resize_gif(im: GifImageFile, size: int=DEFAULT_EMOJI_SIZE) -> bytes: def resize_gif(im: GifImageFile, size: int=DEFAULT_EMOJI_SIZE) -> bytes:
frames = [] frames = []
@ -187,6 +200,12 @@ class ZulipUploadBackend:
def get_realm_icon_url(self, realm_id: int, version: int) -> str: def get_realm_icon_url(self, realm_id: int, version: int) -> str:
raise NotImplementedError() raise NotImplementedError()
def upload_realm_logo_image(self, logo_file: File, user_profile: UserProfile) -> None:
raise NotImplementedError()
def get_realm_logo_url(self, realm_id: int, version: int) -> str:
raise NotImplementedError()
def upload_emoji_image(self, emoji_file: File, emoji_file_name: str, user_profile: UserProfile) -> None: def upload_emoji_image(self, emoji_file: File, emoji_file_name: str, user_profile: UserProfile) -> None:
raise NotImplementedError() raise NotImplementedError()
@ -423,6 +442,36 @@ class S3UploadBackend(ZulipUploadBackend):
# ?x=x allows templates to append additional parameters with &s # ?x=x allows templates to append additional parameters with &s
return "https://%s.s3.amazonaws.com/%s/realm/icon.png?version=%s" % (bucket, realm_id, version) return "https://%s.s3.amazonaws.com/%s/realm/icon.png?version=%s" % (bucket, realm_id, version)
def upload_realm_logo_image(self, logo_file: File, user_profile: UserProfile) -> None:
content_type = guess_type(logo_file.name)[0]
bucket_name = settings.S3_AVATAR_BUCKET
s3_file_name = os.path.join(str(user_profile.realm.id), 'realm', 'logo')
image_data = logo_file.read()
upload_image_to_s3(
bucket_name,
s3_file_name + ".original",
content_type,
user_profile,
image_data,
)
resized_data = resize_logo(image_data)
upload_image_to_s3(
bucket_name,
s3_file_name + ".png",
'image/png',
user_profile,
resized_data,
)
# See avatar_url in avatar.py for URL. (That code also handles the case
# that users use gravatar.)
def get_realm_logo_url(self, realm_id: int, version: int) -> str:
bucket = settings.S3_AVATAR_BUCKET
# ?x=x allows templates to append additional parameters with &s
return "https://%s.s3.amazonaws.com/%s/realm/logo.png?version=%s" % (bucket, realm_id, version)
def ensure_medium_avatar_image(self, user_profile: UserProfile) -> None: def ensure_medium_avatar_image(self, user_profile: UserProfile) -> None:
file_path = user_avatar_path(user_profile) file_path = user_avatar_path(user_profile)
s3_file_name = file_path s3_file_name = file_path
@ -576,6 +625,22 @@ class LocalUploadBackend(ZulipUploadBackend):
# ?x=x allows templates to append additional parameters with &s # ?x=x allows templates to append additional parameters with &s
return "/user_avatars/%s/realm/icon.png?version=%s" % (realm_id, version) return "/user_avatars/%s/realm/icon.png?version=%s" % (realm_id, version)
def upload_realm_logo_image(self, logo_file: File, user_profile: UserProfile) -> None:
upload_path = os.path.join('avatars', str(user_profile.realm.id), 'realm')
image_data = logo_file.read()
write_local_file(
upload_path,
'logo.original',
image_data)
resized_data = resize_logo(image_data)
write_local_file(upload_path, 'logo.png', resized_data)
def get_realm_logo_url(self, realm_id: int, version: int) -> str:
# ?x=x allows templates to append additional parameters with &s
return "/user_avatars/%s/realm/logo.png?version=%s" % (realm_id, version)
def ensure_medium_avatar_image(self, user_profile: UserProfile) -> None: def ensure_medium_avatar_image(self, user_profile: UserProfile) -> None:
file_path = user_avatar_path(user_profile) file_path = user_avatar_path(user_profile)
@ -633,6 +698,9 @@ def copy_avatar(source_profile: UserProfile, target_profile: UserProfile) -> Non
def upload_icon_image(user_file: File, user_profile: UserProfile) -> None: def upload_icon_image(user_file: File, user_profile: UserProfile) -> None:
upload_backend.upload_realm_icon_image(user_file, user_profile) upload_backend.upload_realm_icon_image(user_file, user_profile)
def upload_logo_image(user_file: File, user_profile: UserProfile) -> None:
upload_backend.upload_realm_logo_image(user_file, user_profile)
def upload_emoji_image(emoji_file: File, emoji_file_name: str, user_profile: UserProfile) -> None: def upload_emoji_image(emoji_file: File, emoji_file_name: str, user_profile: UserProfile) -> None:
upload_backend.upload_emoji_image(emoji_file, emoji_file_name, user_profile) upload_backend.upload_emoji_image(emoji_file, emoji_file_name, user_profile)

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-08-16 00:34
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('zerver', '0195_realm_first_visible_message_id'),
]
operations = [
migrations.AddField(
model_name='realm',
name='logo_source',
field=models.CharField(choices=[('D', 'Default to Zulip'), ('U', 'Uploaded by administrator')], default='D', max_length=1),
),
migrations.AddField(
model_name='realm',
name='logo_version',
field=models.PositiveSmallIntegerField(default=1),
),
]

View File

@ -291,6 +291,7 @@ class Realm(models.Model):
waiting_period_threshold=int, waiting_period_threshold=int,
) # type: Dict[str, Union[type, Tuple[type, ...]]] ) # type: Dict[str, Union[type, Tuple[type, ...]]]
# Icon is the square mobile icon.
ICON_FROM_GRAVATAR = u'G' ICON_FROM_GRAVATAR = u'G'
ICON_UPLOADED = u'U' ICON_UPLOADED = u'U'
ICON_SOURCES = ( ICON_SOURCES = (
@ -301,6 +302,17 @@ class Realm(models.Model):
max_length=1) # type: str max_length=1) # type: str
icon_version = models.PositiveSmallIntegerField(default=1) # type: int icon_version = models.PositiveSmallIntegerField(default=1) # type: int
# Logo is the horizonal logo we show in top-left of webapp navbar UI.
LOGO_DEFAULT = u'D'
LOGO_UPLOADED = u'U'
LOGO_SOURCES = (
(LOGO_DEFAULT, 'Default to Zulip'),
(LOGO_UPLOADED, 'Uploaded by administrator'),
)
logo_source = models.CharField(default=LOGO_DEFAULT, choices=LOGO_SOURCES,
max_length=1) # type: str
logo_version = models.PositiveSmallIntegerField(default=1) # type: int
BOT_CREATION_POLICY_TYPES = [ BOT_CREATION_POLICY_TYPES = [
BOT_CREATION_EVERYONE, BOT_CREATION_EVERYONE,
BOT_CREATION_LIMIT_GENERIC_BOTS, BOT_CREATION_LIMIT_GENERIC_BOTS,
@ -2299,6 +2311,7 @@ class RealmAuditLog(models.Model):
REALM_REACTIVATED = 'realm_reactivated' REALM_REACTIVATED = 'realm_reactivated'
REALM_SCRUBBED = 'realm_scrubbed' REALM_SCRUBBED = 'realm_scrubbed'
REALM_PLAN_TYPE_CHANGED = 'realm_plan_type_changed' REALM_PLAN_TYPE_CHANGED = 'realm_plan_type_changed'
REALM_LOGO_CHANGED = 'realm_logo_changed'
SUBSCRIPTION_CREATED = 'subscription_created' SUBSCRIPTION_CREATED = 'subscription_created'
SUBSCRIPTION_ACTIVATED = 'subscription_activated' SUBSCRIPTION_ACTIVATED = 'subscription_activated'

View File

@ -1749,8 +1749,12 @@ paths:
description: The realm's name. description: The realm's name.
realm_icon: realm_icon:
type: string type: string
description: The URI of the organization's icon (usually description: The URI of the organization's mobile icon (usually
a logo). a square version of the logo).
realm_logo:
type: string
description: The URI of the organization's top-left navbar logo
(usually a wide rectangular version of the logo).
realm_description: realm_description:
type: string type: string
description: HTML description of the organization, as description: HTML description of the organization, as
@ -1762,6 +1766,7 @@ paths:
"push_notifications_enabled": false, "push_notifications_enabled": false,
"msg": "", "msg": "",
"realm_icon": "https://secure.gravatar.com/avatar/62429d594b6ffc712f54aee976a18b44?d=identicon", "realm_icon": "https://secure.gravatar.com/avatar/62429d594b6ffc712f54aee976a18b44?d=identicon",
"realm_logo": "/static/images/logo/zulip-org-logo.png",
"realm_description": "<p>The Zulip development environment default organization. It's great for testing!</p>", "realm_description": "<p>The Zulip development environment default organization. It's great for testing!</p>",
"email_auth_enabled": true, "email_auth_enabled": true,
"zulip_version": "1.9.0-rc1+git", "zulip_version": "1.9.0-rc1+git",

View File

@ -1556,6 +1556,7 @@ class FetchAuthBackends(ZulipTestCase):
('realm_name', check_string), ('realm_name', check_string),
('realm_description', check_string), ('realm_description', check_string),
('realm_icon', check_string), ('realm_icon', check_string),
('realm_logo', check_string),
]) ])
def test_fetch_auth_backend_format(self) -> None: def test_fetch_auth_backend_format(self) -> None:

View File

@ -96,6 +96,7 @@ class HomeTest(ZulipTestCase):
"login_page", "login_page",
"max_avatar_file_size", "max_avatar_file_size",
"max_icon_file_size", "max_icon_file_size",
"max_logo_file_size",
"max_message_id", "max_message_id",
"maxfilesize", "maxfilesize",
"message_content_in_email_notifications", "message_content_in_email_notifications",
@ -148,6 +149,8 @@ class HomeTest(ZulipTestCase):
"realm_invite_by_admins_only", "realm_invite_by_admins_only",
"realm_invite_required", "realm_invite_required",
"realm_is_zephyr_mirror_realm", "realm_is_zephyr_mirror_realm",
"realm_logo_source",
"realm_logo_url",
"realm_mandatory_topics", "realm_mandatory_topics",
"realm_message_content_delete_limit_seconds", "realm_message_content_delete_limit_seconds",
"realm_message_content_edit_limit_seconds", "realm_message_content_edit_limit_seconds",

View File

@ -10,6 +10,7 @@ from zerver.lib.avatar import (
from zerver.lib.avatar_hash import user_avatar_path from zerver.lib.avatar_hash import user_avatar_path
from zerver.lib.bugdown import url_filename from zerver.lib.bugdown import url_filename
from zerver.lib.realm_icon import realm_icon_url from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.realm_logo import realm_logo_url
from zerver.lib.test_classes import ZulipTestCase, UploadSerializeMixin from zerver.lib.test_classes import ZulipTestCase, UploadSerializeMixin
from zerver.lib.test_helpers import ( from zerver.lib.test_helpers import (
avatar_disk_path, avatar_disk_path,
@ -32,6 +33,7 @@ from zerver.models import Attachment, get_user, \
RealmDomain, RealmEmoji, get_realm, get_system_bot, \ RealmDomain, RealmEmoji, get_realm, get_system_bot, \
validate_attachment_request validate_attachment_request
from zerver.lib.actions import ( from zerver.lib.actions import (
do_change_plan_type,
do_delete_old_unclaimed_attachments, do_delete_old_unclaimed_attachments,
internal_send_private_message, internal_send_private_message,
) )
@ -807,6 +809,8 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
"/user_avatars/hash-medium.png?x=x") "/user_avatars/hash-medium.png?x=x")
self.assertEqual(backend.get_realm_icon_url(15, 1), self.assertEqual(backend.get_realm_icon_url(15, 1),
"/user_avatars/15/realm/icon.png?version=1") "/user_avatars/15/realm/icon.png?version=1")
self.assertEqual(backend.get_realm_logo_url(15, 1),
"/user_avatars/15/realm/logo.png?version=1")
with self.settings(S3_AVATAR_BUCKET="bucket"): with self.settings(S3_AVATAR_BUCKET="bucket"):
backend = S3UploadBackend() backend = S3UploadBackend()
@ -816,6 +820,8 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
"https://bucket.s3.amazonaws.com/hash-medium.png?x=x") "https://bucket.s3.amazonaws.com/hash-medium.png?x=x")
self.assertEqual(backend.get_realm_icon_url(15, 1), self.assertEqual(backend.get_realm_icon_url(15, 1),
"https://bucket.s3.amazonaws.com/15/realm/icon.png?version=1") "https://bucket.s3.amazonaws.com/15/realm/icon.png?version=1")
self.assertEqual(backend.get_realm_logo_url(15, 1),
"https://bucket.s3.amazonaws.com/15/realm/logo.png?version=1")
def test_multiple_upload_failure(self) -> None: def test_multiple_upload_failure(self) -> None:
""" """
@ -1238,6 +1244,145 @@ class RealmIconTest(UploadSerializeMixin, ZulipTestCase):
def tearDown(self) -> None: def tearDown(self) -> None:
destroy_uploads() destroy_uploads()
class RealmLogoTest(UploadSerializeMixin, ZulipTestCase):
def test_multiple_upload_failure(self) -> None:
"""
Attempting to upload two files should fail.
"""
# Log in as admin
self.login(self.example_email("iago"))
with get_test_image_file('img.png') as fp1, \
get_test_image_file('img.png') as fp2:
result = self.client_post("/json/realm/logo", {'f1': fp1, 'f2': fp2})
self.assert_json_error(result, "You must upload exactly one logo.")
def test_no_file_upload_failure(self) -> None:
"""
Calling this endpoint with no files should fail.
"""
self.login(self.example_email("iago"))
result = self.client_post("/json/realm/logo")
self.assert_json_error(result, "You must upload exactly one logo.")
correct_files = [
('img.png', 'png_resized.png'),
('img.jpg', None), # jpeg resizing is platform-dependent
('img.gif', 'gif_resized.png'),
('img.tif', 'tif_resized.png'),
('cmyk.jpg', None)
]
corrupt_files = ['text.txt', 'corrupt.png', 'corrupt.gif']
def test_no_admin_user_upload(self) -> None:
self.login(self.example_email("hamlet"))
with get_test_image_file(self.correct_files[0][0]) as fp:
result = self.client_post("/json/realm/logo", {'file': fp})
self.assert_json_error(result, 'Must be an organization administrator')
def test_upload_limited_plan_type(self) -> None:
user_profile = self.example_user("iago")
do_change_plan_type(user_profile.realm, Realm.LIMITED)
self.login(user_profile.email)
with get_test_image_file(self.correct_files[0][0]) as fp:
result = self.client_post("/json/realm/logo", {'file': fp})
self.assert_json_error(result, 'Feature unavailable on your current plan.')
def test_get_default_logo(self) -> None:
self.login(self.example_email("hamlet"))
realm = get_realm('zulip')
realm.logo_source = Realm.LOGO_DEFAULT
realm.save()
response = self.client_get("/json/realm/logo?foo=bar")
redirect_url = response['Location']
self.assertEqual(redirect_url, realm_logo_url(realm) + '&foo=bar')
def test_get_realm_logo(self) -> None:
self.login(self.example_email("hamlet"))
realm = get_realm('zulip')
realm.logo_source = Realm.LOGO_UPLOADED
realm.save()
response = self.client_get("/json/realm/logo?foo=bar")
redirect_url = response['Location']
self.assertTrue(redirect_url.endswith(realm_logo_url(realm) + '&foo=bar'))
def test_valid_logos(self) -> None:
"""
A PUT request to /json/realm/logo with a valid file should return a url
and actually create an realm logo.
"""
for fname, rfname in self.correct_files:
# TODO: use self.subTest once we're exclusively on python 3 by uncommenting the line below.
# with self.subTest(fname=fname):
self.login(self.example_email("iago"))
with get_test_image_file(fname) as fp:
result = self.client_post("/json/realm/logo", {'file': fp})
realm = get_realm('zulip')
self.assert_json_success(result)
self.assertIn("logo_url", result.json())
base = '/user_avatars/%s/realm/logo.png' % (realm.id,)
url = result.json()['logo_url']
self.assertEqual(base, url[:len(base)])
if rfname is not None:
response = self.client_get(url)
data = b"".join(response.streaming_content)
# size should be 100 x 100 because thumbnail keeps aspect ratio
# while trying to fit in a 800 x 100 box without losing part of the image
self.assertEqual(Image.open(io.BytesIO(data)).size, (100, 100))
def test_invalid_logos(self) -> None:
"""
A PUT request to /json/realm/logo with an invalid file should fail.
"""
for fname in self.corrupt_files:
# with self.subTest(fname=fname):
self.login(self.example_email("iago"))
with get_test_image_file(fname) as fp:
result = self.client_post("/json/realm/logo", {'file': fp})
self.assert_json_error(result, "Could not decode image; did you upload an image file?")
def test_delete_logo(self) -> None:
"""
A DELETE request to /json/realm/logo should delete the realm logo and return gravatar URL
"""
self.login(self.example_email("iago"))
realm = get_realm('zulip')
realm.logo_source = Realm.LOGO_UPLOADED
realm.save()
result = self.client_delete("/json/realm/logo")
self.assert_json_success(result)
self.assertIn("logo_url", result.json())
realm = get_realm('zulip')
self.assertEqual(result.json()["logo_url"], realm_logo_url(realm))
self.assertEqual(realm.logo_source, Realm.LOGO_DEFAULT)
def test_realm_logo_version(self) -> None:
self.login(self.example_email("iago"))
realm = get_realm('zulip')
logo_version = realm.logo_version
self.assertEqual(logo_version, 1)
with get_test_image_file(self.correct_files[0][0]) as fp:
self.client_post("/json/realm/logo", {'file': fp})
realm = get_realm('zulip')
self.assertEqual(realm.logo_version, logo_version + 1)
def test_realm_logo_upload_file_size_error(self) -> None:
self.login(self.example_email("iago"))
with get_test_image_file(self.correct_files[0][0]) as fp:
with self.settings(MAX_LOGO_FILE_SIZE=0):
result = self.client_post("/json/realm/logo", {'file': fp})
self.assert_json_error(result, "Uploaded file is larger than the allowed limit of 0 MB")
def tearDown(self) -> None:
destroy_uploads()
class LocalStorageTest(UploadSerializeMixin, ZulipTestCase): class LocalStorageTest(UploadSerializeMixin, ZulipTestCase):
def test_file_upload_local(self) -> None: def test_file_upload_local(self) -> None:
@ -1506,6 +1651,26 @@ class S3Test(ZulipTestCase):
resized_path_id = os.path.join(str(user_profile.realm.id), "realm", "icon.png") resized_path_id = os.path.join(str(user_profile.realm.id), "realm", "icon.png")
resized_data = bucket.get_key(resized_path_id).read() resized_data = bucket.get_key(resized_path_id).read()
# resized image size should be 100 x 100 because thumbnail keeps aspect ratio
# while trying to fit in a 800 x 100 box without losing part of the image
resized_image = Image.open(io.BytesIO(resized_data)).size
self.assertEqual(resized_image, (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE))
@use_s3_backend
def test_upload_realm_logo_image(self) -> None:
bucket = create_s3_buckets(settings.S3_AVATAR_BUCKET)[0]
user_profile = self.example_user("hamlet")
image_file = get_test_image_file("img.png")
zerver.lib.upload.upload_backend.upload_realm_logo_image(image_file, user_profile)
original_path_id = os.path.join(str(user_profile.realm.id), "realm", "logo.original")
original_key = bucket.get_key(original_path_id)
image_file.seek(0)
self.assertEqual(image_file.read(), original_key.get_contents_as_string())
resized_path_id = os.path.join(str(user_profile.realm.id), "realm", "logo.png")
resized_data = bucket.get_key(resized_path_id).read()
resized_image = Image.open(io.BytesIO(resized_data)).size resized_image = Image.open(io.BytesIO(resized_data)).size
self.assertEqual(resized_image, (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE)) self.assertEqual(resized_image, (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE))

View File

@ -871,6 +871,7 @@ def api_get_server_settings(request: HttpRequest) -> HttpResponse:
"realm_uri", "realm_uri",
"realm_name", "realm_name",
"realm_icon", "realm_icon",
"realm_logo",
"realm_description"]: "realm_description"]:
if context[settings_item] is not None: if context[settings_item] is not None:
result[settings_item] = context[settings_item] result[settings_item] = context[settings_item]

View File

@ -0,0 +1,58 @@
from django.conf import settings
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.http import HttpResponse, HttpRequest
from zerver.decorator import require_realm_admin
from zerver.lib.actions import do_change_logo_source
from zerver.lib.realm_logo import realm_logo_url
from zerver.lib.response import json_error, json_success
from zerver.lib.upload import upload_logo_image
from zerver.models import Realm, UserProfile
@require_realm_admin
def upload_logo(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
if user_profile.realm.plan_type == Realm.LIMITED:
return json_error(_("Feature unavailable on your current plan."))
if len(request.FILES) != 1:
return json_error(_("You must upload exactly one logo."))
logo_file = list(request.FILES.values())[0]
if ((settings.MAX_LOGO_FILE_SIZE * 1024 * 1024) < logo_file.size):
return json_error(_("Uploaded file is larger than the allowed limit of %s MB") % (
settings.MAX_LOGO_FILE_SIZE))
upload_logo_image(logo_file, user_profile)
do_change_logo_source(user_profile.realm, user_profile.realm.LOGO_UPLOADED)
logo_url = realm_logo_url(user_profile.realm)
json_result = dict(
logo_url=logo_url
)
return json_success(json_result)
@require_realm_admin
def delete_logo_backend(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
# We don't actually delete the logo because it might still
# be needed if the URL was cached and it is rewrited
# in any case after next update.
do_change_logo_source(user_profile.realm, user_profile.realm.LOGO_DEFAULT)
default_url = realm_logo_url(user_profile.realm)
json_result = dict(
logo_url=default_url
)
return json_success(json_result)
def get_logo_backend(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
url = realm_logo_url(user_profile.realm)
# We can rely on the url already having query parameters. Because
# our templates depend on being able to use the ampersand to
# add query parameters to our url, get_logo_url does '?version=version_number'
# hacks to prevent us from having to jump through decode/encode hoops.
assert '?' in url
url += '&' + request.META['QUERY_STRING']
return redirect(url)

View File

@ -178,6 +178,7 @@ DEFAULT_SETTINGS = {
# File uploads and avatars # File uploads and avatars
'DEFAULT_AVATAR_URI': '/static/images/default-avatar.png', 'DEFAULT_AVATAR_URI': '/static/images/default-avatar.png',
'DEFAULT_LOGO_URI': '/static/images/logo/zulip-org-logo.png',
'S3_AVATAR_BUCKET': '', 'S3_AVATAR_BUCKET': '',
'S3_AUTH_UPLOADS_BUCKET': '', 'S3_AUTH_UPLOADS_BUCKET': '',
'S3_REGION': '', 'S3_REGION': '',
@ -349,6 +350,7 @@ DEFAULT_SETTINGS.update({
'DATA_UPLOAD_MAX_MEMORY_SIZE': 25 * 1024 * 1024, 'DATA_UPLOAD_MAX_MEMORY_SIZE': 25 * 1024 * 1024,
'MAX_AVATAR_FILE_SIZE': 5, 'MAX_AVATAR_FILE_SIZE': 5,
'MAX_ICON_FILE_SIZE': 5, 'MAX_ICON_FILE_SIZE': 5,
'MAX_LOGO_FILE_SIZE': 5,
'MAX_EMOJI_FILE_SIZE': 5, 'MAX_EMOJI_FILE_SIZE': 5,
# Limits to help prevent spam, in particular by sending invitations. # Limits to help prevent spam, in particular by sending invitations.

View File

@ -97,6 +97,12 @@ v1_api_and_json_patterns = [
'DELETE': 'zerver.views.realm_icon.delete_icon_backend', 'DELETE': 'zerver.views.realm_icon.delete_icon_backend',
'GET': 'zerver.views.realm_icon.get_icon_backend'}), 'GET': 'zerver.views.realm_icon.get_icon_backend'}),
# realm/logo -> zerver.views.realm_logo_
url(r'^realm/logo$', rest_dispatch,
{'POST': 'zerver.views.realm_logo.upload_logo',
'DELETE': 'zerver.views.realm_logo.delete_logo_backend',
'GET': 'zerver.views.realm_logo.get_logo_backend'}),
# realm/filters -> zerver.views.realm_filters # realm/filters -> zerver.views.realm_filters
url(r'^realm/filters$', rest_dispatch, url(r'^realm/filters$', rest_dispatch,
{'GET': 'zerver.views.realm_filters.list_filters', {'GET': 'zerver.views.realm_filters.list_filters',