├── shorty ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_auto_20160322_1720.py │ ├── 0001_initial.py │ └── 0003_auto_20160602_1131.py ├── templatetags │ ├── __init__.py │ └── shorty.py ├── version.py ├── admin.py ├── autoconfig.py ├── app_settings.py ├── urls.py ├── templates │ └── shorty │ │ ├── preview.html │ │ └── home.html ├── forms.py ├── models.py ├── views.py └── tests.py ├── setup.cfg ├── example_project ├── example_project │ ├── __init__.py │ ├── wsgi.py │ └── settings.py └── manage.py ├── MANIFEST.in ├── .gitignore ├── test_settings.py ├── NOTICE ├── setup.py ├── .travis.yml ├── README.rst └── LICENSE /shorty/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /shorty/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /shorty/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = shorty 3 | -------------------------------------------------------------------------------- /example_project/example_project/__init__.py: -------------------------------------------------------------------------------- 1 | '''example project __init__''' 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft shorty/static 2 | graft shorty/templates 3 | include LICENSE 4 | include NOTICE 5 | -------------------------------------------------------------------------------- /shorty/version.py: -------------------------------------------------------------------------------- 1 | """Version for wocose.""" 2 | from gitversion import rewritable_git_version 3 | __VERSION__ = rewritable_git_version(__file__) 4 | 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | example_project/example_project/media 2 | example_project/example_project/static 3 | *.swp 4 | *.pyc 5 | local_settings.py 6 | *.egg 7 | *.eggs 8 | -------------------------------------------------------------------------------- /shorty/admin.py: -------------------------------------------------------------------------------- 1 | '''Shorty Admin''' 2 | 3 | from .models import ShortURL 4 | from django.contrib import admin 5 | 6 | admin.site.register(ShortURL, admin.ModelAdmin) 7 | -------------------------------------------------------------------------------- /shorty/autoconfig.py: -------------------------------------------------------------------------------- 1 | DEFAULT_SETTINGS = { 2 | 'AUTOCONFIG_URL_PREFIXES': { 3 | 'shorty': '', 4 | }, 5 | } 6 | 7 | SETTINGS = { 8 | 'INSTALLED_APPS': [ 9 | 'django.contrib.admin', 10 | 'nuit', 11 | ], 12 | 'NUIT_GLOBAL_TITLE': 'Shorty', 13 | } 14 | -------------------------------------------------------------------------------- /shorty/app_settings.py: -------------------------------------------------------------------------------- 1 | '''Shorty App Settings''' 2 | from django.conf import settings 3 | 4 | 5 | ADMIN_ENABLED = getattr(settings, 'SHORTY_ADMIN_ENABLED', True) 6 | 7 | EXTERNAL_FLAG = getattr(settings, 'SHORTY_EXTERNAL_FLAG', False) 8 | 9 | CANONICAL_DOMAIN = getattr(settings, 'SHORTY_CANONICAL_DOMAIN', None) 10 | -------------------------------------------------------------------------------- /shorty/templatetags/shorty.py: -------------------------------------------------------------------------------- 1 | '''Shorty Template Tags''' 2 | from django import template 3 | from django.core.urlresolvers import reverse 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.simple_tag(takes_context=True) 9 | def build_short_url(context, path): 10 | return context['request'].build_absolute_uri(reverse('redirect', kwargs={'slug': path})) 11 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'ENGINE': 'django.db.backends.sqlite3', 4 | }, 5 | } 6 | 7 | ROOT_URLCONF = 'django_autoconfig.autourlconf' 8 | INSTALLED_APPS = ['shorty',] 9 | STATIC_URL = '/static/' 10 | STATIC_ROOT = '' 11 | 12 | from django_autoconfig.autoconfig import configure_settings 13 | configure_settings(globals()) 14 | -------------------------------------------------------------------------------- /example_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../')) 6 | 7 | if __name__ == "__main__": 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 9 | 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /shorty/urls.py: -------------------------------------------------------------------------------- 1 | '''Shorty URLs''' 2 | from django.conf.urls import url 3 | 4 | from . import views 5 | from . import app_settings 6 | 7 | urlpatterns = [ 8 | url(r'^$', views.do_redirect, name='redirect_base'), 9 | url(r'^(?P[-_\w]+)/?$', views.do_redirect, name='redirect'), 10 | ] 11 | 12 | if app_settings.ADMIN_ENABLED: 13 | urlpatterns = [ 14 | url(r'^admin/$', views.home, name='home'), 15 | url(r'^admin/delete/$', views.delete, name='delete'), 16 | ] + urlpatterns 17 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Ocado Innovation Limited 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /shorty/templates/shorty/preview.html: -------------------------------------------------------------------------------- 1 | {% extend 'nuit/base.html' topbar=True %} 2 | {% load shorty %} 3 | 4 | {% block title %}Redirect Preview{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
9 | 10 |
11 | 12 |

You're being redirected!

13 | 14 |

The link you've clicked is redirecting you to the following URL:

15 | 16 |

{{url.redirect}}

17 | 18 | Continue 19 | 20 |
21 | 22 |
23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /shorty/migrations/0002_auto_20160322_1720.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('shorty', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='shorturl', 16 | options={'ordering': ('created',)}, 17 | ), 18 | migrations.AlterField( 19 | model_name='shorturl', 20 | name='created', 21 | field=models.DateTimeField(auto_now_add=True), 22 | preserve_default=True, 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | from shorty.version import __VERSION__ 4 | 5 | dependencies=[ 6 | 'django', 7 | 'django-autoconfig >= 0.5.0', 8 | 'django-nuit >= 1.0.0, < 2.0.0', 9 | ] 10 | test_dependencies=[ 11 | 'django-setuptest', 12 | 'mock', 13 | ] 14 | 15 | setup( 16 | name='djshorty', 17 | version=__VERSION__, 18 | description='A Django URL shortening app', 19 | author='Ben Cardy', 20 | author_email='ben.cardy@ocado.com', 21 | packages=find_packages(), 22 | install_requires=dependencies, 23 | # To run tests via python setup.py test 24 | tests_require=test_dependencies, 25 | test_suite='setuptest.setuptest.SetupTestSuite', 26 | include_package_data=True, 27 | classifiers=[ 28 | 'License :: OSI Approved :: Apache Software License', 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /shorty/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='ShortURL', 17 | fields=[ 18 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 19 | ('path', models.SlugField(unique=True)), 20 | ('redirect', models.URLField()), 21 | ('created', models.DateField(auto_now_add=True)), 22 | ('user', models.ForeignKey(related_name='short_urls', to=settings.AUTH_USER_MODEL)), 23 | ], 24 | options={ 25 | }, 26 | bases=(models.Model,), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /shorty/migrations/0003_auto_20160602_1131.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.6 on 2016-06-02 11:31 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('shorty', '0002_auto_20160322_1720'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='shorturl', 17 | options={'ordering': ('-created',), 'verbose_name': 'Short URL', 'verbose_name_plural': 'Short URLs'}, 18 | ), 19 | migrations.AddField( 20 | model_name='shorturl', 21 | name='external', 22 | field=models.BooleanField(default=False, help_text=b'Available externally'), 23 | ), 24 | migrations.AlterField( 25 | model_name='shorturl', 26 | name='path', 27 | field=models.SlugField(error_messages={b'unique': b'This short URL is already in use; please choose something different, or leave blank for a random URL'}, unique=True), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /example_project/example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example_project project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 19 | 20 | # This application object is used by any WSGI server configured to use this 21 | # file. This includes Django's development server, if the WSGI_APPLICATION 22 | # setting points here. 23 | from django.core.wsgi import get_wsgi_application 24 | application = get_wsgi_application() 25 | 26 | # Apply WSGI middleware here. 27 | # from helloworld.wsgi import HelloWorldApplication 28 | # application = HelloWorldApplication(application) 29 | -------------------------------------------------------------------------------- /shorty/forms.py: -------------------------------------------------------------------------------- 1 | '''Shorty Forms''' 2 | from django import forms 3 | from django.core.urlresolvers import reverse 4 | from django.utils.safestring import mark_safe 5 | 6 | from .models import ShortURL 7 | from .app_settings import EXTERNAL_FLAG 8 | 9 | 10 | class ShortURLForm(forms.ModelForm): 11 | redirect = forms.URLField(required=True, widget=forms.URLInput(attrs={'placeholder': 'Enter a URL'})) 12 | path = forms.SlugField(required=False, widget=forms.TextInput(attrs={'placeholder': 'short-url'})) 13 | override_existing = forms.CharField(required=False, widget=forms.HiddenInput()) 14 | 15 | def __init__(self, request, *args, **kwargs): 16 | self.request = request 17 | return super(ShortURLForm, self).__init__(*args, **kwargs) 18 | 19 | def clean_override_existing(self): 20 | redirect = self.cleaned_data['redirect'] 21 | override_existing = self.cleaned_data['override_existing'] 22 | 23 | if override_existing != '1': 24 | short_urls = ShortURL.objects.filter(redirect=redirect) 25 | if short_urls: 26 | self.request.previous_short_urls = short_urls 27 | raise forms.ValidationError('That URL has been shortened previously.') 28 | 29 | return override_existing 30 | 31 | class Meta: 32 | model = ShortURL 33 | fields = ['redirect', 'path'] 34 | if EXTERNAL_FLAG: 35 | fields.append('external') 36 | -------------------------------------------------------------------------------- /shorty/models.py: -------------------------------------------------------------------------------- 1 | '''Shorty Models''' 2 | from django.db import models 3 | from django.contrib.auth.models import User 4 | from django.core.urlresolvers import reverse 5 | 6 | import string 7 | import random 8 | 9 | 10 | RANDOM_SLUG_CHOICES = string.ascii_letters + string.digits + '_-' 11 | 12 | 13 | def random_slug(): 14 | while True: 15 | slug = ''.join(random.choice(RANDOM_SLUG_CHOICES) for _ in range(7)) 16 | try: 17 | ShortURL.objects.get(path=slug) 18 | except ShortURL.DoesNotExist: 19 | return slug 20 | 21 | 22 | class ShortURL(models.Model): 23 | path = models.SlugField(unique=True, error_messages={'unique': 'This short URL is already in use; please choose something different, or leave blank for a random URL',}) 24 | redirect = models.URLField() 25 | user = models.ForeignKey(User, related_name='short_urls') 26 | created = models.DateTimeField(auto_now_add=True) 27 | external = models.BooleanField(default=False, help_text='Should this short link be available outside the company\'s network?') 28 | 29 | class Meta: 30 | ordering = ('-created', ) 31 | verbose_name = 'Short URL' 32 | verbose_name_plural = 'Short URLs' 33 | 34 | def __unicode__(self): 35 | return '{}: {}'.format(self.path, self.redirect) 36 | 37 | def save(self, *args, **kwargs): 38 | if not self.path: 39 | self.path = random_slug() 40 | return super(ShortURL, self).save(*args, **kwargs) 41 | 42 | def build_uri(self, request): 43 | return request.build_absolute_uri(reverse('redirect', kwargs={'slug': self.path})) 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | env: 3 | - DJANGO_VERSION=">=1.8, < 1.9" 4 | - DJANGO_VERSION=">=1.9, < 1.10" 5 | - DJANGO_VERSION=">=1.10a1, < 1.11" 6 | python: 7 | - 2.7 8 | - 3.5 9 | matrix: 10 | allow_failures: 11 | - python: 3.5 12 | env: DJANGO_VERSION=">=1.9, < 1.10" 13 | - python: 2.7 14 | env: DJANGO_VERSION=">=1.10a1, < 1.11" 15 | - python: 3.5 16 | env: DJANGO_VERSION=">=1.10a1, < 1.11" 17 | - python: 3.5 18 | env: DJANGO_VERSION=">=1.8, < 1.9" 19 | fast_finish: true 20 | install: 21 | - pip install gitversion 22 | - pip install "Django $DJANGO_VERSION" 23 | - pip install . 24 | - pip install coveralls 25 | script: 26 | - python setup.py test 27 | after_success: 28 | - coveralls 29 | deploy: 30 | provider: pypi 31 | user: ocadotechnology 32 | password: 33 | secure: "eN4q5C00SqUYbmyeHwzacqu9R/FOZnxnPXHE8s3cYsuPeKiq0l3sdm+j6f4PrOslKO16lPqJUtPERJW4G+jsdGEC+l8n8WOsB6xIHkJSRL2QlGvVEv/9GgFrTzYmjmzDY1k8+3Bz/kTf8jfdEXzxthsSsbIEfloAwT+ff0v22tVa3AybnKNwHwznTCme0T5GfgbbIBOncc1EWFQrsvuc/J9EMCz8J8Mw2QDIvludzlb9/Yq4mOmlNGvn1CiYJzaDvhRIRhJ3PCK3Jvf/71Err1ERXxxFKmJUyL9j8FjlPHQSbmkjjqzpIdpSpmX3U8yzXPr7FKi+9QzoXGpwa1rmMmgnFyS+qhnwEIth97+BFsDiF2w8oDngFPfh9AFvhnU7SOiSmbSD1sRtC4rb+vVMvqm7BfZUxhpD6WBIw/nAxU5Juhumi+l6hyeyKPHqVFX5s2yp1mFa1kKN8R8XnPtgQbu5G+S++zKZLKEDyN0vIc4ze0/GnQFEr3HY57M0KkIc9LwjRjnZn526O7aLLCFY4xMRf78CkBB2nbF8deUwAqQGYj1ddgo3NquVCu5g+mA41hmsQmkVetIA34iB9XoCtBD9H8GT4ExDZa/QBd41pOqOHTYmwGC3NwZSiFooCuKZke0Fdwi2FmlHM69NBzaorKCl0SNKKwsJBo5gFGjCj50=" 34 | distributions: "bdist_wheel sdist" 35 | on: 36 | all_branches: true 37 | condition: $DJANGO_VERSION = ">=1.9, < 1.10" 38 | repo: ocadotechnology/djshorty 39 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Shorty 2 | ====== 3 | 4 | A URL shortening app written in Django. 5 | 6 | Shortened URLs will work at either shorty.example.com/short-url or short-url.shorty.example.com (i.e. the short path can either be in the URL, or a subdomain). 7 | 8 | Quick Start 9 | ----------- 10 | 11 | Get started with Shorty by following these steps: 12 | 13 | * Install ``djshorty`` with ``pip``:: 14 | 15 | pip install djshorty 16 | 17 | * Add ``shorty`` to your ``INSTALLED_APPS`` in ``settings.py``:: 18 | 19 | INSTALLED_APPS = ( 20 | ... 21 | 'shorty', 22 | ... 23 | ) 24 | 25 | * Either set ``short.urls`` as your ``ROOT_URLCONF``, or include it in your own ``urls.py``. 26 | 27 | * Shorty relies on django-autoconfig_, which requires the following at the end of ``settings.py``:: 28 | 29 | from django_autoconfig.autoconfig import configure_settings 30 | configure_settings(globals()) 31 | 32 | 33 | Settings 34 | -------- 35 | 36 | Shorty provides the following settings: 37 | 38 | * ``SHORTY_EXTERNAL_FLAG``: If Shorty is deployed in a corporate environment, and you want the ability for some short URLs to resolve outside the company and others to remain internal, set this to ``True``. URLs not marked as 'external' will require authentication. This is designed to work with a Single Sign On solution. 39 | 40 | * ``SHORTY_CANONICAL_DOMAIN``: Set this to normalise the domain before redirection. This is useful if you have multiple domains, but the SSO system (see above) requires a single domain to work. For example, if Shorty is primarily deployed at ``https://shorty.example.com/``, but you also allow short URLs to resolve at ``https://.shorty.example.com``, you may need to set this to ``'https://shorty.example.com'``. It should include the scheme (http or https), and not end with a trailing slash. 41 | 42 | Contributing 43 | ------------ 44 | 45 | To contribute, fork the repo, do your work, and issue a pull request. We ask that contributors adhere to `PEP8 `_ standards, and include full tests for all their code. 46 | 47 | .. _`django-autoconfig`: http://github.com/mikebryant/django-autoconfig/ 48 | -------------------------------------------------------------------------------- /example_project/example_project/settings.py: -------------------------------------------------------------------------------- 1 | '''Django settings for example_project project.''' 2 | import os 3 | 4 | DEBUG = True 5 | TEMPLATE_DEBUG = DEBUG 6 | THUMBNAIL_DEBUG = True 7 | 8 | DATABASES = { 9 | 'default': { 10 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 11 | 'NAME': os.path.join(os.path.abspath(os.path.dirname(__file__)), 'dbfile'), # Or path to database file if using sqlite3. 12 | 'USER': '', # Not used with sqlite3. 13 | 'PASSWORD': '', # Not used with sqlite3. 14 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 15 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 16 | } 17 | } 18 | 19 | # Local time zone for this installation. Choices can be found here: 20 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 21 | # although not all choices may be available on all operating systems. 22 | # In a Windows environment this must be set to your system time zone. 23 | TIME_ZONE = 'Europe/London' 24 | 25 | # Language code for this installation. All choices can be found here: 26 | # http://www.i18nguy.com/unicode/language-identifiers.html 27 | LANGUAGE_CODE = 'en-gb' 28 | 29 | # Absolute path to the directory static files should be collected to. 30 | # Don't put anything in this directory yourself; store your static files 31 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 32 | # Example: "/home/media/media.lawrence.com/static/" 33 | STATIC_ROOT = os.path.join(os.path.dirname(__file__), 'static') 34 | 35 | # URL prefix for static files. 36 | # Example: "http://media.lawrence.com/static/" 37 | STATIC_URL = '/static/' 38 | 39 | # Make this unique, and don't share it with anybody. 40 | SECRET_KEY = 'NOT-A-SECRET' 41 | 42 | ROOT_URLCONF = 'django_autoconfig.autourlconf' 43 | APPEND_SLASHES = False 44 | 45 | # Python dotted path to the WSGI application used by Django's runserver. 46 | WSGI_APPLICATION = 'example_project.wsgi.application' 47 | 48 | INSTALLED_APPS = ( 49 | 'shorty', 50 | ) 51 | 52 | LOGIN_URL = 'admin:login' 53 | LOGOUT_URL = 'admin:logout' 54 | 55 | try: 56 | from example_project.local_settings import * 57 | except ImportError: 58 | pass 59 | 60 | SHORTY_EXTERNAL_FLAG = True 61 | 62 | from django_autoconfig import autoconfig 63 | autoconfig.configure_settings(globals()) 64 | -------------------------------------------------------------------------------- /shorty/views.py: -------------------------------------------------------------------------------- 1 | '''Shorty Views''' 2 | 3 | from django.shortcuts import render 4 | from django.http import HttpResponseRedirect, HttpResponseBadRequest, Http404 5 | from django.views.decorators.http import require_POST 6 | from django.contrib.auth.decorators import login_required 7 | from django.core.urlresolvers import reverse, NoReverseMatch 8 | from django.utils.six.moves.urllib.parse import urlparse 9 | from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger 10 | 11 | from .models import ShortURL 12 | from .forms import ShortURLForm 13 | from .app_settings import EXTERNAL_FLAG, CANONICAL_DOMAIN 14 | 15 | 16 | def check_initial_redirect(request, path_slug): 17 | 18 | if not CANONICAL_DOMAIN: 19 | return None 20 | 21 | canonical_scheme, canonical_netloc = urlparse(CANONICAL_DOMAIN)[:2] 22 | current_scheme, current_netloc = urlparse(request.build_absolute_uri())[:2] 23 | if not (current_scheme == canonical_scheme and current_netloc == canonical_netloc): 24 | qs = request.META['QUERY_STRING'] 25 | if qs: 26 | qs = '?{}'.format(qs) 27 | return '{}/{}{}'.format(CANONICAL_DOMAIN, path_slug, qs) 28 | 29 | 30 | def do_redirect(request, slug=None): 31 | 32 | path_slug = slug 33 | if not path_slug: 34 | try: 35 | path_slug = request.META['HTTP_HOST'].split('.', 1)[0] 36 | except KeyError: 37 | return HttpResponseBadRequest() 38 | 39 | initial_redirect = check_initial_redirect(request, path_slug) 40 | 41 | if initial_redirect: 42 | return HttpResponseRedirect(initial_redirect) 43 | 44 | try: 45 | short_url = ShortURL.objects.get(path=path_slug) 46 | except ShortURL.DoesNotExist: 47 | if not slug: 48 | return HttpResponseRedirect(reverse('home')) 49 | raise Http404 50 | 51 | if EXTERNAL_FLAG and not short_url.external and not request.user.is_authenticated(): 52 | return login_required(lambda _: None)(request) 53 | 54 | if 'preview' in request.GET or 'p' in request.GET: 55 | return render(request, 'shorty/preview.html', { 56 | 'url': short_url, 57 | }) 58 | 59 | return HttpResponseRedirect(short_url.redirect) 60 | 61 | 62 | @login_required 63 | def home(request): 64 | 65 | path = None 66 | 67 | if 'path' in request.session: 68 | try: 69 | path = request.build_absolute_uri(reverse('redirect', kwargs={'slug': request.session['path']})) 70 | except NoReverseMatch: 71 | pass 72 | del request.session['path'] 73 | 74 | if request.method == 'POST': 75 | form = ShortURLForm(request, request.POST) 76 | if form.is_valid(): 77 | model = form.save(commit=False) 78 | model.user = request.user 79 | model.save() 80 | request.session['path'] = model.path 81 | return HttpResponseRedirect(reverse('home')) 82 | else: 83 | form = ShortURLForm(request) 84 | 85 | paginator = Paginator(request.user.short_urls.all().order_by('-created'), 10) 86 | 87 | page = request.GET.get('page') 88 | try: 89 | short_urls = paginator.page(page) 90 | except PageNotAnInteger: 91 | short_urls = paginator.page(1) 92 | except EmptyPage: 93 | short_urls = paginator.page(paginator.num_pages) 94 | 95 | return render(request, 'shorty/home.html', { 96 | 'EXTERNAL_FLAG': EXTERNAL_FLAG, 97 | 'path': path, 98 | 'form': form, 99 | 'short_urls': short_urls, 100 | 'redirect_base': request.build_absolute_uri(reverse('redirect_base')), 101 | }) 102 | 103 | 104 | @login_required 105 | @require_POST 106 | def delete(request): 107 | try: 108 | url = ShortURL.objects.get(pk=request.POST['id_short_url'], user=request.user) 109 | except (ShortURL.DoesNotExist, KeyError): 110 | return HttpResponseBadRequest() 111 | url.delete() 112 | return HttpResponseRedirect(reverse('home')) 113 | 114 | -------------------------------------------------------------------------------- /shorty/templates/shorty/home.html: -------------------------------------------------------------------------------- 1 | {% extend 'nuit/base.html' topbar=True %} 2 | {% load shorty %} 3 | 4 | {% block title %}URL Shortener{% endblock %} 5 | 6 | {% block css %} 7 | 8 | 39 | 40 | {% endblock %} 41 | 42 | {% block scripts %} 43 | 44 | 45 | 46 | 61 | 62 | {% endblock %} 63 | 64 | {% block content %} 65 | 66 |
67 | 68 |
69 | 70 |
71 |
72 |
73 |

Shorten a URL

74 |
75 |
76 | {% if request.previous_short_urls %} 77 |
78 |
79 |
80 |

That URL has already been shortened to:

81 | 86 |

Are you sure you wish to create a new short URL?

87 | 88 |
89 |
90 |
91 | {% endif %} 92 |
93 |
94 | {% if EXTERNAL_FLAG %} 95 | {% foundation_form form %} 96 | redirect {'show_label': False} 97 | path {'show_label': False, 'prefix': '{{redirect_base}}', 'prefix_small': 6, 'small': 12, 'medium': 9}; external {'switch': True} 98 | {% end_foundation_form %} 99 | {% else %} 100 | {% foundation_form form %} 101 | redirect {'show_label': False} 102 | path {'show_label': False, 'prefix': '{{redirect_base}}', 'prefix_small': 6} 103 | {% end_foundation_form %} 104 | {% endif %} 105 |
106 |
107 |
108 |
109 | 110 |
111 |
112 |
113 | 114 |
115 |
116 | 117 | {% if path %} 118 |
Your URL has been shortened!
119 |
120 |
121 | 122 |
123 |
124 | 125 |
126 |
127 |
128 | {% endif %} 129 | 130 | {% if request.user.short_urls.exists %} 131 | 132 | 133 | 134 | 135 | 136 | {% if EXTERNAL_FLAG %}{% endif %} 137 | 138 | 139 | 140 | 141 | {% for url in short_urls %} 142 | 143 | 146 | 150 | 153 | {% if EXTERNAL_FLAG %} 154 | 157 | {% endif %} 158 | 165 | 166 | {% endfor %} 167 | 168 |
Short URLCreatedAvailable Externally 
144 | 145 | 147 | {% build_short_url url.path %} 148 |
{{url.redirect}} 149 |
151 | {{url.created|date:'d/m/Y'}} {{url.created|time:'H:i'}} 152 | 155 | 156 | 159 |
160 | {% csrf_token %} 161 | 162 | 163 |
164 |
169 | 170 | {% pagination_menu short_urls %} 171 | 172 | {% endif %} 173 | 174 |
175 |
176 | 177 |
178 | 179 |
180 | 181 | {% endblock %} 182 | -------------------------------------------------------------------------------- /shorty/tests.py: -------------------------------------------------------------------------------- 1 | '''Shorty Tests''' 2 | 3 | from django.test import TestCase, Client 4 | from django.contrib.auth.models import User 5 | 6 | import mock 7 | 8 | from .models import ShortURL 9 | from .forms import ShortURLForm 10 | from . import views 11 | 12 | 13 | class UserTestCase(TestCase): 14 | def setUp(self): 15 | self.user = User.objects.create_user('test', 'test@test.com', 'test') 16 | super(UserTestCase, self).setUp() 17 | 18 | 19 | class ModelTestCase(UserTestCase): 20 | 21 | def test_random_slug_generation(self): 22 | one = ShortURL.objects.create(redirect='http://www.google.com', user=self.user) 23 | two = ShortURL.objects.create(redirect='http://www.foo.com', user=self.user) 24 | self.assertNotEqual(one.path, two.path) 25 | self.assertNotEqual(one.path, '') 26 | self.assertNotEqual(two.path, '') 27 | 28 | 29 | class RedirectViewTestCase(UserTestCase): 30 | 31 | def setUp(self): 32 | super(RedirectViewTestCase, self).setUp() 33 | ShortURL.objects.create(redirect='http://www.google.com', path='google', user=self.user, external=True) 34 | ShortURL.objects.create(redirect='http://www.foo.com', path='foo', user=self.user) 35 | self.client = Client() 36 | 37 | def test_missing_slug(self): 38 | '''Missing slugs should raise a 404''' 39 | response = self.client.get('/404') 40 | self.assertEqual(response.status_code, 404) 41 | 42 | def test_no_slug(self): 43 | '''No slug at all should redirect to admin''' 44 | response = self.client.get('/', HTTP_HOST='shorty.example.com') 45 | self.assertRedirects(response, '/admin/', host='shorty.example.com', fetch_redirect_response=False) 46 | 47 | def test_no_slug_no_host_header(self): 48 | '''No slug (and no Host header) should return a 400 Bad Request''' 49 | response = self.client.get('/') 50 | self.assertEqual(response.status_code, 400) 51 | 52 | def test_unauthenticated_redirect_canonical_false_external_false(self): 53 | '''Unauthenticated clients - no settings: should simply get redirects''' 54 | response = self.client.get('/google') 55 | self.assertRedirects(response, 'http://www.google.com', fetch_redirect_response=False) 56 | response = self.client.get('/foo/') 57 | self.assertRedirects(response, 'http://www.foo.com', fetch_redirect_response=False) 58 | 59 | @mock.patch.object(views, 'EXTERNAL_FLAG', True) 60 | def test_unauthenticated_redirect_canonical_false_external_true(self): 61 | '''Unauthenticated clients, external access''' 62 | with self.settings(LOGIN_URL='/login/'): 63 | # External=True objects should return redirect 64 | response = self.client.get('/google') 65 | self.assertRedirects(response, 'http://www.google.com', fetch_redirect_response=False) 66 | # External=False objects should force login 67 | response = self.client.get('/foo/') 68 | self.assertRedirects(response, '/login/?next=/foo/', fetch_redirect_response=False) 69 | 70 | @mock.patch.object(views, 'CANONICAL_DOMAIN', 'http://shorty.example.com') 71 | def test_unauthenticated_redirect_canonical_true_external_false(self): 72 | '''Unauthenticated clients, no external access, canonical domain''' 73 | # Matching domain and scheme should redirect like usual 74 | response = self.client.get('/google', HTTP_HOST='shorty.example.com') 75 | self.assertRedirects(response, 'http://www.google.com', fetch_redirect_response=False) 76 | # Not matching domain should redirect to matching domain 77 | response = self.client.get('/google', HTTP_HOST='shorty1.example.com') 78 | self.assertRedirects(response, 'http://shorty.example.com/google', fetch_redirect_response=False) 79 | 80 | @mock.patch.object(views, 'CANONICAL_DOMAIN', 'https://shorty.example.com') 81 | def test_unauthenticated_redirect_canonical_https_external_false(self): 82 | '''Unauthenticated clients, no external access, canonical domain''' 83 | # Non matching schcme should redirect to matching 84 | response = self.client.get('/google', HTTP_HOST='shorty.example.com') 85 | self.assertRedirects(response, 'https://shorty.example.com/google', fetch_redirect_response=False) 86 | 87 | @mock.patch.object(views, 'EXTERNAL_FLAG', True) 88 | def test_authenticated_redirect_canonical_false_external_true(self): 89 | '''Authenticated clients, external access, redirects should work''' 90 | self.client.login(username='test', password='test') 91 | response = self.client.get('/foo') 92 | self.assertRedirects(response, 'http://www.foo.com', fetch_redirect_response=False) 93 | 94 | def test_preview(self): 95 | '''Test preview domain''' 96 | response = self.client.get('/google?preview=True') 97 | self.assertEqual(response.status_code, 200) 98 | self.assertInHTML("http://www.google.com", response.content) 99 | response = self.client.get('/google?p=True') 100 | self.assertEqual(response.status_code, 200) 101 | self.assertInHTML("http://www.google.com", response.content) 102 | 103 | @mock.patch.object(views, 'CANONICAL_DOMAIN', 'http://shorty.example.com') 104 | def test_preview_preseved_over_canonical_redirect(self): 105 | '''Unauthenticated clients, no external access, canonical domain''' 106 | # Not matching domain should redirect to matching domain 107 | response = self.client.get('/google?preview=True', HTTP_HOST='shorty1.example.com') 108 | self.assertRedirects(response, 'http://shorty.example.com/google?preview=True', fetch_redirect_response=False) 109 | 110 | 111 | class AuthenticatedUITestCase(UserTestCase): 112 | 113 | def setUp(self): 114 | super(AuthenticatedUITestCase, self).setUp() 115 | ShortURL.objects.create(redirect='http://www.google.com', path='google', user=self.user, external=True) 116 | ShortURL.objects.create(redirect='http://www.foo.com', path='foo', user=self.user) 117 | self.client = Client() 118 | self.client.login(username='test', password='test') 119 | 120 | def test_delete_short_url(self): 121 | id_short_url = ShortURL.objects.get(path='google').pk 122 | response = self.client.post('/admin/delete/', {'id_short_url': id_short_url}) 123 | self.assertRedirects(response, '/admin/', fetch_redirect_response=False) 124 | response = self.client.post('/admin/delete/', {'id_short_url': id_short_url}) 125 | self.assertEqual(response.status_code, 400) 126 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------