Kategorier
blandet

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!

Kategorier
blandet

Kortlinkværktøj med Django/Python

Der er nok ikke mange mennesker efterhånden, der ikke har deres egen kortlinkservice. En af de mest kendte er https://bitly.com/.

Som en øvelse har jeg lavet kortlinkservicen https://wallnot.dk/link. Linkene bliver godt nok ikke specielt korte, men indtil videre sparer jeg udgiften til et selvstændigt domænenavn. Det er ikke fordi, der mangler muligheder andre steder.

At lave et kortlink-værktøj i Django er overraskende nemt.

Her er en lille opskrift.

Opskrift på kortlinkværktøj

Efter at have oprettet mit projekt (se evt. guide på https://www.djangoproject.com/start/) går jeg i gang.

Jeg starter med min datamodel i models.py. Hvert link har en destination (det lange link), et kort link og et tidsstempel. Destinationen er en URL, det korte link er et antal tegn og tidsstemplet er – et tidsstempel:

from django.db import models
from django.utils import timezone

class Link(models.Model):
    destination = models.URLField(max_length=500)
    shortlink = models.CharField(max_length=6, unique=True)
    date = models.DateTimeField(default=timezone.now, editable=False)

Jeg ved, at jeg skal bruge en formular. Den opretter jeg i forms.py. Her bruger jeg en type formular, der kaldes ModelForm. Django sørger for, at valideringsreglerne følger samme type data, som jeg har i min bagvedliggende datamodel:

from django.forms import ModelForm, URLInput
from .models import Link

class LinkForm(ModelForm):
    class Meta:
        model = Link
        fields = ['destination']
        widgets = {
            'destination': URLInput(attrs={'placeholder': 'Indsæt link'}),
        }

Logikkerne bag de enkelte visninger i Django laves i views.py. Jeg har to forskellige visninger. Én visning som jeg bruger til at vise min forside, hvor jeg både viser min formular til indtastning af links og det korte link (index). Én visning, som aktiveres når brugeren besøger et kort link (redirect).

Endelig har jeg en funktion, som jeg bruger til at generere selve de korte links.

Jeg har kommenteret koden en masse, så jeg håber den er til at følge med i:

from django.shortcuts import render
from django.http import HttpRequest, HttpResponseRedirect
from .models import Link
from .forms import LinkForm
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[:6]	# First 6 characters of that string 

# Front page with a form to enter destination address. Short URL returned.
def index(request):
	form = LinkForm()	# Loads form
	url = 'https://wallnot.dk/link/'	# site url
	# 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
			# If destination is already in database, return short link for destination from database
			try:
				link = Link.objects.get(destination=destination)
				sharelink = url + link.shortlink # Creates full URL using page URL and hash
			# If destination is not in database, create a new short link
			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.save()	# Saves destination and short link to database
						sharelink = url + link.shortlink # Creates full URL using page URL and hash
						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, just renders page.
		else:
			context = {'form': form}
			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 the front page template is shown with an error variable
	except:
		error = True
		context = {'error': error}
		return render(request, 'links/index.html', context)

For at kunne servere siderne, har jeg urls.py, der fortæller Django hvordan en indtastet URL af brugeren skal pege på funktioner i views.py:

from django.urls import path
from . import views

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

Og endelig har jeg index.html, som er den skabelon, som min side genereres på baggrund af. Hvis du ikke har prøvet Django før, så læg mærke til alt det, der står i tuborgklammer ({}). De bruges dels til simple funktioner (fx if-funktioner, dels til at indsætte variable fra views.py i den side, der genereres.

Hvis du lægger mærke til funktionerne, bruger jeg if-funktionerne til at nøjes med en skabelon, uanset hvilken situation brugeren er havnet i, sådan at indholdet fx er anderledes, når brugeren har lavet en fejl i udfyldelsen af formularen, end når brugeren ikke har udfyldt formularen endnu.

Der er også et lille javascript i filen, der sørger for at brugeren kan kopiere det korte link til sin udklipsholder.

<!doctype html>
<html lang="da">
  <head>
    <!-- Required meta tags -->
	<title>Korte links</title>
	<meta name="description" content="Skønne korte links">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
	<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">
	<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
	<meta name="msapplication-TileColor" content="#ffc40d">
	<meta name="theme-color" content="#ffffff">

	<style>
	body { 
		font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
		text-align: center;
		box-sizing: border-box;	
	}

	h1 {
		margin-top: 0;
		font-size: 4.0rem;
		font-weight: 300;
		line-height: 1.2;
		margin-bottom: 1.5rem;
	}

	h2 {
		margin-top: 1.5rem;
		font-size: 2.5rem;
		font-weight: 300;
		line-height: 1.2;
		margin-bottom: 1.5rem;
	}

	input {
		width: 60%;
		line-height: 1.2;
		font-size: 1.0rem;
		height: 1.5rem;
		padding: 10px;
	}

	button {
		width: 50%;
		border: 1px solid transparent;
		padding: .375rem .75rem;
		font-size: 1rem;
		line-height: 1.8;
		height: 2.5rem;
		border-radius: .25rem;
		color: #fff;
		background-color: #28a745;
		border-color: #28a745;
	}

	button:focus {
		box-shadow: 0 0 0 0.2rem rgba(72,180,97,.5)
	}

	button:hover {
		background-color: #218838;
		border-color: #1e7e34;
	}
	
	.footer {
		position: fixed;
		left: 0;
		bottom: 0;
		width: 100%;
		background-color: #f1f1f1;
		color: black;
	}	
	</style>
  </head>
  <body>

<h1>Lav et kort link</h1>

{% if form %}
	<form method="post">
	{% csrf_token %}
	<p>{{ form.destination }}</p>
	<p><button type="submit" value="Lav et kort link">Lav et kort link</button></p>
	</form>

	{% if form.destination.errors %}
		<h2>Tast et gyldigt link!</h2>
		<p><em>Du har tastet et ugyldigt link. Prøv igen med et gyldigt link med http://, https://, ftp:// eller ftps:// foran.</em></p>
	{% endif %}

	{% if request.method == "POST" and not form.destination.errors %}
		<h2>Her er dit link:</h2>
		<p><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 class="footer">
  <p>Lav relativt korte links på wallnot.dk. Gratis og fri for annoncer og overvågning.</p>
</div>


<script>
function fallbackCopyTextToClipboard(text) {
  var textArea = document.createElement("textarea");
  textArea.value = text;
  document.body.appendChild(textArea);
  textArea.focus();
  textArea.select();

  try {
    var successful = document.execCommand("copy");
    var msg = successful ? "successful" : "unsuccessful";
    console.log("Fallback: Kopiering gik fint " + msg);
  } catch (err) {
    console.error("Fallback: Kunne ikke kopiere", err);
  }
  document.body.removeChild(textArea);
}

function copyTextToClipboard(text) {
  if (!navigator.clipboard) {
    fallbackCopyTextToClipboard(text);
    return;
  }
  navigator.clipboard.writeText(text).then(function() {
    console.log('Kopiering gik fint');
  }, function(err) {
    console.error('Kunne ikke kopiere', err);
  });
}

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

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

</body>
</html>