Skip to content

Commit

Permalink
Wrap user-facing strings in gettext; add icon_emoji
Browse files Browse the repository at this point in the history
  • Loading branch information
posita committed Feb 25, 2018
1 parent 6e51e9e commit aa16944
Show file tree
Hide file tree
Showing 6 changed files with 166 additions and 79 deletions.
24 changes: 9 additions & 15 deletions docs/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,22 +95,16 @@ To override this, use the ``FERNET_KEYS`` setting. For example:
from os import environ
SECRET_KEY = environ['SECRET_KEY']
# Use only Base64-encoded 32 byte values for keys; don't derive them
# from arbitrary strings
# The keys as a single environment variable separated by semicolons.
# The first entry is the current key (for encrypting and
# decrypting). Any additional entries are older keys for decrypting
# only.
FERNET_KEYS = environ['FERNET_KEY'].split(';')
# If the following is False, use only Base64-encoded 32 byte values
# for keys. Don't derive them from arbitrary strings.
FERNET_USE_HKDF = False
# For supporting any legacy keys that were used when FERNET_USE_HKDF
# was True
from fernet_fields.hkdf import derive_fernet_key
# The keys
FERNET_KEYS = [
# The first entry is the current key (for encrypting and
# decrypting)
environ['FERNET_KEY'],
# Optional additional entries are older keys for decrypting only
# environ['OLD_FERNET_KEY_1'],
# Equivalent to the default key
# derive_fernet_key(SECRET_KEY),
]
See `the docs <http://django-fernet-fields.readthedocs.io/en/latest/#keys>`__ for details.

Expand Down
9 changes: 5 additions & 4 deletions emojiwatch/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 2.0.2 on 2018-02-23 08:04
# Generated by Django 2.0.2 on 2018-02-25 02:47

import django.core.validators
from django.db import migrations, models
Expand All @@ -18,9 +18,10 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('_version', models.IntegerField(default=-2147483648)),
('team_id', models.CharField(max_length=63, unique=True, validators=[django.core.validators.RegexValidator('^T[0-9A-Z]*$', message='Must be of the format (e.g.) T123ABC...')], verbose_name='Team ID')),
('workspace_token', fernet_fields.fields.EncryptedCharField(max_length=255, validators=[django.core.validators.RegexValidator('^xoxa-([0-9A-Fa-f]+)+', message='Must be of the format (e.g.) xoxa-1f2e3d-4c5b6a...')], verbose_name='Workspace Token')),
('channel_id', models.CharField(max_length=63, validators=[django.core.validators.RegexValidator('^C[0-9A-Z]*$', message='Must be of the format (e.g.) C123ABC...')], verbose_name='Channel ID')),
('team_id', models.CharField(default='T', max_length=63, unique=True, validators=[django.core.validators.RegexValidator('\\AT[0-9A-Z]+\\Z', message='Must be of the format (e.g.) T123ABC...')], verbose_name='Team ID')),
('auth_token', fernet_fields.fields.EncryptedCharField(default='xoxa-', max_length=255, validators=[django.core.validators.RegexValidator('\\Axox[abp]-([0-9A-Fa-f]+)+', message='Must be of the format (e.g.) xox...-1f2e3d-4c5b6a...')], verbose_name='OAuth Token')),
('channel_id', models.CharField(default='C', max_length=63, validators=[django.core.validators.RegexValidator('\\AC[0-9A-Z]+\\Z', message='Must be of the format (e.g.) C123ABC...')], verbose_name='Channel ID')),
('icon_emoji', models.CharField(default=':robot_face:', max_length=255, validators=[django.core.validators.RegexValidator('\\A:[\\w-]+:\\Z', message='Must be of the format (e.g.) :emoji_name:...')], verbose_name='Icon Emoji')),
('notes', fernet_fields.fields.EncryptedTextField(blank=True, default='', verbose_name='Notes')),
],
options={
Expand Down
42 changes: 32 additions & 10 deletions emojiwatch/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@

__all__ = ()

AUTH_TOKEN_MAX_LEN = 255
AUTH_TOKEN_RE = r'\Axox[abp]-([0-9A-Fa-f]+)+'
CHANNEL_ID_MAX_LEN = 63
CHANNEL_ID_RE = r'^C[0-9A-Z]*$'
CHANNEL_ID_RE = r'\AC[0-9A-Z]+\Z'
ICON_EMOJI_DEFAULT = ':robot_face:'
ICON_EMOJI_MAX_LEN = 255
ICON_EMOJI_RE = r'\A:[\w-]+:\Z'
TEAM_ID_MAX_LEN = 63
TEAM_ID_RE = r'^T[0-9A-Z]*$'
WORKSPACE_TOKEN_MAX_LEN = 255
WORKSPACE_TOKEN_RE = r'^xoxa-([0-9A-Fa-f]+)+'
TEAM_ID_RE = r'\AT[0-9A-Z]+\Z'

# ---- Exceptions --------------------------------------------------------

Expand Down Expand Up @@ -96,7 +99,7 @@ def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_updat
updated = filtered._update(values) # pylint: disable=protected-access

if updated == 0:
raise StaleVersionError('{!r}._version {} is stale'.format(self, self._version - 1))
raise StaleVersionError(gettext('{!r}._version {} is stale').format(self, self._version - 1))

assert updated == 1

Expand All @@ -116,6 +119,7 @@ class Meta(object):
# ---- Properties ----------------------------------------------------

team_id = d_d_models.CharField(
default='T',
max_length=TEAM_ID_MAX_LEN,
null=False,
unique=True,
Expand All @@ -127,16 +131,20 @@ class Meta(object):

team_id.short_description = gettext('Team ID (e.g., T123ABC...)')

workspace_token = fernet_fields.EncryptedCharField(
max_length=WORKSPACE_TOKEN_MAX_LEN,
auth_token = fernet_fields.EncryptedCharField(
default='xoxa-',
max_length=AUTH_TOKEN_MAX_LEN,
null=False,
validators=[
d_c_validators.RegexValidator(WORKSPACE_TOKEN_RE, message=gettext('Must be of the format (e.g.) xoxa-1f2e3d-4c5b6a...')),
d_c_validators.RegexValidator(AUTH_TOKEN_RE, message=gettext('Must be of the format (e.g.) xox...-1f2e3d-4c5b6a...')),
],
verbose_name=gettext('Workspace Token'),
verbose_name=gettext('OAuth Token'),
)

auth_token.short_description = gettext('Auth Token (e.g., a workspace token like xoxa-...)')

channel_id = d_d_models.CharField(
default='C',
max_length=CHANNEL_ID_MAX_LEN,
null=False,
validators=[
Expand All @@ -145,6 +153,20 @@ class Meta(object):
verbose_name=gettext('Channel ID'),
)

team_id.short_description = gettext('Channel ID (e.g., C123ABC...)')

icon_emoji = d_d_models.CharField(
default=ICON_EMOJI_DEFAULT,
max_length=ICON_EMOJI_MAX_LEN,
null=False,
validators=[
d_c_validators.RegexValidator(ICON_EMOJI_RE, message=gettext('Must be of the format (e.g.) :emoji_name:...')),
],
verbose_name=gettext('Icon Emoji'),
)

icon_emoji.short_description = gettext('Icon Emoji (e.g., {}...)').format(ICON_EMOJI_DEFAULT)

notes = fernet_fields.EncryptedTextField(
blank=True,
default='',
Expand All @@ -153,4 +175,4 @@ class Meta(object):

@property
def slack(self):
return slacker.Slacker(self.workspace_token)
return slacker.Slacker(self.auth_token)
66 changes: 43 additions & 23 deletions emojiwatch/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
import django.views.decorators.http as d_v_d_http
import django.views.generic as d_v_generic

from gettext import gettext

from . import (
LOGGER,
SLACK_VERIFICATION_TOKEN,
Expand All @@ -53,7 +55,7 @@
_EMOJI_URL_MAX_LEN = 1023
_EMOJIS_MAX_LEN = 32
_FIELD_MAX_LEN = 63
_SHRUG = '\u00af\\_(\u30c4)_/\u00af'
_SHRUG = str(u'\u00af\\_(\u30c4)_/\u00af')
_SUB_HANDLERS_BY_SUBTYPE = {} # type: typing.Dict[typing.Text, typing.Callable[[SlackWorkspaceEmojiWatcher, typing.Dict], slacker.Response]]
_UNRECOGNIZED_JSON_BODY_ERR = 'unrecognized JSON structure from request body'

Expand All @@ -71,7 +73,7 @@ class RequestPayloadValidationError(Exception):

def __init__( # noqa:F811 # pylint: disable=keyword-arg-before-vararg
self,
message=_UNRECOGNIZED_JSON_BODY_ERR,
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR),
response=d_http.HttpResponseBadRequest(),
*args,
**kw
Expand Down Expand Up @@ -121,7 +123,7 @@ def event_hook_handler(
slack_retry_reason = request.META.get('HTTP_X_SLACK_RETRY_REASON', None)

if slack_retry_num:
LOGGER.info("Slack retry attempt %s ('%s')", slack_retry_num, slack_retry_reason)
LOGGER.info(gettext("Slack retry attempt %s ('%s')"), slack_retry_num, slack_retry_reason)

content_type = request.META.get('HTTP_CONTENT_TYPE', 'application/json')

Expand All @@ -132,7 +134,7 @@ def event_hook_handler(
try:
payload_data = json.loads(request.body.decode('utf-8')) # type: typing.Dict
except (JSONDecodeError, UnicodeDecodeError):
LOGGER.info('unable to parse JSON from request body')
LOGGER.info(gettext('unable to parse JSON from request body'))
truncate_len = 1024
half_truncate_len = truncate_len >> 1

Expand All @@ -148,41 +150,41 @@ def event_hook_handler(
verification_token = payload_data['token']
except (KeyError, TypeError):
verification_token = None
LOGGER.info(_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'token')") # pylint: disable=logging-not-lazy
LOGGER.info(gettext(_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'token')")) # pylint: disable=logging-not-lazy

if not verification_token \
or verification_token != SLACK_VERIFICATION_TOKEN:
raise RequestPayloadValidationError(
message='bad verification token',
message=gettext('bad verification token'),
response=d_http.HttpResponseForbidden(),
)

try:
call_type = payload_data['type']
except KeyError:
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'type')",
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'type')"),
)

if call_type == 'url_verification':
try:
challenge = payload_data['challenge']
except KeyError:
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'challenge')",
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'challenge')"),
)

if not isinstance(challenge, str) \
or len(challenge) > _CHALLENGE_MAX_LEN:
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized challenge)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized challenge)'),
)

return d_http.HttpResponse(challenge, content_type='text/plain')

if call_type != 'event_callback':
raise RequestPayloadValidationError(
message='unrecognized call type',
message=gettext('unrecognized call type'),
)

try:
Expand All @@ -197,7 +199,7 @@ def event_hook_handler(
or len(event_type) > _FIELD_MAX_LEN \
or event_type != 'emoji_changed':
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event type)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event type)'),
)

try:
Expand All @@ -208,21 +210,21 @@ def event_hook_handler(
subhandler = _SUB_HANDLERS_BY_SUBTYPE[event_subtype]
except (KeyError, ValueError):
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event subtype)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event subtype)'),
)

if not isinstance(team_id, str) \
or len(team_id) > TEAM_ID_MAX_LEN \
or not re.search(TEAM_ID_RE, team_id):
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized team_id)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized team_id)'),
)

team = SlackWorkspaceEmojiWatcher.objects.filter(team_id=team_id).first()

if team is None:
raise RequestPayloadValidationError(
message='no such team ({})'.format(team_id),
message=gettext('no such team ({})').format(team_id),
)

try:
Expand All @@ -231,13 +233,13 @@ def event_hook_handler(
except slacker.Error as exc:
if exc.args == ('invalid_auth',):
raise RequestPayloadValidationError(
message='call to Slack API failed auth',
message=gettext('call to Slack API failed auth'),
response=d_http.HttpResponseForbidden(),
)

# Log, but otherwise ignore errors from our callbacks to Slack's
# API
LOGGER.info('falled call to Slack')
LOGGER.info(gettext('falled call to Slack'))
LOGGER.debug(_SHRUG, exc_info=True)

except RequestPayloadValidationError as exc:
Expand All @@ -250,6 +252,20 @@ def event_hook_handler(

return d_http.HttpResponse()

# ========================================================================
def _as_user(
team, # type: SlackWorkspaceEmojiWatcher
): # type: (...) -> typing.Optional[bool]
# as_user can only be non-None if it's a bot token (see
# <https://api.slack.com/docs/token-types>)
return False if re.search(r'\Axoxb-', team.auth_token) else None

# ========================================================================
def _icon_emoji(
team, # type: SlackWorkspaceEmojiWatcher
): # type: (...) -> typing.Optional[typing.Text]
return team.icon_emoji if team.icon_emoji else None

# ========================================================================
def _handle_add(
team, # type: SlackWorkspaceEmojiWatcher
Expand All @@ -262,13 +278,13 @@ def _handle_add(
or not emoji_name \
or len(emoji_name) > _EMOJI_NAME_MAX_LEN:
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event name)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event name)'),
)

if not isinstance(emoji_url, str) \
or len(emoji_url) > _EMOJI_URL_MAX_LEN:
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event value)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event value)'),
)

if emoji_url:
Expand All @@ -277,14 +293,16 @@ def _handle_add(
'fallback': '<{}>'.format(emoji_url),
'image_url': emoji_url,
},
] # type: typing.Optional[typing.List[typing.Dict]]
] # type: typing.Optional[typing.List[typing.Dict[typing.Text, typing.Text]]]
else:
attachments = None

return team.slack.chat.post_message(
team.channel_id,
html.escape('added `:{}:`').format(emoji_name),
html.escape(gettext('added `:{}:`').format(emoji_name)),
as_user=_as_user(team),
attachments=attachments,
icon_emoji=_icon_emoji(team),
)

# ========================================================================
Expand All @@ -296,7 +314,7 @@ def _handle_remove(

if not isinstance(emoji_names, list):
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event names)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event names)'),
)

if not emoji_names:
Expand All @@ -308,12 +326,14 @@ def _handle_remove(
if any((not isinstance(emoji_name, str) for emoji_name in emoji_names[:_EMOJIS_MAX_LEN])) \
or any((len(emoji_name) > _EMOJI_NAME_MAX_LEN for emoji_name in emoji_names)):
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event names)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event names)'),
)

return team.slack.chat.post_message(
team.channel_id,
html.escape('removed {}{}').format(', '.join('`:{}:`'.format(name) for name in emoji_names[:_EMOJIS_MAX_LEN]), '...' if too_many else ''),
html.escape(gettext('removed {}{}').format(', '.join('`:{}:`'.format(name) for name in emoji_names[:_EMOJIS_MAX_LEN]), '...' if too_many else '')),
as_user=_as_user(team),
icon_emoji=_icon_emoji(team),
)

# ---- Initialization ----------------------------------------------------
Expand Down
Loading

0 comments on commit aa16944

Please sign in to comment.