lnk.dk er i luften med dejlige forkortede links

For noget tid siden fortalte jeg om en prototype på en linkforkorter, jeg havde lavet. Den var ikke særlig brugbar, for den lå på wallnot.dk og lavede derfor ikke specielt korte links.

Efter en behagelig og ukompliceret dialog med de flinke advokater hos Kønig Advokater, der ejede brugsretten til domænenavnet lnk.dk, har jeg fået lov til at overtage lnk.dk – og nu er en opdateret udgave af min kortlinkservice i luften.

lnk.dk kan du lave automatisk generede korte links (tænk lnk.dk/ab0g) eller selv vælge, hvad dit korte link skal hedde (tænk lnk.dk/morten).

Som sædvanlig har jeg brugt Django til arbejdet.

I models.py definerer jeg datamodellen, lidt ekstra validering til brug i formularen til at lave korte links, og hvad jeg gerne vil se i admin-interfacet for siden:

from django.db import models
from django.utils import timezone
from django.contrib import admin
from django.core.exceptions import ValidationError

def validate_destination(destination):
	if "lnk.dk/" in destination.lower():
		raise ValidationError('For at undgå risiko for uendelige viderestillinger, kan du ikke tilføje korte links fra lnk.dk som destination.')
		
def validate_shortlink(shortlink):
	if shortlink == "om":
		raise ValidationError('Det korte link "om" bruger lnk.dk til at fortælle om lnk.dk. Vælg et andet selvvalgt kort link.')
	elif shortlink == "administration":
		raise ValidationError('Det korte link "administration" bruger lnk.dk til at administrere lnk.dk. Vælg et andet selvvalgt kort link.')

class Link(models.Model):
	destination = models.URLField('Destinationslink', max_length=65535, validators=[validate_destination])
	shortlink = models.SlugField('Kort link', max_length=100, unique=True, allow_unicode=True, validators=[validate_shortlink])
	LINK_TYPE_CHOICES = (
		('automatic', 'Automatisk'),
		('manual', 'Manuelt'),
	)	
	type = models.CharField('Type', max_length=10, choices=LINK_TYPE_CHOICES)
	date = models.DateTimeField(default=timezone.now, editable=False)
	
class LinkAdmin(admin.ModelAdmin):
	list_display = ('destination','shortlink','type','date')
	list_filter = ('type', )
	search_fields = ['destination']

I forms.py definerer jeg formularen, som brugeren indtaster sit lange link og evt. et selvvalgt kort link i. Jeg forsøger også at formulere nogle forståelige fejlmeddelelser:

from django.forms import ModelForm
from .models import Link

class LinkForm(ModelForm):
	def __init__(self, *args, **kwargs):
		super(LinkForm, self).__init__(*args, **kwargs)
		self.fields['destination'].widget.attrs['placeholder'] = 'https://eksempel.dk/meget/lang/url'
		self.fields['shortlink'].widget.attrs['placeholder'] = 'eksempel'
		self.fields['shortlink'].label_suffix = ""	# Remove colon after label
		self.fields['shortlink'].required = False	# Not required in form
	
	class Meta:
		model = Link
		fields = ['destination', 'shortlink']
		labels = {
			'shortlink': ('Evt. selvvalgt kort link, lnk.dk/'),
		}
		error_messages = {
			'destination': {
				'max_length': ('Din destinationsurl er for lang til denne kortlinkservice.'),
				'invalid': ('Din destinationsurl er ikke en gyldig adresse. Husk http://, https:// eller ftp:// foran dit link, hvis du har glemt det.'),
			},
			'shortlink': {
				'unique': ('Det selvvalgte link, du har valgt, er allerede i brug. Find på et andet.'),
				'max_length': ('Dit selvvalgte link må maksimalt være 100 tegn langt.'),
				'invalid': ('Du kan kun bruge unicode-bogstaver, cifre, bindestreg og understreg i din selvvalgte adresse.'),
			}
		}

Mine visninger forberedes i views.py som har:

  • En funktion til autogenerede kortlinks. Linket tilføjet en tilfældig streng hashes til en ny tilfældig streng. Der tilføjes en tilfældig streng hver gang for at sikre, at der genereres en ny streng hver gang (for at undgå en uendelig løkke, hvis et links hash-værdi skulle kollidere med et andet links hashværdi).
  • En visning til forsiden med dens formular, validering af formularen og visning af kortlink og eventuelle fejl.
  • En visning, der sørger for at viderestille fra et kort link til et destinationslink, hvis det korte link findes. Ellers vises en fejlside.
  • En visning til en “om lnk.dk”-side.
from django.shortcuts import render
from django.http import HttpResponseRedirect
from .models import Link
from .forms import LinkForm
from django.urls import reverse
import hashlib
import bcrypt

# Function to create a random hash to use as short link address
def create_shortlink(destination):
	salt = bcrypt.gensalt().decode()	# Random salt
	destination = destination+salt		# Salt added to destination URL
	hash = hashlib.md5(destination.encode()).hexdigest() # Hashed to alphanumeric string
	return hash[:4]	# First 5 characters of that string 

# Front page with a form to enter destination address. Short URL returned.
def linkindex(request):
	form = LinkForm()	# Loads form
	# If a destination is submitted, a short link is returned
	if request.method == 'POST':
		form = LinkForm(request.POST) # Form instance with submitted data
		# Check whether submitted data is valid
		if form.is_valid():
			destination = form.cleaned_data['destination'] # Submitted destination
			shortlink = form.cleaned_data['shortlink'] # Submitted slug
		
			# User has specified a unique (validated) short link
			if shortlink:
				link = form.save(commit=False)
				link.type = "manual"
				link = form.save()
				site_url = reverse('redirect', args=[link.shortlink])
				sharelink = request.build_absolute_uri(site_url)
			# User wants an automatic link
			else:
				# If a short link with same destionation of same type already exists,
				# it is fetched from database  and served. No need to use up a new
				# URL for the same destination.
				try:
					link = Link.objects.get(destination=destination, type="automatic")
					site_url = reverse('redirect', args=[link.shortlink])
					sharelink = request.build_absolute_uri(site_url)
				# If a link of same type with same destination does not exist, one is
				# created.
				except:	
					# Loop to create a unique hash value for short link
					unique_link = False
					while unique_link == False:
						hash = create_shortlink(destination)	# Return hash
						# First we check whether the hash is a duplicate
						try:
							Link.objects.get(shortlink=hash)	# Check whether hash is used
						# If not a duplicate, an error is thrown, and we can save the hash
						except:
							link = form.save(commit=False)	# Prepare to save form destination data and hash
							link.shortlink = hash	# Sets short link to hash value
							link.type = "automatic"
							link.save()	# Saves destination and short link to database
							site_url = reverse('redirect', args=[link.shortlink])
							sharelink = request.build_absolute_uri(site_url)
							unique_link = True	# If check causes error, hash is unused, exit loop
			context = {'sharelink': sharelink, 'form': form}	# Dictionary with variables used in template
			return render(request, 'links/index.html', context)
		# If form is invalid, check whether a user is trying to create a duplicate manual
		# shortlink.
		else:
			# Check if there's a valid destination link
			if 'destination' in form.cleaned_data:
				# Check if there's a shortlink that's not unique
				try:
					link = Link.objects.get(shortlink=form.data['shortlink'])
					# If so, check whether the destination is the same
					if form.cleaned_data['destination'] == link.destination:
						# Show sharelink to user
						site_url = reverse('redirect', args=[link.shortlink])
						sharelink = request.build_absolute_uri(site_url)
						form.errors['shortlink'] = "" # Error replaced by empty string
					# Render form with sharelink already used error
					else:
						sharelink = ""
				# Render form with error
				except:	
					sharelink = ""
			# Render form with errors	
			else:
				sharelink = ""
		context = {'form': form, 'sharelink': sharelink}
		return render(request, 'links/index.html', context)
	# Render page with form before user has submitted
	context = {'form': form}
	return render(request, 'links/index.html', context)

# Short link redirect to destination URL
def redirect(request, shortlink):
	# Query the database for short link, if there is a hit, redirect to destination URL
	try:
		link = Link.objects.get(shortlink=shortlink)
		return HttpResponseRedirect(link.destination)
	# An error means the short link doesn't exist, so error 404 is shown
	except:
		return render(request, 'links/404.html', status=404)

# About page		
def about(request):
	context = {'request': request}
	return render(request, 'links/about.html', context)	

urls.py sørger for at forbinde den adressse, brugeren har tastet i browseren, med de rette visninger fra views.py:

from django.urls import path
from . import views

urlpatterns = [
    path('', views.linkindex, name='index'),
	path('om', views.about, name='about'),
    path('<shortlink>', views.redirect, name='redirect'),
]

Endelig har jeg skabelon-filer, der sørger for selve html-koden på siden. base.html er min overordnede skabelon med det overordnede design, meta-tags, sidefod osv.:

{% load static %}{% spaceless %}<!doctype html>
<html lang="da">
	<head>
		<title>lnk.dk: Danmarks korteste links</title>
		<meta charset="utf-8"/>
		<meta name="description" content="Lav de korteste korte kortlinks gratis på lnk.dk. Fri for annoncer og overvågning.">
		<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
		<link rel="stylesheet" href="{% static "links/style.css" %}">
		<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
		<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
		<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
		<link rel="manifest" href="/site.webmanifest">
		<meta name="theme-color" content="#ffffff">
	</head>
<body>
{% block content %}{% endblock %}
<div class="footer">
	<p>Lav de korteste korte links gratis på lnk.dk. Fri for annoncer og overvågning. <a href="{% url 'about' %}">Om lnk.dk</a></p>
</div>
</body>
</html>
{% endspaceless %}

Og her er skabelonen til forsiden, index.html:

{% extends "links/base.html" %}{% block content %}{% spaceless %}
<h1>Lav et kort link</h1>
<div class="content">
	{% if form %}
		<form method="post">
		{% csrf_token %}
		<div class="form_field">
			<div class="label">
				{{ form.destination.label_tag}}
			</div>
			<div>{{ form.destination }}</div>
			{{ form.destination.errors }}
		</div>
		<div class="form_field">
			<div class="label">
				{{ form.shortlink.label_tag}}
			</div>
			<div>{{ form.shortlink }}</div>
			{{ form.shortlink.errors }}
		</div>
		<p><button type="submit" value="Giv mig et kort link">Giv mig et kort link</button></p>
		</form>

		{% if request.method == "POST" and not form.destination.errors and not form.shortlink.errors  %}
			<h1>Her er dit link:</h1>
			<p class="sharelink"><a href="{{ sharelink }}">{{ sharelink }}</a></p>
			<button class="copy">Kopier link</button>
		{% endif %} 

	{% endif %}

	{% if error %}
		<h2>Har du tastet forkert?</h2>
		<p><em>Du har prøvet at bruge et kort link. Desværre er det link, du har tastet, ikke registreret. Måske er du kommet til at taste forkert?</em></p>
		<p><a href="{% url 'index' %}">Til forsiden</a>
	{% endif %} 
</div>

{% if request.method == "POST" and not form.destination.errors %}
<script>
function fallbackCopyTextToClipboard(text) {
  var textArea = document.createElement("textarea");
  textArea.value = text;
  document.body.appendChild(textArea);
  textArea.focus();
  textArea.select();
  document.execCommand("copy");
  document.body.removeChild(textArea);
}
  
function copyTextToClipboard(text) {
  if (!navigator.clipboard) {
    fallbackCopyTextToClipboard(text);
    return;
  }
  navigator.clipboard.writeText(text);
}

var copy = document.querySelector('.copy');

copy.addEventListener('click', function(event) {
  copyTextToClipboard('{{ sharelink }}');
});
</script>
{% endif %}
{% endspaceless %}{% endblock %}

Er du nået hertil? Se det i praksis på lnk.dk!