diff --git a/ursh/MANIFEST.in b/ursh/MANIFEST.in new file mode 100644 index 0000000..db61780 --- /dev/null +++ b/ursh/MANIFEST.in @@ -0,0 +1,4 @@ +graft indico_ursh/static +graft indico_ursh/translations + +global-exclude *.pyc __pycache__ .keep diff --git a/ursh/indico_ursh/__init__.py b/ursh/indico_ursh/__init__.py new file mode 100644 index 0000000..c753ada --- /dev/null +++ b/ursh/indico_ursh/__init__.py @@ -0,0 +1,13 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2002 - 2019 CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from __future__ import unicode_literals + +from indico.util.i18n import make_bound_gettext + + +_ = make_bound_gettext('ursh') diff --git a/ursh/indico_ursh/blueprint.py b/ursh/indico_ursh/blueprint.py new file mode 100644 index 0000000..c567622 --- /dev/null +++ b/ursh/indico_ursh/blueprint.py @@ -0,0 +1,19 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2002 - 2019 CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from __future__ import unicode_literals + +from indico.core.plugins import IndicoPluginBlueprint + +from indico_ursh.controllers import RHCustomShortURLPage, RHGetShortURL, RHShortURLPage + + +blueprint = IndicoPluginBlueprint('ursh', 'indico_ursh') +blueprint.add_url_rule('/ursh', 'get_short_url', RHGetShortURL, methods=('POST',)) +blueprint.add_url_rule('/url-shortener', 'shorten_url', RHShortURLPage) +blueprint.add_url_rule('/event//manage/short-url', 'shorten_event_url', RHCustomShortURLPage, + methods=('GET', 'POST')) diff --git a/ursh/indico_ursh/client/index.js b/ursh/indico_ursh/client/index.js new file mode 100644 index 0000000..eaf0fab --- /dev/null +++ b/ursh/indico_ursh/client/index.js @@ -0,0 +1,150 @@ +// This file is part of the Indico plugins. +// Copyright (C) 2002 - 2019 CERN +// +// The Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; +// see the LICENSE file for more details. + +/* global $T:false */ + +import {handleAxiosError, indicoAxios} from 'indico/utils/axios'; + +const $t = $T.domain('ursh'); + +function _showTip(element, msg, hideEvent = 'unfocus') { + element.qtip({ + content: { + text: msg, + }, + hide: { + event: hideEvent, + fixed: true, + delay: 700, + }, + show: { + event: false, + ready: true, + }, + }); +} + +async function _makeUrshRequest(originalURL) { + const urshEndpoint = '/ursh'; + + let response; + try { + response = await indicoAxios.post(urshEndpoint, { + original_url: originalURL, + }); + } catch (error) { + handleAxiosError(error); + return; + } + + return response.data.url; +} + +function _validateAndFormatURL(url) { + if (!url) { + throw Error($t.gettext('Please fill in a URL to shorten')); + } + + // if protocol is missing, prepend it + if (url.startsWith(location.hostname)) { + url = `${location.protocol}//${url}`; + } + + // regular expression, because I.E. does not like the URL class + // provides minimal validation, leaving the serious job to the server + const re = RegExp(`^([\\d\\w]+:)//([^/ .]+(?:\\.[^/ .]+)*)(/.*)?$`); + const urlTokens = url.match(re); + if (!urlTokens) { + throw Error($t.gettext('This does not look like a valid URL')); + } + + // extract tokens + const hostname = urlTokens[2]; + const path = urlTokens[3] ? urlTokens[3] : '/'; + + const protocol = location.protocol; // patch protocol to match server + if (hostname !== location.hostname) { + throw Error($t.gettext('Invalid host: only Indico URLs are allowed')); + } + + return `${protocol}//${hostname}${path}`; +} + +function _getUrshInput(input) { + const inputURL = input.val().trim(); + input.val(inputURL); + + try { + const formattedURL = _validateAndFormatURL(inputURL); + input.val(formattedURL); + return formattedURL; + } catch (err) { + _showTip(input, err.message); + input.focus().select(); + return null; + } +} + +async function _handleUrshPageInput(evt) { + evt.preventDefault(); + + const input = $('#ursh-shorten-input'); + const originalURL = _getUrshInput(input); + if (originalURL) { + const result = await _makeUrshRequest(originalURL); + if (result) { + const outputElement = $('#ursh-shorten-output'); + $('#ursh-shorten-response-form').slideDown(); + outputElement.val(result); + outputElement.select(); + } else { + _showTip(input, $t.gettext('This does not look like a valid URL')); + input.focus().select(); + } + } +} + +async function _handleUrshClick(evt) { + evt.preventDefault(); + const originalURL = evt.target.dataset.originalUrl; + const result = await _makeUrshRequest(originalURL); + $(evt.target).copyURLTooltip(result, 'unfocus'); +} + +function _validateUrshCustomShortcut(shortcut) { + return shortcut.length >= 5; +} + +$(document) + .on('click', '#ursh-shorten-button', _handleUrshPageInput) + .on('keydown', '#ursh-shorten-input', evt => { + if (evt.key === 'Enter') { + _handleUrshPageInput(evt); + } + }) + .on('click', '.ursh-get', _handleUrshClick) + .on('input', '#ursh-custom-shortcut-input', evt => { + const value = $(evt.target).val(); + $('#ursh-custom-shortcut-submit-button').prop('disabled', !_validateUrshCustomShortcut(value)); + }) + .on('mouseenter', '#ursh-custom-shortcut-submit-button', evt => { + if (evt.target.disabled) { + _showTip( + $(evt.target), + $t.gettext('Please check that the shortcut is correct'), + 'mouseleave' + ); + } + }); + +$(document).ready(() => { + // keep dropdown menu open when clicking on an entry + $('.ursh-dropdown') + .next('ul') + .find('li a') + .on('menu_select', () => true); +}); diff --git a/ursh/indico_ursh/controllers.py b/ursh/indico_ursh/controllers.py new file mode 100644 index 0000000..44514b4 --- /dev/null +++ b/ursh/indico_ursh/controllers.py @@ -0,0 +1,103 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2002 - 2019 CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from __future__ import unicode_literals + +import posixpath + +from flask import jsonify, request, session +from flask_pluginengine import render_plugin_template +from werkzeug.exceptions import BadRequest +from werkzeug.urls import url_parse + +from indico.core.config import config +from indico.modules.events.management.controllers import RHManageEventBase +from indico.web.rh import RH +from indico.web.util import jsonify_template + +from indico_ursh import _ +from indico_ursh.util import register_shortcut, request_short_url, strip_end +from indico_ursh.views import WPShortenURLPage + + +CUSTOM_SHORTCUT_ALPHABET = frozenset('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-') + + +class RHGetShortURL(RH): + """Make a request to the URL shortening service""" + + @staticmethod + def _resolve_full_url(original_url): + if url_parse(original_url).host: + return original_url + original_url = original_url.lstrip('/') + return posixpath.join(config.BASE_URL, original_url) + + @staticmethod + def _check_host(full_url): + if url_parse(full_url).host != url_parse(config.BASE_URL).host: + raise BadRequest('Invalid host for URL shortening service') + + def _process(self): + original_url = request.json.get('original_url') + full_url = self._resolve_full_url(original_url) + self._check_host(full_url) + short_url = request_short_url(full_url) + return jsonify(url=short_url) + + +class RHShortURLPage(RH): + """Provide a simple page, where users can submit a URL to be shortened""" + + def _process(self): + return WPShortenURLPage.render_template('ursh_shortener_page.html') + + +class RHCustomShortURLPage(RHManageEventBase): + """Provide a simple page, where users can submit a URL to be shortened""" + + def _make_absolute_url(self, url): + return posixpath.join(config.BASE_URL, url[1:]) if url.startswith('/') else url + + def _get_error_msg(self, result): + if result['status'] == 409: + return _('Shortcut already exists') + elif result['status'] == 400: + return _('Malformed shortcut') + return result['error'].get('description') + + def _process_args(self): + from indico_ursh.plugin import UrshPlugin + super(RHCustomShortURLPage, self)._process_args() + api_host = url_parse(UrshPlugin.settings.get('api_host')) + self.ursh_host = strip_end(api_host.to_url(), api_host.path[1:]).rstrip('/') + '/' + + def _process_GET(self): + original_url = self._make_absolute_url(request.args['original_url']) + return WPShortenURLPage.render_template('ursh_custom_shortener_page.html', + event=self.event, + ursh_host=self.ursh_host, + original_url=original_url, + submitted=False) + + def _process_POST(self): + original_url = self._make_absolute_url(request.args['original_url']) + shortcut = request.form['shortcut'].strip() + + if not (set(shortcut) <= CUSTOM_SHORTCUT_ALPHABET): + raise BadRequest('Invalid shortcut') + + result = register_shortcut(original_url, shortcut, session.user) + + if result.get('error'): + kwargs = {'success': False, 'msg': self._get_error_msg(result)} + else: + kwargs = {'success': True, 'shorturl': result['short_url']} + + return jsonify_template('ursh_custom_shortener_page.html', render_plugin_template, + event=self.event, ursh_host=self.ursh_host, shortcut=shortcut, + original_url=original_url, submitted=True, **kwargs) diff --git a/ursh/indico_ursh/plugin.py b/ursh/indico_ursh/plugin.py new file mode 100644 index 0000000..37da0b8 --- /dev/null +++ b/ursh/indico_ursh/plugin.py @@ -0,0 +1,58 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2002 - 2019 CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from __future__ import unicode_literals + +from flask_pluginengine import render_plugin_template +from wtforms.fields.core import StringField +from wtforms.fields.html5 import URLField +from wtforms.validators import DataRequired + +from indico.core.plugins import IndicoPlugin +from indico.web.forms.base import IndicoForm +from indico.web.views import WPBase + +from indico_ursh import _ +from indico_ursh.blueprint import blueprint + + +class SettingsForm(IndicoForm): + api_key = StringField(_('API key'), [DataRequired()], + description=_('The API key to access the ursh service')) + api_host = URLField(_('API host'), [DataRequired()], + description=_('The ursh API host, providing the interface to generate short URLs')) + + +class UrshPlugin(IndicoPlugin): + """URL Shortener + + Provides a URL shortening service for indico assets, events, etc. + """ + + configurable = True + settings_form = SettingsForm + default_settings = { + 'api_key': '', + 'api_host': '', + } + + def init(self): + super(UrshPlugin, self).init() + self.template_hook('url-shortener', self._inject_ursh_link) + self.template_hook('page-footer', self._inject_ursh_footer) + self.inject_bundle('main.js', WPBase) + + def get_blueprints(self): + return blueprint + + def _inject_ursh_link(self, target=None, event=None, dropdown=False, element_class='', text='', **kwargs): + if self.settings.get('api_key') and self.settings.get('api_host') and (target or event): + return render_plugin_template('ursh_link.html', target=target, event=event, + dropdown=dropdown, element_class=element_class, text=text, **kwargs) + + def _inject_ursh_footer(self, **kwargs): + return render_plugin_template('ursh_footer.html') diff --git a/ursh/indico_ursh/templates/ursh_custom_shortener_page.html b/ursh/indico_ursh/templates/ursh_custom_shortener_page.html new file mode 100644 index 0000000..30c4721 --- /dev/null +++ b/ursh/indico_ursh/templates/ursh_custom_shortener_page.html @@ -0,0 +1,57 @@ +{% extends 'layout/base.html' %} + +{% block page_class %}fixed-width-standalone-text-page{% endblock %} + +{% block title -%} + {% trans %}Create custom shortcut{% endtrans %} +{%- endblock %} + +{% block content -%} + {% if submitted and not success %} +
+
+ +
{% trans %}Custom URL creation failed:{% endtrans %} {{ msg }}
+
+
+ {% elif submitted and success %} +
+
+ +
{% trans %}Custom URL created successfully{% endtrans %}
+
+
+ {% endif %} +

{% trans %}Creating a custom shortcut for the following URL:{% endtrans %}

+
+ +
+

{% trans %}Please enter the desired shortcut below (at least 5 chars).{% endtrans %}

+
+
+ + + +
+
+ + {% if submitted and success and shorturl %} + + {% endif %} +{%- endblock %} diff --git a/ursh/indico_ursh/templates/ursh_dropdown.html b/ursh/indico_ursh/templates/ursh_dropdown.html new file mode 100644 index 0000000..d3f4afe --- /dev/null +++ b/ursh/indico_ursh/templates/ursh_dropdown.html @@ -0,0 +1,23 @@ + + diff --git a/ursh/indico_ursh/templates/ursh_footer.html b/ursh/indico_ursh/templates/ursh_footer.html new file mode 100644 index 0000000..7732352 --- /dev/null +++ b/ursh/indico_ursh/templates/ursh_footer.html @@ -0,0 +1,8 @@ + + {% trans %}URL Shortener{% endtrans %} + diff --git a/ursh/indico_ursh/templates/ursh_link.html b/ursh/indico_ursh/templates/ursh_link.html new file mode 100644 index 0000000..86ff4c9 --- /dev/null +++ b/ursh/indico_ursh/templates/ursh_link.html @@ -0,0 +1,32 @@ +{% set event_manager = event and event.can_manage(session.user) %} +{% set target = event.url if event else target %} +{% if dropdown or event_manager %} + + +{% else %} + + {{- text -}} + +{% endif %} diff --git a/ursh/indico_ursh/templates/ursh_shortener_page.html b/ursh/indico_ursh/templates/ursh_shortener_page.html new file mode 100644 index 0000000..05d1cfb --- /dev/null +++ b/ursh/indico_ursh/templates/ursh_shortener_page.html @@ -0,0 +1,36 @@ +{% extends 'layout/base.html' %} + +{% block page_class %}fixed-width-standalone-text-page{% endblock %} + +{% block title -%} + {% trans %}URL Shortener{% endtrans %} +{%- endblock %} + +{% block content -%} +

+ {% trans -%} + In this page, you can generate short links for any Indico URL. + Just enter the URL below and click "Shorten". + {%- endtrans %} +

+
+ + +
+ +

+ {% trans -%} + Tip: You can also generate short links by using the quick-links in various + places within Indico! Just look for the icon. + {%- endtrans %} +

+{%- endblock %} diff --git a/ursh/indico_ursh/util.py b/ursh/indico_ursh/util.py new file mode 100644 index 0000000..199adc2 --- /dev/null +++ b/ursh/indico_ursh/util.py @@ -0,0 +1,61 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2002 - 2019 CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from __future__ import unicode_literals + +import json +import posixpath + +import requests +from werkzeug.exceptions import ServiceUnavailable + + +def _get_settings(): + from indico_ursh.plugin import UrshPlugin + api_key = UrshPlugin.settings.get('api_key') + api_host = UrshPlugin.settings.get('api_host') + + if not api_key or not api_host: + raise ServiceUnavailable('Not configured') + + return api_key, api_host + + +def request_short_url(original_url): + from indico_ursh.plugin import UrshPlugin + api_key, api_host = _get_settings() + headers = {'Authorization': 'Bearer {api_key}'.format(api_key=api_key), 'Content-Type': 'application/json'} + url = posixpath.join(api_host, 'api/urls/') + + response = requests.post(url, data=json.dumps({'url': original_url, 'allow_reuse': True}), headers=headers) + response.raise_for_status() + data = response.json() + UrshPlugin.logger.info('Shortcut created: %s -> %s', data['shortcut'], original_url) + return data['short_url'] + + +def register_shortcut(original_url, shortcut, user): + from indico_ursh.plugin import UrshPlugin + api_key, api_host = _get_settings() + headers = {'Authorization': 'Bearer {api_key}'.format(api_key=api_key), 'Content-Type': 'application/json'} + url = posixpath.join(api_host, 'api/urls', shortcut) + data = {'url': original_url, 'allow_reuse': True, 'meta': {'indico.user': user.id}} + + response = requests.put(url, data=json.dumps(data), headers=headers) + if not (400 <= response.status_code < 500): + response.raise_for_status() + + data = response.json() + if not data.get('error'): + UrshPlugin.logger.info('Shortcut created: %s -> %s', data['shortcut'], original_url) + return data + + +def strip_end(text, suffix): + if not text.endswith(suffix): + return text + return text[:len(text) - len(suffix)] diff --git a/ursh/indico_ursh/views.py b/ursh/indico_ursh/views.py new file mode 100644 index 0000000..d47b840 --- /dev/null +++ b/ursh/indico_ursh/views.py @@ -0,0 +1,16 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2002 - 2019 CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from __future__ import unicode_literals + +from indico.core.plugins import WPJinjaMixinPlugin +from indico.web.views import WPDecorated + + +class WPShortenURLPage(WPJinjaMixinPlugin, WPDecorated): + def _get_body(self, params): + return self._get_page_content(params) diff --git a/ursh/setup.py b/ursh/setup.py new file mode 100644 index 0000000..cd5e45c --- /dev/null +++ b/ursh/setup.py @@ -0,0 +1,34 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2002 - 2019 CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from __future__ import unicode_literals + +from setuptools import find_packages, setup + + +setup( + name='indico-plugin-ursh', + version='2.2', + description='URL shortening service for Indico', + url='https://github.com/indico/indico-plugins', + license='MIT', + author='Indico Team', + author_email='indico-team@cern.ch', + packages=find_packages(), + zip_safe=False, + include_package_data=True, + install_requires=[ + 'indico>=2.2.dev0', + ], + classifiers=[ + 'Environment :: Plugins', + 'Environment :: Web Environment', + 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', + 'Programming Language :: Python :: 2.7' + ], + entry_points={'indico.plugins': {'ursh = indico_ursh.plugin:UrshPlugin'}} +) diff --git a/ursh/webpack-bundles.json b/ursh/webpack-bundles.json new file mode 100644 index 0000000..5f28835 --- /dev/null +++ b/ursh/webpack-bundles.json @@ -0,0 +1,5 @@ +{ + "entry": { + "main": "./index.js" + } +}