Ellenlange URLs lassen sich nur schwer in Zeitschriftenartikeln drucken oder per Email versenden. Ein CGI-Skript auf einem öffentlichen Server erlaubt drastische Abkürzungen.
Beim Lesen des neuesten Cryptograms, dem monatlichen Rundbrief des
Sicherheits-Superhelden Bruce Schneier [2], fiel mir auf, dass die sonst
eher länglichen URLs zu diversen Literaturverweisen ungewohnt kurz
ausfielen: Sie verwiesen alle auf http://tinyurl.com und endeten
in kryptischen Kürzeln.
Alles klar: tinyurl.com bietet einen
(kostenlosen) Service an, der lange URLs speichert und durchnumeriert.
Verweist jemand auf so einen URL, sieht tinyurl.com in seiner
Datenbank nach und führt einen redirect auf den Ziel-URL aus. So
registrierte ich neulich
http://tinyurl.com/kowv
das auf den neuesten USA-Rundbrief unter
http://perlmeister.com/rundbrief.archiv/20030801
verweist. Praktisch!
Das Ganze lässt sich auch ganz simpel in Perl implementieren: Ein
persistenter Hash führt eindeutige Kürzel als Schlüssel und
registrierte URLs als Werte. Statt dezimaler Zahlen
werden Gebilde aus Kleinbuchstaben und Zahlen eingesetzt.
Wir landen wir im Zahlensystem zur Basis 36 (26 Buchstaben plus 10
Ziffern) und verbrauchen schlagartig
für eine Million nur noch vier Zeichen: 4c92 repräsentiert die
dezimale Zahl 1.000.000 im 36er-System.
Eine vierstellige Zahl zur Basis 36 kann insgesamt
36^4 = 1.679.616
verschiedene Werte abspeichern -- das sollte für's Erste ausreichen.
Listing u (wir sparen wo's geht) zeigt ein CGI-Skript, das
tinyurl.com nachbildet.
|
| Abbildung 1: Der Kompressor nimmt einen neuen URL entgegen, speichert ihn in seiner Datenbank und gibt dessen Kurzform aus. |
Das aufs offene Internet gestellte Skript soll natürlich nicht dem allerersten Schlimmfinger in die Hände fallen, deswegen einige Sicherheitsvorkehrungen:
Die CGI-Funktionalität erledigt Lincoln Steins CGI-Modul
und dessen CGI::Carp-Ableger, der mit dem eingestellten
fatalsToBrowser-Tag jegliche Perl-Exceptions abfängt und
zu Debugging-Zwecken im Browser darstellt.
Sieht das Skript keinen url-Parameter, zeigt es im Browser ein
Formular zur Eingabe einer neuen URL an, dessen Submit-Button sie
als Parameter url wieder zum Skript auf dem Server schickt.
Der rechnet dann eine abgekürzte URL aus und speichert die Zuordnung
der Abkürzung zur vollen URL in seiner Mini-Datenbank ab, falls sie
dort nicht eh schon steht. Die abgekürzten URLs sind von der Form
http://server.com/cgi/u/xxxx
und senden die URL-Abkürzung xxxx angehängt an den Pfad zum Skript
u, dessen CGI-Umgebung sie in der Variablen $ENV{PATH_INFO}
zugänglich macht. Sieht das Skript eine derartige Anfrage, holt es den
zugehörigen langen URL aus der Datenbank und sendet einen redirect(),
was den Browser nahtlos zur entsprechenden Website verzweigen lässt.
Komfortables Logging mit DEBUG(), INFO() und LOGDIE()-Macros besorgt wie
immer Log::Log4perl qw(:easy)
das heute mal mit einem FileRotate-Appender
aus dem Log::Dispatch-Fundus konfiguriert wird. size=1000000 legt
ein Megabyte maximale Größe fest, und max=1 bestimmt, dass der
FileRotate-Appender
ein volles Logfile shrink.log nach shrink.log.1 abrollt,
wenn shrink.log 1 Megabyte überschreitet, aber keine
weiteren Backups vornimmt, damit sich zu jeder Zeit höchstens
2 Megabytes an Logdateien auf der Platte befinden.
Der mittels tie() an die Datei /tmp/shrink.dat gebundene
persistente Hash URLS speichert in u nicht wie üblich eine
einzelne Key/Value-Zuordnung, sondern gleich drei! Deswegen erhalten die
Schlüssel jeweils einen Präfix:
by_shrink/),
die URL zu einer Abkürzung (Präfix by_url/)
die nächste zu vergebende Abkürzung (next/)
An einigen Stellen soll das Skript einfach aufhören: Schnell noch vom
persistenten Hash abkoppeln und dann das Programm beenden. Mit exit()
auszusteigen, wäre schlechter Stil, da dies
in Umgebungen wie mod_perl Probleme verursacht. return() geht auch nur,
wenn perl gerade eine Subroutine ausführt. Schließlich kam das gute alte
goto zum Einsatz, das echte Programmierer (im Gegensatz zu sog.
Quiche-Fressern) laut [3] bekanntlich nicht fürchten.
An den paar Abbruchstellen im Skript springt
goto einfach zum weiter unten definierten Label END.
Um die Anzahl der URLs pro Tag, die Benutzer pro IP-Adresse einspeichern
können, zu limitieren, kommt ein
File-Cache mit einstellbarem Verfallsdatum zum Einsatz. Das schlaue
Cache::Cache-Modul bietet eine Schnittstelle, die mit set() neue Einträge
setzt und sie mit get() wieder holt. Die abgeleitete Klasse
Cache::FileCache implementiert diese Funktionalität als Hierarchie von
Dateien auf der Platte.
Das Skript speichert pro IP einen Zähler und erhöht diesen mit jeder von dieser Internet-Adresse angeforderten URL. Ist der Höchststand erreicht, blockt das Skript weitere Anträge zu neuen URLs, liefert aber weiterhin die Kürzel zu bereits definierten aus. Nach Ablauf eines Tages 'vergisst' File::Cache aber die vorgenommenen Einträge und der Zählvorgang beginnt von vorn. Mit diesem simplen Verfahren akzeptieren wir allerdings einen kleinen Fehler: Schlimmstenfalls wird so eine IP für einen Tag blockiert, die stetig aber innerhalb der Tagesgrenzen neue URLs anforderte.
Die IP-Adresse des Clients stellt die CGI-Umgebung des Webservers
zuverlässig in der Variablen
$ENV{REMOTE_ADDR} zur Verfügung.
Von der Kommandozeile aus aufgerufen setzt das Skript die
Variable $ENV{REMOTE_ADDR} allerdings nicht. Darum setzt
Zeile 130 anstelle der IP-Adresse einfach den String NO_IP.
Die Option default_expires_in des Cache::FileCache-Konstruktors
gibt die Zeitspanne in Sekunden nach
dem letzten set() an, ab der der Cache sich einfach nicht mehr an den
Eintrag erinnern kann. auto_purge_on_get gibt an, dass der Cache
automatisch bei jedem get()-Aufruf bereits verfallene Einträge aufspürt
und von der Platte putzt -- das stellt sicher, dass der Cache nicht
unendlich mit legitim operierenden IP-Adressen anwächst.
Die ab Zeile 109 definierte Funktion base36 wandelt Dezimalzahlen platzsparend zur Basis 36 um. Wie geht das? Eine Zahl im Zehner-System baut sich wie folgt auf:
a*1 + b*10 + c*10*10 + ...
wobei cba die Ziffern der Zahl darstellen. 156 stellt sich als
6*1 + 5*10 + 1*10*10
dar. Im 36er-System hingegen gilt stattdessen
a*1 + b*36 + b*36*36 +...
und folgender Algorithmus wandelt eine Dezimalzahl d dahin um:
``Bestimme den Rest der Division d/36 (also d modulo 36 oder d % 36).
Dies ergibt das letzte Zeichen der Zahl zur Basis 36. Dann teile
die umzuwandelnde Zahl durch 36, nimm den ganzzahligen Anteil des
Ergebnisses und fahre fort, um die nächste Zeichen (von rechts
nach links) der Zielzahl zu ermitteln.''
In der Funktion base36() kommen im Array @chars
zunächst alle gültigen Zeichen zu liegen: Die
Ziffern von 0 bis 9 und die Kleinbuchstaben von 'a' bis 'z'.
Die danach folgende for-Schleife ermittelt zunächst mit
my $b = @chars;
wegen des in skalaren Kontext gestellten Arrays @chars
dessen Länge in $b -- also die Anzahl der gültigen Zeichen,
36 im vorgestellten Fall. Mit $num % $b wird ermittelt, wieviel
als Rest übrigbleibt, wenn man die umzuwandelnde Zahl $num
durch die Zahl der verfügbaren Zeichen im 36er-System teilt --
also genau die letzte 'Ziffer' der umgewandelnden Zahl im Zielsystem.
Der Fortsetzungsteil der for-Schleife, $num /= $b,
führt wegen der vorher in base36() angeforderten Direktive
use integer;
mit $num und $b eine Division ohne Fließkommaanteil durch.
Der Ausdruck
$result .= $chars[$num % $b];
schnappt sich das richtige Zeichen aus dem 36er-Zeichensatz und
fügt es ans Ende des Strings in $result an -- die Zahl im
Zielsystem wird also verkehrt herum aufgebaut. Dies korrigiert
der Ausdruck
return scalar reverse $result;
am Ende, indem er den String in $result umdreht. Skalarer
Kontext ist notwendig, da reverse im List-Kontext
einfach eine ihr übergebene Liste umdreht und nicht die
Buchstaben eines Einzelwerts.
001 #!/usr/bin/perl
002 ###########################################
003 # Mike Schilli, 2003 (m@perlmeister.com)
004 ###########################################
005 use warnings;
006 use strict;
007 use Log::Log4perl qw(:easy);
008 use Cache::FileCache;
009
010 my $DB_FILE = "/tmp/shrinky.dat";
011 my $DB_MAX_SIZE = 10_000_000;
012 my $MAX_URL_LEN = 256;
013 my $REQS_PER_IP = 200;
014
015 Log::Log4perl->init(\ <<"EOT");
016 log4perl.logger = DEBUG, Rot
017 log4perl.appender.Rot=\\
018 Log::Dispatch::FileRotate
019 log4perl.appender.Rot.filename=\\
020 /tmp/shrink.log
021 log4perl.appender.Rot.layout=\\
022 PatternLayout
023 log4perl.appender.Rot.layout.\\
024 ConversionPattern=%d %m%n
025 log4perl.appender.Rot.mode=append
026 log4perl.appender.Rot.size=1000000
027 log4perl.appender.Rot.max=1
028 EOT
029
030 use CGI qw(:all);
031 use CGI::Carp qw(fatalsToBrowser);
032 use DB_File;
033
034 tie my %URLS, 'DB_File', $DB_FILE,
035 O_RDWR|O_CREAT, 0755 or
036 LOGDIE "tie failed: $!";
037
038 # First time initialization
039 $URLS{"next/"} ||= 1;
040
041 my $redir = "";
042
043 if(exists $ENV{PATH_INFO}) {
044 # Redirect requested
045 my $num = substr($ENV{PATH_INFO}, 1);
046 $redir = $URLS{"by_shrink/$num"} if
047 $num ne "_"
048 and exists $URLS{"by_shrink/$num"};
049 }
050
051 if($redir) {
052 print redirect($redir);
053 goto END;
054 }
055
056 print header();
057
058 if(my $url = param('url')) {
059
060 if(length $url > $MAX_URL_LEN) {
061 print "Sorry, URL too long.\n";
062 goto END;
063 }
064
065 my $surl;
066
067 # Does it already exist?
068 if(exists $URLS{"by_url/$url"}) {
069 DEBUG "$url exists already";
070 $surl = $URLS{"by_url/$url"};
071
072 } else {
073 if(-s $DB_FILE > $DB_MAX_SIZE) {
074 DEBUG "DB File maxed out " .
075 (-s $DB_FILE) . " > $DB_FILE";
076 print "Sorry, no more URLs.\n";
077 goto END;
078 }
079
080 if(rate_limit($ENV{REMOTE_ADDR})) {
081 print "Sorry, too many requests " .
082 "from this IP\n";
083 goto END;
084 }
085
086 # Register new URL
087 my $n = base36($URLS{"next/"}++);
088 INFO "$url: New shortcut: $n";
089 $surl = url() . "/$n";
090 $URLS{"by_shrink/$n"} = $url;
091 $URLS{"by_url/$url"} = $surl;
092 }
093 print a({href => $surl}, $surl);
094 }
095
096 # Accept user input
097 print h1("Add a URL"),
098 start_form(),
099 textfield(-size => 60,
100 -name => "url",
101 -default => "http://"),
102 submit(), end_form();
103
104 END:
105
106 untie %URLS;
107
108 ###########################################
109 sub base36 {
110 ###########################################
111 my ($num) = @_;
112
113 use integer;
114
115 my @chars = ('0'..'9', 'a'..'z');
116 my $result = "";
117
118 for(my $b=@chars; $num; $num/=$b) {
119 $result .= $chars[$num % $b];
120 }
121
122 return scalar reverse $result;
123 }
124
125 ###########################################
126 sub rate_limit {
127 ###########################################
128 my ($ip) = @_;
129
130 $ip = 'NO_IP' unless defined $ip;
131
132 INFO "Request from IP $ip";
133
134 my $cache = Cache::FileCache->new(
135 { default_expires_in => 3600*24,
136 auto_purge_on_get => 1,
137 }
138 );
139
140 my $count = $cache->get($ip);
141
142 if(defined $count and
143 $count >= $REQS_PER_IP) {
144 INFO "Rate-limiting IP $ip";
145 return 1;
146 }
147
148 $cache->set($ip, ++$count);
149
150 return 0;
151 }
Das Skript benötigt Log::Log4perl, Log::Dispach::FileRotate und
Cache::FileCache, alle sind vom CPAN erhältlich. Die Pfade zur Logdatei
(Zeile 20) und zur Datenbankdatei (Zeile 10) sind an die lokalen
Verhältnisse anzupassen und das Skript
ins cgi-bin-Verzeichnis eines Webservers zu verfrachten.
Wenn der Aufruf von der Kommandozeile funktioniert (auf Ausführungsrechte
und Schreibrechte für die Datenverzeichnisse achten!), sollte es
auch im Webbrowser klappen -- aber bitte auf die unterschiedliche User-ID
(meist nobody) achten. Staucht eure überlangen URLs zusammen,
kürzt gnadenlos!
![]() |
Michael Schilliarbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com. |