once.pl
changeset 68 9b2e5de193c9
parent 67 f44ea3ef0634
child 69 342819c70918
equal deleted inserted replaced
67:f44ea3ef0634 68:9b2e5de193c9
     1 #! /usr/bin/perl -T
       
     2 # FIXME: UPDATE {{
       
     3 # Example .htaccess
       
     4 # | Options -Indexes
       
     5 # | <Files once.pl>
       
     6 # | AuthType Basic
       
     7 # | AuthName upload
       
     8 # | Require valid-user
       
     9 # | AuthUserFile /home/heiko/public_html/.passwd
       
    10 # | </Files>
       
    11 #
       
    12 # Je nach Permission-Lage kann es gut sein, daß das upload.d-Verzeichnis
       
    13 # mit der Hand angelegt werden muß und dem Webserver-Nutzer „geschenkt“
       
    14 # werden muß.
       
    15 #
       
    16 # Das Upload-Verzeichnis sollte natuerlich vor der Indizierung geschuetzt
       
    17 # werden - siehe Beispiel .htaccess.
       
    18 #
       
    19 # Eventuell in der Apache-Config sowas wie
       
    20 #   ScriptAlias /ud	    /home/ud/XXX/once.pl
       
    21 #   Alias	/d	    /home/ud/XXX/d/
       
    22 # gesetzt werden.
       
    23 #
       
    24 # }}
       
    25 #
       
    26 
       
    27 # TODO: Security review!
       
    28 
       
    29 use 5.018;
       
    30 use strict;
       
    31 use warnings;
       
    32 use IO::File;
       
    33 use CGI qw(param upload);
       
    34 use CGI::Carp qw(fatalsToBrowser);
       
    35 use FindBin qw($RealBin);
       
    36 use File::Basename;
       
    37 use File::Path qw(remove_tree make_path);
       
    38 use File::Spec::Functions;
       
    39 use File::MimeInfo qw(mimetype);
       
    40 use Cwd qw(getcwd realpath);
       
    41 use Digest::MD5 qw(md5_hex md5);
       
    42 use Template;
       
    43 use experimental qw(smartmatch lexical_topic);
       
    44 
       
    45 sub humanize;     # convert numbers to human readable format
       
    46 sub deletedir;    # safely delete directories
       
    47 sub confirm;      # ask for user confirmation (HTML)
       
    48 sub deslash;      # cleanup a path name
       
    49 sub gen_uuid;     # create a uniq identifier
       
    50 sub base62;
       
    51 sub md5_base62 { ... }
       
    52 sub untaint;
       
    53 
       
    54 my $rxVIEW = qr/[a-z\d]{6}-[a-z\d]+/i;      # date-userhash
       
    55 my $rxFILE = qr/[a-z\d]{6}-[a-z\d]+-./i;    # date-filehash-deletemode
       
    56 
       
    57 my $TT_CONFIG =
       
    58   { INCLUDE_PATH =>
       
    59       [map { catfile($RealBin, $_) } qw(var templates.override templates)] };
       
    60 
       
    61 umask 077;
       
    62 
       
    63 # The working (var) directory gets passed to us via ONCE_VAR environment
       
    64 # FIXME: Should we allow the current directory as an alternative?
       
    65 
       
    66 die "Environment ONCE_VAR needs to be defined\n"
       
    67   if not defined $ENV{ONCE_VAR};
       
    68 my $VAR = untaint($ENV{ONCE_VAR}, qr((^/.*)));
       
    69 
       
    70 exit main() if not caller;
       
    71 
       
    72 sub main {
       
    73 
       
    74     # Handle the UPLOAD / VIEW request
       
    75     # per view (user) we have an own directory
       
    76 
       
    77     # pre condition checks
       
    78     -d $VAR
       
    79       or mkdir $VAR => 0777
       
    80       or die "Can't mkdir $VAR: $! (your admin should have created it)\n";
       
    81 
       
    82     -x -w $VAR
       
    83       or die "Can't write to $VAR: $!\n";
       
    84 
       
    85     # Download?
       
    86     # PATH_INFO is something like
       
    87     # /once/hDOPzMJMY0p/3fLjYmbYlGk-1450284310-d/boot.dot
       
    88     #       |-VIEW-----|                         |-BASE-|
       
    89     #       |-STORE----------------------------|
       
    90     # …     |-PATH--------------------------------------|
       
    91     if ($ENV{PATH_INFO} =~
       
    92         m{/(?<path>(?<store>(?<view>$rxVIEW)/$rxFILE)/(?<base>.*))})
       
    93     {
       
    94         #        use Data::Dumper;
       
    95         #        die Dumper \%+;
       
    96         my $view  = deslash realpath catfile $VAR, $+{view};
       
    97         my $store = deslash realpath catfile $VAR, $+{store};
       
    98         my $file  = deslash realpath catfile $VAR, $+{path};
       
    99         my $base  = $+{base};
       
   100 
       
   101         unless (-f $file) {
       
   102             print header('text/plain', '404 Not found'), 'Not found';
       
   103             exit 0;
       
   104         }
       
   105 
       
   106         my $mimetype = mimetype($file);
       
   107         confirm $base, $mimetype
       
   108           if $store =~ /-d$/ and not defined param('confirmed');
       
   109 
       
   110         open my $f, '<', $file or die "Can't open <`$file`: $!\n";
       
   111         remove_tree $1 if $store =~ m(^(/.*-d)$);
       
   112         rmdir $1 if $view =~ m(^(/.*));
       
   113 
       
   114         print header(-type => $mimetype, -charset => 'UTF-8');
       
   115         if (request_method() ~~ [qw(GET POST)]) {
       
   116             local $/ = \do { 1 * 2**20 };    # 1 MB Buffer
       
   117             print while <$f>;
       
   118         }
       
   119         exit 0;
       
   120 
       
   121     }
       
   122 
       
   123     # Setup the essentials: view and user_dir
       
   124     my ($view, $user_dir) = do {
       
   125 
       
   126         # view: display name
       
   127         #       anonymous | hans | …
       
   128         # user_dir: the directory name, becomes part of the
       
   129         #           link, later
       
   130         #       /var/lib/once/1AaIF9-1KF
       
   131         #                            `--> base62 of a random value, may
       
   132         #                            be shorter than 3 digits
       
   133         #                     `-----> base62 of a unix time stamp,
       
   134         #                     number of digits will be 6 for the
       
   135         #                     forseeable future
       
   136         # NOTE: if you change the generated user_dir string here, you may need
       
   137         # to adapt the patterns $rxVIEW and $rxFILE at the beginning of
       
   138         # the script.
       
   139         #
       
   140         my ($v, $d);
       
   141         if ($ENV{REMOTE_USER} =~ /^\s*(.+)\s*$/) {
       
   142 
       
   143             # Known users get a directory name based user name.
       
   144             # Yes, if somebody can guess the user names, they can guess
       
   145             # the directory names too. But they can't guess the
       
   146             # completly randomly named files in there.
       
   147             $d = join '-' => base62(time), md5_base62($1);
       
   148             $v = $1;
       
   149         }
       
   150         else {
       
   151             # Anonymous get an timestamp()-rand(1000) directory
       
   152             $d = join '-' => base62(time), base62(rand(10_000));
       
   153             $v = 'anonymous';
       
   154         }
       
   155         $v, deslash catfile($VAR, $d);
       
   156     };
       
   157 
       
   158     # Handle the removal request and we're done
       
   159     if (param('delete') =~ m{(?<store>(?<view>$rxVIEW)/$rxFILE/?)}) {
       
   160 
       
   161         # FIXME: sanitization
       
   162         my $store = deslash catfile $VAR, $+{store};
       
   163         my $view  = deslash catfile $VAR, $+{view};
       
   164         remove_tree $1 if $store =~ m(^(/.*));
       
   165         rmdir $1 if $view =~ m(^(/.*));
       
   166         print redirect(-uri => url(-path_info => 1));
       
   167         exit 0;
       
   168     }
       
   169 
       
   170     # save the uploaded file
       
   171 
       
   172     if (length(my $file = param('upload'))) {
       
   173         my $upload_fh = upload('upload');
       
   174         my $uuid      = gen_uuid();
       
   175         my ($delete, $expires, $days) = do {
       
   176             my ($d, $e);
       
   177             my $days = param('expires') // 0;
       
   178 
       
   179             # sanitize expires
       
   180             $days =~ /.*?([+-]?\d+).*/;
       
   181             $days = $1 // 10;
       
   182             $e = base62 time + $days * 86400;
       
   183 
       
   184             if    ($days == 0) { $d = 'd' }    # at first [d]ownload
       
   185             elsif ($days < 0)  { $d = 'm' }    # only [m]anually
       
   186             else               { $d = 'e' }    # if expired
       
   187             ($d, $e, $days);
       
   188         };
       
   189 
       
   190         # sanitize the filename
       
   191         my $filename = do {
       
   192             $file =~ tr /\\/\//;
       
   193             $file =~ /(.*)/;
       
   194             $1;
       
   195         };
       
   196 
       
   197         my $dir = catfile($user_dir, "$expires-$uuid-$delete");
       
   198         make_path($dir);
       
   199         {
       
   200             my $outfh = new IO::File "$dir/$filename", 'w'
       
   201               or die "Can't create $dir/$filename: $!\n";
       
   202             print {$outfh} <$upload_fh>;
       
   203         }
       
   204 
       
   205         if (not $delete ~~ [qw(d m)]
       
   206             and my $atfh = new IO::File("|at now + $days days"))
       
   207         {
       
   208             print {$atfh}
       
   209               "rm -f \"$dir/$filename\"\n",
       
   210               "rmdir \"$dir\"\n";
       
   211             close $atfh;
       
   212         }
       
   213 
       
   214     }
       
   215 
       
   216     # create the view
       
   217     my %tt = (view => $view);
       
   218     my $tt = Template->new($TT_CONFIG)
       
   219       or die $Template::ERROR;
       
   220 
       
   221     # List the current content
       
   222     if (my @files = map { deslash $_ } glob "$user_dir/*-*/*") {
       
   223 
       
   224         foreach (map { /(.*)/ } sort { -M $a <=> -M $b } @files) {
       
   225 
       
   226             my %file;
       
   227 
       
   228             my ($file, $dir) = fileparse($_);
       
   229             $dir = substr $dir,
       
   230               length $VAR;    # make it relative to $VAR
       
   231 
       
   232             # FIXME: use the rx* patterns from above
       
   233             $dir =~ m{/(?<expires>[a-z\d]{6})-(?<hash>[a-z\d]+)-(?<delete>.)}i
       
   234               or next;
       
   235             my ($hash, $expires, $delete) = @{+}{qw/hash expires delete/};
       
   236             if (${expires} <= time and $delete eq 'e') {
       
   237                 /(.*)/;
       
   238                 unlink $_  or die "Can't unlik $_: $!\n";
       
   239                 rmdir $dir or die "Can't rmdir $dir: $!\n";
       
   240                 next;
       
   241             }
       
   242 
       
   243             my $d;
       
   244             if ($delete eq 'e') {
       
   245                 $d = localtime ${expires};
       
   246             }
       
   247             elsif ($delete eq 'd') {
       
   248                 $d = 'unmittelbar nach Download';
       
   249             }
       
   250             else {
       
   251                 $d = 'nur manuell';
       
   252             }
       
   253 
       
   254             $file{name}          = $file;
       
   255             $file{link}          = "$ENV{PATH_INFO}/$dir$file";
       
   256             $file{size}          = humanize -s $_;
       
   257             $file{uploaded}      = (stat _)[9];
       
   258             $file{removal}{type} = $d;
       
   259             $file{removal}{link} = "?delete=$dir";
       
   260 
       
   261             push @{ $tt{files} }, \%file;
       
   262         }
       
   263 
       
   264     }
       
   265     $tt->process('inventory.html', \%tt) or die $tt->error();
       
   266     return 0;
       
   267 }
       
   268 
       
   269 sub deletedir {
       
   270     remove_tree
       
   271       map  { /^(\/.*)/ }
       
   272       grep { /^\Q$VAR\E/ } @_;
       
   273 }
       
   274 
       
   275 sub humanize {
       
   276     my $_     = shift;
       
   277     my @units = qw(B K M G T);
       
   278     while (length int > 3 and @units) {
       
   279         $_ = sprintf "%.1f" => $_ / 1024;
       
   280         shift @units;
       
   281     }
       
   282     croak "filesize is too big (can't convert to human readable number)"
       
   283       if !@units;
       
   284     return "$_$units[0]";
       
   285 }
       
   286 
       
   287 sub deslash { $_[0] =~ s{/+}{/}gr }
       
   288 
       
   289 sub confirm {
       
   290     my ($base, $mimetype) = @_;
       
   291     my %tt = (
       
   292         file => {
       
   293             name     => $base,
       
   294             mimetype => $mimetype
       
   295         }
       
   296     );
       
   297     my $tt = Template->new($TT_CONFIG)
       
   298       or die $Template::ERROR;
       
   299     $tt->process('confirm.html' => \%tt);
       
   300     exit 0;
       
   301 }
       
   302 
       
   303 sub unbase62 {
       
   304     my @digits = reverse split '', shift;
       
   305     state $value = do {
       
   306         my %value;
       
   307         for (
       
   308             my ($symbol, $value) = (base62(0), 0) ;
       
   309             length($symbol) == 1 ;
       
   310             $symbol = base62 ++$value
       
   311           )
       
   312         {
       
   313             $value{$symbol} = $value;
       
   314         }
       
   315         \%value;
       
   316     };
       
   317     state $base = scalar keys %$value;
       
   318 
       
   319     my $unbase62 = 0;
       
   320     while (my ($p, $symbol) = each @digits) {
       
   321         $unbase62 += $value->{$symbol} * $base**$p;
       
   322     }
       
   323     return $unbase62;
       
   324 }
       
   325 
       
   326 sub base62 {
       
   327     my $n = shift // $_;
       
   328     die 'input is >MAXINT' if $n == $n - 1 or $n == $n + 1;
       
   329     state $digits = [0 .. 9, 'a' .. 'z', 'A' .. 'Z'];
       
   330     state $base = @$digits;
       
   331     my @result;
       
   332 
       
   333     for (; $n >= $base ; $n = int($n / $base)) {
       
   334         my $mod = $n % $base;
       
   335         unshift @result, $digits->[$mod];
       
   336     }
       
   337     unshift @result, $digits->[$n];
       
   338     join '', @result;
       
   339 }
       
   340 
       
   341 sub untaint {
       
   342     my ($_, $rx) = (@_, qr((\w+)));
       
   343     /$rx/;
       
   344     die sprintf("%s:%s:%d: untaint('%s', %s): not defined\n", caller, $_, $rx)
       
   345       if not defined $1;
       
   346     return $1;
       
   347 }
       
   348 
       
   349 sub gen_uuid {
       
   350 
       
   351     #open my $f, '/dev/urandom' or croak;
       
   352     #read $f, my($_), 128/8;
       
   353     #/^(.*)$/;
       
   354     #die join '-', map { base62 $_ } unpack 'Q*', $1;
       
   355     return base62 int rand(2**64);
       
   356 }