Ab in die Kiste (Linux-Magazin, Juli 2011)

Größere Dateien tauscht die Jugend heute gerne über den proprietären Dropbox-Service aus. Dessen Web-API erlaubt auch handgeschriebene Skripts wie eines zum Dateiholen hinter einer Firewall.

Neulich bot eine Praktikantin bei Yahoo an, mir ein digitales Hörbuch zu leihen, und schlug vor, das sie es mir gleich 'dropboxen' könne. Nun versuche ich zwar immer noch, mit der Jugend von heute schrittzuhalten, doch dass der unter dropbox.com erhältliche Service bereits zum Standard für den Austausch größerer Dateien erwachsen ist, war mir neu. Auch dass die amerikanische Jugend 'dropboxen' bereits als Verb wie 'googeln' verwendet, gab mir zu denken.

Von der Website auf dropbox.com lädt der User kostenlos ein Client-Binary herunter (Windows, Mac und sogar Linux werden unterstützt) und sieht dann auf seinem System einen neuen lokalen Dropbox-Folder. Schiebt er Dateien hinein, springt hinter den Kulissen eine magische Software an, die den neuen Inhalt unauffällig und tröpfchenweise auf den Dropbox-Server hochlädt. Von dort synchronisieren sich dann weitere Clients desselben Users (oder die von extra autorisierten Freunden) ebenso magisch, sodass ein User auf jedem Computer der Welt einen permanent aktualisierten Order mit wichtigen Dateien zur Verfügung hat. Falls irgendwo kein Dropbox-Client installiert ist, benötigt der User lediglich einen Browser, denn die Daten lassen sich auch auf der Dropbox-Website ansehen und manipulieren (Abbildung 1).

Abbildung 1: Das Webinterface von Dropbox

Open Source bevorzugt

Allerdings behagte mir der Gedanke überhaupt nicht, ein Binary ohne einsehbaren Source-Code auf meinem PC zuhause zu starten. Zum Glück bietet Dropbox aber auch eine Web-API an, mit der auch ein paranoider Pinguinfreund wie ich seine helle Freude an dem kostenlosen Service entfalten kann.

Vernünftigerweise möchte dropbox.com auch die User von API-getriebenen Programmen dazu erziehen, ihren Usernamen und Passwort nicht auf irgendwelchen Oberflächen von Drittanbietern einzutippen und setzt deswegen auf OAuth. Um den User auf der Applikation anzumelden, holt diese zunächst von der Dropbox-Website unter Angabe eines Developer-Tokens und -Secrets einen Request-Token mit -Secret ab. Mit dem Request-Token im URL lenkt dann die Applikation den Browser des Users zur Dropbox-Website, die ihn, falls er dort noch nicht eingeloggt ist, zur Angabe seines Usernamens mit Passwort auffordert (Abbildung 2).

Abbildung 2: Die Perl-Applikation verweist auf dropbox.com, wo der User seine Kenndaten eingibt, um sich zu autorisieren.

Klappt das Login, fragt Dropbox den User, ob er wirklich damit einverstanden ist, dass die Applikation "Perl Test Client" Zugriff auf seine Dropbox-Daten erhält (Abbildung 3). Bestätigt er dies, lenkt die Dropbox-Website den Browser wieder zurück zur Applikation und schickt im URL einen sogenannten Access Token mit. Diesen darf die Applikation dann speichern und kann damit bis zum eingestellten Verfallsdatum Aufträge des Users auf dessen Dropbox-Account ausführen.

Abbildung 3: Der User bestätigt, dass er der Perl-Applikation die Verwaltung seiner Dropbox-Daten anvertraut.

Listing 1: dropbox-init

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Mojolicious::Lite;
    04 use Net::Dropbox::API;
    05 use YAML qw(LoadFile DumpFile);
    06 
    07 my $dev_key    = "iyaiu823ajksgwf";
    08 my $dev_secret = "zlkj32lkj2kl3dp";
    09 my $listen     = "http://localhost:8082";
    10 my($home)      = glob '~';
    11 my $CFG_FILE  = "$home/.dropbox.yml";
    12 
    13 my $CFG = {};
    14 $CFG = LoadFile( $CFG_FILE ) if 
    15   -f $CFG_FILE;
    16 
    17 @ARGV = (qw(daemon --listen), $listen);
    18 
    19 my $box = Net::Dropbox::API->new({
    20   key    => $dev_key, 
    21   secret => $dev_secret,
    22 });
    23 
    24 my $REQUEST_TOKEN;
    25 my $REQUEST_SECRET;
    26 
    27 ###########################################
    28 get '/' => sub {
    29 ###########################################
    30   my ( $self ) = @_;
    31 
    32   $box->callback_url( "$listen/callback" );
    33   $self->stash->{login_url} = $box->login;
    34 
    35   $REQUEST_TOKEN  = $box->request_token;
    36   $REQUEST_SECRET = $box->request_secret;
    37 } => 'index';
    38 
    39 ###########################################
    40 get '/callback' => sub {
    41 ###########################################
    42   my ( $self ) = @_;
    43 
    44   $box->auth({
    45     request_token  => 
    46       $self->param('oauth_token'), 
    47     request_secret => 
    48       $REQUEST_SECRET
    49   });
    50 
    51   $CFG->{ access_token } = 
    52     $box->access_token();
    53   $CFG->{ access_secret } = 
    54     $box->access_secret();
    55 
    56   DumpFile $CFG_FILE, $CFG;
    57 
    58   $self->render_text( "Token saved.",
    59         layout => 'default' );
    60 };
    61 
    62 app->start;
    63 
    64 __DATA__
    65 ###########################################
    66 @@ index.html.ep
    67 % layout 'default';
    68 <a href="<%= $login_url %>"
    69 >Login on dropbox.com</a>
    70 
    71 @@ layouts/default.html.ep
    72 <!doctype html><html>
    73   <head><title>Token Fetcher</title></head>
    74     <body>
    75       <pre>
    76       <%== content %>
    77       </pre>
    78     </body>
    79 </html>

Webserver als Gerüstbau

Da ein Perl-Client als Kommandozeilenapplikation normalerweise kein Browserinterface betreibt, klopft das Skript dropbox-init in Listing 1 mit dem Modul Mojolicious::Lite vom CPAN schnell einen notdürftigen Webserver auf http://localhost:8082 zusammen. Er reagiert auf die Pfade "/" und "/callback", und betreibt damit eine Startseite und eine Rücksprungadresse nach erfolgreicher Anmeldung des Users auf der Dropbox-Website. Der HTML-Code, den der Server jeweils ausspuckt, liegt im __DATA__-Segment ab Zeile 65. Mojolicious sucht dort wegen des Verweises auf index in Zeile 37 nach @@ index.html.ep und sended das dort liegende HTML zurück, nachdem es die Template-Variablen ersetzt hat. Die Anweisung "% layout 'default';" setzt das Drumherum, damit aus der Meldung auch ein wohlgeformtes HTML-Dokument wird.

Startet der User die Mojolicious-Applikation und tippt http://localhost:8082 in den Browser, erscheint die notdürftige UI in Abbildung 4, die nur einen Link auf die Login-Seite von dropbox.com enthält.

Abbildung 4: Der Mojoliciuos-Server bringt einen Login-Link, der zu dropbox.com verweist.

Das Modul Net::Dropbox::API, ebenfalls vom CPAN, abstrahiert schön die Dropbox-Zugriffe und die OAuth-Authorisierung. Setzt der API-Developer im Konstrukturaufruf in Zeile 19 die auf der Dropbox-Developer-Site ([3]) eingeholten Kombination aus "Developer Key" und "Secret", greift der Aufruf der Methode login() (Zeile 33) hinter den Kulissen auf den Dropbox-Server zu, holt einen Request-Token mit Secret ein und liefert einen Login-URL zurück. Diesen präsentiert die Web-Applikation dann dem User, der sich auf einen Mausklick hin so bei Dropbox anmelden kann.

Die Mojolicious-Applikation speichert die eingeholten Werte für Request-Token/Secret in den globalen Variablen $REQUEST_TOKEN und $REQUEST_SECRET, auf die es später zugreifen kann, wenn dropbox.com den Browser nach erfolgreichem Login wieder zurück zu unserem Gerüstserver, unter der in Zeile 32 gesetzten Callback-URL, zurücksendet. Erlangt die Mojolicious-Applikation anschließend wieder die Kontrolle, braucht sie sich nur den dem Callback beigelegten Access-Token aus der Parameterliste zu schnappen und hat dann zusammen mit dem vorher gespeicherten Requets-Token-Secret die Schlüssel in der Hand, um anstelle des Users nach Belieben in dessen Dropbox-Account herumzufuhrwerken. Das Skript speichert die beiden Schlüssel aber nur in der YAML-Datei ~/.dropbox.yml (Abbildung 6), damit später aufgerufene Applikationen sie sich ohne große Klimmzüge dort abholen können.

Abbildung 5: Nach dem Einloggen geht die Kontrolle von dropbox.com wieder an den Mojolicious-Server über, der die Token-Daten in einer YAML-Datei speichert.

Abbildung 6: Die YAML-Datei mit Access-Token und Access-Secret

Dronenbetrieb

Zum einfachen Zugriff auf die User-Kenndaten mit transparenter Dropbox-Bedienung definiert Listing 2 eine von Net::Dropbox::API abgeleitete Klasse MyDropbox, deren Konstruktor das Dropbox-Modul gleich mit den in der YAML-Datei abgelegten Schlüsseln ausstattet, sodass Applikationen, die es verwenden, sich nur noch um den eigentlichen Betrieb und nicht mehr um Authentisierungsfragen kümmern müssen. Außerdem ist so der Betrieb im Dronenmodus möglich, bei dem kein User mehr davorsitzt oder ein Webbrowser im Spiel ist.

Listing 2: MyDropbox.pm

    01 package MyDropbox;
    02 use strict;
    03 use base 'Net::Dropbox::API';
    04 use YAML qw(LoadFile);
    05 
    06 my $dev_key    = "iyaiu823ajksgwf";
    07 my $dev_secret = "zlkj32lkj2kl3dp";
    08 my($home)      = glob '~';
    09 my $CFG_FILE   = "$home/.dropbox.yml";
    10 
    11 ###########################################
    12 sub new {
    13 ###########################################
    14   my($class) = @_;
    15 
    16   my $box = Net::Dropbox::API->new({
    17     key    => $dev_key,
    18     secret => $dev_secret,
    19   });
    20 
    21   my $cfg = LoadFile( $CFG_FILE );
    22   $box->access_token( 
    23       $cfg->{access_token} );
    24   $box->access_secret( 
    25       $cfg->{access_secret} );
    26   
    27   bless $box, $class;
    28 }
    29 
    30 1;

Eine einfache Beispielapplikation zeigt Listing 3, die lediglich den Kontext auf "dropbox" setzt (Produktionsbetrieb im Gegensatz zur Testumgebung "sandbox") und dann mit list() die im Folder Photos liegenden Dateien auflistet.

Listing 3: dropbox-dump

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use MyDropbox;
    04 use Data::Dumper;
    05 
    06 my $box = MyDropbox->new();
    07 $box->context( "dropbox" );
    08 
    09 my $href = $box->list(
    10     "/Photos");
    11 
    12 $Data::Dumper::Indent = 1;
    13 print Dumper( $href );

Zurück kommt eine verschachtelte Struktur nach Abbildung 7, aus der der Programmierer den Inhalt der Dropbox, aufgeschlüsselt nach Dateinamen, -größe, dem Datum der letzten Modifizierung und einigem mehr extrahieren kann. Weitere Methoden erlauben das Hoch- und Herunterladen, sowie das Löschen von Dateien. Auch ein Test, der feststellt, ob sich seit der letzten Anfrage in einem bestimmten Teil der Dateihierarchie etwas verändert hat, wird angeboten. Dem Einfallsreichtum der Entwickler sind keine Grenzen gesetzt, solange diese das gegenwärtig eingestellte Tageslimit von 5000 Zugriffen nicht überschreiten.

Abbildung 7: Der list()-Aufruf liefert eine Datenstruktur mit dem Inhalt der Dropbox zurück.

Geister-Updater

Als praktische Anwendung der API habe ich mir neulich einen Geister-Updater implementiert. Die Klone der git-Repositories, an denen ich arbeite, sind dank des hier letztes Jahr vorgestellen Gitmeta-Tools [4] immer auf dem aktuellen Stand. Leider kommt es aber vor, dass ich vergessen habe, auf meinem Heimrechner 'git push' auszuführen, dass dort also lokal noch Änderungen vorliegen, die noch nicht auf dem Gitserver liegen und somit nicht so einfach auf den Laptop im Hotelzimmer zu bekommen sind, da sich der Heimrechner hinter einer Firewall verbirgt.

Ein selbstgeschriebener Dropbox-Dämon auf dem Heimrechner löst das Problem. Das Skript in Listing 4 wird mit dropbox-gitgetter start im Hintergrund hochgefahren und überwacht die Datei gitgetter/requests.txt im Dropbox-Folder periodisch alle 60 Sekunden auf Änderungen. Benötigt der Dropbox-Nutzer irgendwo auf dem Internet eine Datei des Heimrechners, fügt er deren Pfad in die Request-Datei ein und lädt die modifizierte Version der Requestdatei in die Dropbox.

Listing 4: dropbox-gitgetter

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use local::lib;
    04 use MyDropbox;
    05 use App::Daemon qw( daemonize );
    06 use Log::Log4perl qw(:easy);
    07 use HTTP::Status qw(:constants);
    08 use File::Temp qw(tempfile);
    09 use Cwd qw(realpath);
    10 use Sysadm::Install qw(:all);
    11 use File::Basename;
    12 
    13 # Log::Log4perl->easy_init($DEBUG);
    14 
    15 daemonize();
    16 
    17 my $mod_hash      = undef;
    18 my $poll_interval = 60;
    19 
    20 my $box = MyDropbox->new();
    21 $box->context( "dropbox" );
    22 
    23 my($home) = glob "~";
    24 my $gitdir = realpath "$home/git";
    25 
    26 while( 1 ) {
    27   my @hash_args = ();
    28   @hash_args = ( hash => $mod_hash ) if
    29     defined $mod_hash;
    30 
    31   my $href = $box->list( { @hash_args },
    32     "/gitgetter" );
    33 
    34   if( $href->{http_response_code} eq 
    35       HTTP_NOT_MODIFIED ) {
    36       DEBUG "Not modified";
    37   } else {
    38       $mod_hash = $href->{hash};
    39       request_handler( $box );
    40   }
    41 
    42   DEBUG "Sleeping ${poll_interval}s";
    43   sleep $poll_interval;
    44 }
    45 
    46 ###########################################
    47 sub request_handler {
    48 ###########################################
    49   my($box) = @_;
    50 
    51   my $content = 
    52    $box->getfile("gitgetter/requests.txt");
    53 
    54   my $pushed = 0;
    55 
    56   for my $line ( split /\n/, $content ) {
    57     $line =~ s/#.*//;
    58     next if $line =~ /^\s*$/;
    59     DEBUG "Found request: '$line'";
    60 
    61     my $file = realpath( "$gitdir/$line" );
    62     if( $file !~ /^$gitdir/ ) {
    63         ERROR "Path $file denied.";
    64         next;
    65     }
    66 
    67     DEBUG "Delivering $file";
    68 
    69     if( !-f $file ) {
    70       ERROR "$file doesn't exist";
    71       next;
    72     }
    73 
    74     my $href = $box->putfile( $file, 
    75         "gitgetter" );
    76     $pushed++;
    77   }
    78 
    79   if( $pushed ) {
    80     my($fh, $tmpfile) = tempfile( 
    81         UNLINK => 1 );
    82     blurt "# pending requests\n", $tmpfile;
    83     my $href = $box->putfile( $tmpfile, 
    84         "gitgetter", "requests.txt" );
    85   }
    86 }

Der Dämon auf dem Heimrechner bekommt die Änderung mit, Steht in der Request-Datei eine neue Zeile mit einem Dateipfad, prüft der Dämon, ob die Anfrage sich auch wirklich auf eine Datei im Verzeichnis der git-Repositories bezieht. Falls ja, holt er die gewünschte Datei aus dem lokalen Dateisystem des Heimrechners, pumpt sie mittels der Dropbox-API in die Dropbox, löscht dann die Anfrage aus requests.txt und spielt die modifizierte Version der Request-Datei wieder in die Dropbox. Da Dropbox sich weigert, leere Dateien hochzuladen, lässt der Dämon auch bei null verbliebenen Anfragen immer einen Kommentar am Kopf stehen.

Abbildung 8: Eine Anfrage an den dropbox-daemon, eine vergessene Datei hochzuladen.

Listing 4 läuft nach dem Kommando start im Hintergrund und unterstützt dank App::Daemon vom CPAN auch stop und status um den Dämon herunter zu fahren oder seinen Status zu ermitteln. Er loggt seine Aktivitäten mittels Log4perl in der Datei /tmp/dropbox-gitgetter.log. Mit -X startet das Skript im Vordergrund, um ein Problem einzukreisen, falls mal etwas nicht erwartungsgemäß funktioniert.

Abbildung 9: Kurze Zeit später hat der Dämon die gewünschte Datei eingeschmuggelt.

In der Endlosschleife ab Zeile 25 ruft Listing 4 alle 60 Sekunden die list()-Methode des MyDropbox-Objekts auf und findet so heraus, ob sich unter dem Dropbox-Verzeichnis /gitgetter etwas geändert hat. Statt aber jedesmal die ganze Hierarchie über die Leitung zurückzupusten, nutzt das Skript die effiziente Hash-Methode. Die Dropbox-API gibt nämlich bei list()-Anfragen neben dem gewünschten Resultat auch noch einen 32-byte Hexstring zurück, der den Status der Dateien-Hierarchie widerspiegelt. Legt der bandbreitenknausende API-Programmierer den Hash beim nächsten Aufruf wieder bei, gibt der Dropbox-Server bei unverändertem Inhalt statt Daten nur den HTTP-Code 403 ("not modified") zurück und das Skript kann sich bis zur nächsten Runde schlafen legen.

Vorsicht vor Bösewichten

Andernfalls springt die Funktion request_handler() ab Zeile 46 an, die mit der Methode getfile() in Zeile 51 den Inhalt der Datei "gitgetter/requests.txt" über die API vom Dropbox-Server einholt. Eine for-Schleife iteriert dann über alle ihre Zeilen, während sie Kommentarzeilen entfernt und leere Zeilen ignoriert. Die Funktion realpath() aus dem Fundus des Moduls Cwd stellt bei gefundenen Zeilen zur Dateianfrage sicher, dass sich im Pfad keine Schummelzeichen befinden, die den Dämon dazu überlisten könnten, in beliebige Dateipfade zu wandern und dortige Dateien auszuliefern. Hierzu hängt das Skript den verlangten Pfad an das vorgegebene git-Verzeichnis an und prüft anschließend, ob "realpath" auf das Ergebnis auch wieder innerhalb des git-Verzeichnisses liegt.

Das ist wichtig, damit kein Bösewicht, der das Dropbox-Passwort des Users knackt oder gar den Dropbox-Server in seine Gewalt bringt, das gesamte lokale Dateisystem ausspähen kann, sondern nur den explizit zugelassenen Pfad zu den git-Repositories, deren Serverversionen eh im Licht der Öffentlichkeit stehen.

Genehmigt der Dämon die Herausgabe der Datei, stellt sie putfile() in Zeile 73 ins Verzeichnis /gitgetter der Dropbox. Falls Requests abgearbeitet wurden, schreibt Zeile 82 eine bis auf eine Kommentarzeile leere requests.txt-Datei zurück in die Dropbox, damit der Dämon nicht beim nächsten Mal wieder von vorne anfängt.

Installation

Der Rattenschwanz an benötigten Modulen findet sich entweder in Pakete der verwendeten Distribution (z.B libapp-daemon-perl, liblog-log4perl usw.) oder über eine CPAN-Shell.

Das CPAN-Modul Net::Dropbox::API unterstützte zum Zeitpunkt der Erstellung dieses Beitrags die Hash-gestützte Dropbox-Abfrage nach modifizierten Dateien noch nicht, also habe ich das Projekt auf Github kurzerhand geforkt, die Funktion eingefügt und den Autor um Aufnahme des Patches in die Mainline gebeten. Falls dies bis zum Erscheinen des Artikels noch nicht geschehen ist, können interessierte Leser den Tarball des modifizierten Moduls auf Github ([4]) herunterladen.

Um die Dropbox-API zu verwenden, benötigen Entwickler einen Developer-Key, den es unter [3] gegen Hinterlegung einer Email-Adresse ohne Umschweife gibt. In den Listings sind dann die Variablen $dev_key und $dev_secret entsprechend anzupassen und schon kann es losgehen.

Mit Vorsicht genießen

Der Vorteil der Dropbox liegt klar in der einfachen Handhabung. Selbst Computerneulinge können dort Daten ablegen und anderswo wieder abholen. In [6] steht zum Beispiel ein Verfahren zur Kommunikation zwischen Entwicklern und Web-Designern, das Änderungen in einem Git-Repository über die Dropbox auch einer Clientel zugänglich macht, die mit der Bedienung eines Repositories überfordert wären.

Übrigens versichert dropbox.com zwar, die dort gespeicherten Daten seien verschlüsselt und selbst für Dropbox-Mitarbeiter nicht einsehbar, doch laut [5] stimmt das nicht. Wie dem auch sei, wie immer gilt: Sensitive Daten sind auf der lokalen Festplatte immer noch am sichersten aufgehoben an den wolkigen Sicherheitskonzepten muss die "Cloud" wohl noch feilen.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2011/07/Perl

[2]

"OAuth", http://en.wikipedia.org/wiki/Oauth

[3]

"Dropbox for Developers", https://www.dropbox.com/developers/quickstart

[4]

"Überall Projekte", Michael Schilli, Linux Magazin 08/2010, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2010/08/Ueberall-Projekte

[4]

"Net::Dropbox 1.4_01", inklusive der im Artikel verwendeten Hash-Funktion auf Github, http://github.com/mschilli/Net--Dropbox/tarball/1.4_01

[5]

"Dropbox Lied to Users About Data Security, Complaint to FTC Alleges", http://www.wired.com/threatlevel/2011/05/dropbox-ftc/

[6]

"Dropbox + git = Designer Luv", Ken Mayer, http://pivotallabs.com/users/ken/blog/articles/1637-dropbox-git-designer-luv

Michael Schilli

arbeitet 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.