# vim: set cindent expandtab ts=4 sw=4:
#
# Copyright (c) 1998-2005 Chi-Keung Ho. All rights reserved.
#
# This programe is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# Extmail - a high-performance webmail to maildir
# $Id$
package Ext::Storage::Fileman;
use strict;

# fileman specification
#
# XXX - fileman derive the Maildir++ protocol
#
# Contents of filesize
# filesize contains two or more lines terminated by newline characters.
#
# The first line contains a copy of the quota definition as used by the
# system's mail server. Each application that uses the maildir must know
# what it's quota is. Instead of configuring each application with the 
# quota logic, and making sure that every application's quota definition
# for the same maildir is exactly the same, the quota specification used
# by the system mail server is saved as the first line of the maildirsize
# file. All other application that enforce the maildir quota simply read
# the first line of maildirsize.
#
# The quota definition is a list, separate by commas. Each member of the
# list consists of an integer followed by a letter, specifying the nature
# of the quota. Currently defined quota types are 'S' - total size of all
# messages, and 'C' - the maximum count of messages in the maildir.
#
# For example, 10000000S,1000C specifies a quota of 10,000,000 bytes or
# 1,000 messages, whichever comes first.
#
# All remaining lines all contain two integers separated by a single space.
# The first integer is interpreted as a byte count. The second integer is
# interpreted as a file count. A fileman writer can add up all byte
# counts and file counts from maildirsize and enforce a quota based either
# on number of messages or the total size of all the messages.
#

# Update 2005-07-28, experimantal OO design, now use global varibles to
# store configuration and some information instead of using object, for
# performance reason. OO object new/del a hundred thousand times will
# be much slower than direct reference :-(

use Fcntl ':flock';
use Exporter;
use vars qw(@ISA @EXPORT %CFG $SORTORDER);
@ISA = qw(Exporter);
@EXPORT = qw(
    fget_dirlist op_rename fixslash
    op_addfile op_getfile op_mkdir op_rmdir op_move
    op_rmfile fget_quota fget_curquota freset_quota
    fre_calculate fupdate_quota fis_overquota fscan_dir
    get_files_list ext2mime fixpath fupdate_quota_s);

%CFG=();
use Ext::Utils qw(untaint); # import the untaint func
use Ext::RFC822 qw(str2time); # import str2time
use Ext::Storage::Maildir qw(valid_dirname); # import valid_dirname

# init - init a fileman, for read/write and quota maintains
#
# XXX $path is the base path, every I/O can't over collapse this
# limitaion, or it will become a big security hole.
sub init {
    my ($path, $mode, $warnlv) = @_;

    # XXX the magic to set default permission
    umask(0077);

    $CFG{path} = untaint ($path ? $path : "./fileman/"); # relative path
    $CFG{mode} = $mode ? $mode : "O_RW";
    $CFG{warnlv} = $warnlv ? $warnlv : "0.9"; # 90% default
    $CFG{ctrlfile} = fixslash("$CFG{path}/filesize");

    my $file = $CFG{path}.'/filesize';

    if (! -d $CFG{path}) {
        mkdir untaint ($CFG{path}), 0700; # raw mkdir call, so $CFG{path} must
                                          # be a security path
    }

    chdir($CFG{path}) or die "Can't chdir to $CFG{path}, $!\n";

    # re-calculate if not exist or bigger than 5120 bytes
    if(! -e $file ||  (stat $file)[7] >= 5120) {
        fre_calculate();
    }

    1;
}

# op_mkdir - create a directory
sub op_mkdir {
    my $folder = fixslash(fixpath($_[0]));

    return "$folder exists" if(-d "$CFG{path}/$folder");

    mkdir untaint("./$folder"), 0700;
    0;
}

# op_rmdir - delete a directory
sub op_rmdir {
    my $folder = untaint(fixpath($_[0]));
    my $flag = $_[1];

    if($folder =~/^(filesize|\.\.)$/) {
        # Ignore default folder
        return 'Can\'t remove system file';
    }

    # realdir => the real full path directory name
    # folder => the relative path name
    my $realdir = fixslash("$CFG{path}/$folder");

    if(-d $realdir) {
        my @entries = ();
        opendir DIR, $realdir or die "Can't opendir, $!\n";
        @entries = grep { !/^\.$/ && !/^\..$/ } readdir(DIR);
        closedir DIR;

        for my $f (@entries) {
            if(-d "$realdir/$f") {
                # the $flag is to indicate that whether we
                # are in recursive mode, if yes ignore all checks
                op_rmdir("$folder/$f", 1);
                next;
            }
            unlink untaint("$realdir/$f") or print "error: $!\n";
        }
        rmdir "$realdir" || return "op_rmdir() $folder: $!\n"; # this remove dir left
        return 0;
    }
    0;
}

# op_move - move a file or directory to another directory
#
# from => file or directory
# to => destination directory
sub op_move {
    my ($from, $to) = @_;
    $from = fixpath($from);
    $to = fixpath($to);

    my $rfrom = fixslash("$CFG{path}/$from");
    my $rto = fixslash("$CFG{path}/$to");
    my $fromname;

    if ($rfrom eq $CFG{ctrlfile} || $rto eq $CFG{ctrlfile}) {
        return 'Can\'t move system control file';
    }

    # whether it's directory or file, get the basename
    if (-r $rfrom) {
        $from =~ s#/+$##;
        $rto =~ s#/+$##;   # XXX fix fromname and rto
        $from =~ m#([^\/]+)$#;
        $fromname = $1 || $from;
    } else {
        return "$from not exists";
    }

    return "$to or $from invalid" if ($to =~ m#^\.$# or $from =~ m#^\.\.$#);
    return "Directory $to not exists" unless (-d $rto);
    return 'Source equal Destination, Abort!' unless ($rfrom ne $rto);

    if ($from =~ m#^/filesize$#) {
        return 'Source or Destination name invalid';
    }

    rename(untaint($rfrom), untaint("$rto/$fromname")) ||
        return "rename $from to $to error, $!";
    return 0;
}

sub op_rename {
    my ($from, $to) = @_;

    $from = untaint($from);
    $to = untaint($to);

    my $rfrom = fixslash(fixpath("$CFG{path}/$from"));
    my $rto = fixslash(fixpath("$CFG{path}/$to"));

    $from = fixslash(fixpath($from));
    $to = fixslash(fixpath($from));

    if ($rfrom eq $CFG{ctrlfile} || $rto eq $CFG{ctrlfile}) {
        return "Can't rename system control file";
    }

    if (!-r $rfrom) {
        return "$from not exists";
    } elsif (-r $rto) {
        return "$to already exists";
    }

    rename($rfrom, $rto) || return "rename fail, $!\n";
    return 0;
}

sub op_addfile {
    my ($from, $dist, $op) = @_;

    $from = untaint (fixpath($from));
    $dist = untaint (fixslash("$CFG{path}/".fixpath($dist)));

    my $size = (stat $from)[7]; # size contain CGI header ?
    my $rv = fis_overquota($size, '1');
    my $dist_size;

    if ($rv eq 2) {
        return "Storage over quota, abort!";
    }

    # ignore the filesize at the topdir, override it
    # is forbidden and dangous! XXX FIXME
    if ($dist eq $CFG{ctrlfile}) {
        return "Can't override filesize, abort!";
    }

    if (-r $dist) {
        # dist file exists, override!!
        $dist_size = (stat $dist)[7];
    }
    open (IN, "< $from") or return "$from open fail, $!\n";
    open (OUT, "> $dist") or return "$dist write fail, $!\n";

    if ($op && $op eq 'strip_header') {
        my $CRLF = "\015\012"; # \r\n # XXX
        local $/ = $CRLF.$CRLF;
        <IN>; # strip the header for some Ext::CGI upload file
    }

    my $pos_start = tell IN;
    while (<IN>) {
        print OUT $_;
    }
    my $pos_end = tell IN;
    close IN;
    close OUT;

    $size = $pos_end - $pos_start;
    if (defined $dist_size && $dist_size>=0) {
        fupdate_quota($dist_size, -1);
    }
    fupdate_quota($size >0 ? $size : 0 , 1);

    0;
}

sub op_getfile {
    my $file = shift;

    $file = "$CFG{path}/$file";
    $file = fixpath(fixslash($file));

    open (my $stream , " < $file") or die "$file open fail, $!\n";
    return $stream;
}

sub op_rmfile {
    my $file = untaint (fixpath($_[0])); # remove dangerous characters
    $file = "$CFG{path}/$file";
    $file = fixslash($file);
    return "Can't remove $file" if ($file eq $CFG{ctrlfile});

    my $size = (stat $file)[7];

    unlink $file or return "$file remove fail, $!";
    fupdate_quota("-$size", '-1');
    0; # success
}

sub fget_dirlist {
    my $dir = fixpath($_[0] || '/');
    my $rv = ['/'];
    push @$rv, @{_dirlist($dir)};
    $rv;
}

# private function
sub _dirlist {
    my $dir = fixpath($_[0]);
    my @entries;
    my $realdir = fixslash("$CFG{path}/$dir");

    if (-d $realdir) {
        opendir DIR, $realdir or die "Can't opendir, $!\n";
        my @rv = sort grep { !/^\./ && -d "$realdir/$_" } readdir(DIR);
        closedir DIR;

        for my $d (@rv) {
            push @entries, fixslash("$dir/$d/");
            my $rv2 = _dirlist("$dir/$d/");
            push @entries, @$rv2;
        }
    }
    \@entries;
}

# fget_quota - to get a Maildir quota limitation info, return a HASH
sub fget_quota {
    my $path = $CFG{path};
    my ($smax, $cmax);

    $path = untaint($path);

    # Update 2005-07-26, check maildirsize first, fallback to
    # $ENV{DEFAULT_QUOTA} if not present.
    if(-e "$path/filesize") {
        open(my $FD, "< $path/filesize") or
            die "Can't open filesize, $!\n";
        $_=<$FD> || "";
        close FD;
    }elsif($ENV{FILEMAN_QUOTA}) {
        $_=$ENV{FILEMAN_QUOTA};
    }else {
        $_=$ENV{DEFAULT_QUOTA}; # default quota
    }

    if(!length($_)) {
        return {
            size => undef,
            count => undef
        }
    }
    if(/(\d+)C/i) {
        # has a count quota
        $cmax = $1;
    }elsif(/(\d+)S/i) {
        # has a size quota
        $smax = $1;
    }

    return {
        size =>$smax,
        count => $cmax
    }; # return a ref of HASH
}

# fget_curquota - to get current quota usage, return a ARRAY not HASH
sub fget_curquota {
    my $path = $CFG{path};
    my ($size, $cnt) = (0,0);

    $path = untaint($path);

    open(FD, "<$path/filesize") or
        die "Can't open maildirsize, $!\n";
    my $s = <FD>; # omit the first line;
    seek(FD,0,0) unless($s=~/S|C/); # unget if no quota limit
    while(<FD>) {
        chomp;
        /\s*([\-]*\d+)\s+([\-]*\d+)/;
        $cnt = $cnt+$2;
        $size = $size+$1; # include -xxx, perl will automaticlly
                          # handle nagetive value :)
    }
    return {
        size => $size,
        count => $cnt
    }; # return a ref of HASH
}

# set_quota - reset quota to a new value, mostly used by ADMIN API
sub freset_quota {
    my ($q_size, $q_cnt) = @_;
    my $path = $CFG{path};

    $path = untaint($path);
    open(FD, "< $path/filesize") or
        die "Can't open filesize, $!\n";
    my $s = <FD>;# omit the first line
    seek(FD,0,0) unless($s=~/S|C/); # unget if no quota limit
    local $/= undef;
    $s = <FD>;
    close FD;

    open(FD, "> $path/filesize.tmp") or
        die "Can't open filesize.tmp for write, $!\n";
    print FD "$q_size"."S";
    if($q_cnt) {
        print FD ",$q_cnt"."C";
    }
    print FD "\n"; # newline
    print FD $s;
    close FD;

    unlink untaint($path."/filesize") || die "unlink fail: $!\n";
    rename untaint($path."/filesize.tmp"), untaint($path."/filesize") or
        die "Can't rename:$!\n";
    1;
}

sub fre_calculate {
    my $inf2 = fscan_dir();
    my $inf = fget_quota;

    open(FD, "> $CFG{ctrlfile}.tmp") or
        die "Can't open filesize.tmp: $!\n";
    flock (FD, LOCK_EX);
    if($inf->{size} and $inf->{count}) {
        print FD $inf->{size}."S,".$inf->{count}."C\n";
    }elsif($inf->{size}) {
        print FD $inf->{size}."S\n";
    }elsif($inf->{count}) {
        print FD $inf->{count}."C\n";
    }

    my $str = _fmt2mds($inf2->{sizes}, $inf2->{files});
    print FD $str;
    flock (FD, LOCK_UN);
    close FD;

    rename (untaint($CFG{path}."/filesize.tmp"),
            untaint($CFG{path}."/filesize"))
        or die "Can't rename filesize, $!\n";
}

# fupdate_quota - do an append action to filesize file.
sub fupdate_quota {
    my ($size, $count) = @_;
    my $file = defined $CFG{mdspath} ?
                   $CFG{mdspath}: $CFG{path}.'/filesize';

    $file = untaint($file);

    open(FD, ">> $file") or
        die "Can't open filesize, $!\n";
    flock(FD, LOCK_EX);
    my $str = _fmt2mds($size, $count);
    print FD $str;
    flock(FD, LOCK_UN);
    close FD;

    # after update, check filesize file size;
    if((stat $file)[7] >= 5120) {
        fre_calculate();
    }
}

# fupdate_quota_s - update bunch of quota records
sub fupdate_quota_s {
    my $ref = $_[0];
    my $file = defined $CFG{mdspath} ?
                  $CFG{mdspath}: $CFG{path}.'/filesize';

    $file = untaint($file);
    open(my $FD, ">> $file") or
        die "Can't open filesize, $!\n";
    flock($FD, LOCK_EX);
    foreach(keys %$ref) {
        my($s,$c) = split(/\s/, $ref->{$_});
        my $str = _fmt2mds($s,$c);
        print $FD $str;
    }
    flock($FD, LOCK_UN);
    close $FD;

    # after update, check filesize file size;
    if((stat $file)[7] >= 5120) {
        fre_calculate();
    }
}

# _fmt2mds format given params into filesize record
sub _fmt2mds {
    my $smaxlen = 8; # recommand 10 digitals
    my $cmaxlen = 5; # recommand 6 digitals
    my $put = "";
    my($s,$c) = @_; # size can be nagetive, like -1260
    if(length($s) < $smaxlen) {
        my $delta = $smaxlen - length($s);
        $put .= " " x $delta . "$s";
    }else {
        $put .="$s";
    }

    if(length($c) < $cmaxlen) {
        my $delta = $cmaxlen - length($c);
        $put .=" ". " " x $delta . "$c";
    }else {
        $put .=" $c";
    }

    return "$put\n";
}

# fis_overquota - check whether a Maildir is over quota, need fget_quota
# this function will automatically set overquota flag to a file:
# $HOME/Maildir/quotawarn.
#
# Tricks: if any of (size, count) is 'undef' or '0', means no limit!
#
# Update: 2005-07-31 use SOFT/HARD_OVER to identify whether a maildir
# is nearly overquota or already overquota
#
# 2005-08-05 add two params to calculate where it's overquota, while
# writing a new email 
use constant NO_OVERQT => 0;
use constant SOFT_OVER => 1;
use constant HARD_OVER => 2;
sub fis_overquota {
    my ($addsize, $addcnt) = @_;
    my $cur = fget_curquota();
    my ($q_size, $q_cnt);
    my $sig = 0; # XXX NOT_OVER

    my $inf = fget_quota();
    $q_size = $inf->{size} ? $inf->{size} : 0;
    $q_cnt = $inf->{count} ? $inf->{count} : 0;

    $cur->{size} += $addsize if(defined $addsize && $addsize>0);
    $cur->{count} += $addcnt if(defined $addcnt && $addcnt>0);

    if($q_cnt) { # quota set
        if($cur->{count} >= $q_cnt) {
            $sig = 2; # XXX HARD_OVER
        }elsif($cur->{count} >= int($q_cnt*$CFG{warnlv})) {
            $sig = 1; # XXX SOFT_OVER
        }
    }

    if($q_size) { # quota set
        # XXX all SOFT_OVER
        unless ($sig>1) { # if not HARD_OVER
            if($cur->{size} >= $q_size) { # HARD_OVER
                $sig = 2;
            }else {
                if($cur->{size} >= $q_size*$CFG{warnlv}) {
                    $sig = 1;
                }
            }
        }
    }

    $sig;
}

sub fscan_dir {
    my $rv = get_dir_cnt(''); # the top path?
    return $rv;
}

# get_files_list - a public func to get a formated files list
sub get_files_list {
    my $dir = fixpath(shift);
    my $path = fixslash($CFG{path}."/$dir");

    opendir DIR, $path || die "Can't opendir $path, $!\n";
    my @dir = sort {$a cmp $b} grep { !/^\.$/ && !/^\..$/ } readdir DIR;
    close DIR;

    if ($dir =~ m#^\s*/*\s*$#) {
        # means we are in topdir, ignore ctrlfile
        @dir = grep { !/^filesize$/ } @dir;
    }
    @dir;
}

my %ext_maps = (
    'gz'    => 'zip',
    'zip'   => 'zip',
    'rar'   => 'zip',
    'tar'   => 'zip',
    'tgz'   => 'zip',
    'bz2'   => 'zip',
    'jpg'   => 'pic',
    'jpeg'  => 'pic',
    'gif'   => 'pic',
    'exe'   => 'exe',
    'com'   => 'exe',
    'bin'   => 'exe',
    'pl'    => 'txt',
    'php'   => 'txt',
    'jsp'   => 'txt',
    'asp'   => 'txt',
    'py'    => 'txt',
    'h'     => 'txt',
    'cpp'   => 'txt',
    'c'     => 'txt',
    'in'    => 'txt',
    'ini'   => 'ini',
    'html'  => 'html',
    'css'   => 'css',
    'js'    => 'txt',
    'csv'   => 'xls',
    'xls'   => 'xls',
    'ppt'   => 'ppt',
    'doc'   => 'doc',
    'pdf'   => 'pdf',
    'chm'   => 'chm',
    'au'    => 'au',
    'mp3'   => 'mp3',
    'rm'    => 'rm',
    'wav'   => 'au',
    'wmv'   => 'stream',
);

sub ext2mime {
    my $file = $_[0];
    if ($file =~ m!/!) {
        $file =~ s#.*/([^\/]+)$#$1#;
    }

    $file =~ m#\.([^\.]+)$#;
    if (my $ext = lc $1) {
        return $ext_maps{$ext} if ($ext_maps{$ext});
    }

    'txt';
}

sub _length_fmt {
    my ($s, $len) = @_;
    my $delta  = 0;
    if(length($s)<$len) {
        $delta = $len - length($s);
    }
    return ('0' x $delta).$s;
}

sub _parse_cache {
    my $s = $_[0];
    my %info = ();
    my @a = split(/\n/, $s);
    foreach(@a) {
        /^([^=]+)=(.*)/;
        $info{$1}=$2;
    }
    \%info;
}

# get_dir_cnt - get a specific directory sizes and file count
sub get_dir_cnt {
    my $dir = fixpath($_[0]); # XXX should be relative path?!
    my $rdir = fixslash("$CFG{path}/$dir");
    my %stat = (dirs => 0, files => 0, sizes => 0);

    opendir DIR, $rdir or die "$rdir open fail, $!\n";
    my @lists = grep { !/^\.$/ && !/^\.\.$/ } readdir DIR;
    close DIR;

    if ($dir =~ m#^\s*/*\s*$#) {
        # means we are in topdir, ignore ctrlfile
        @lists = grep { !/^filesize$/ } @lists;
    }

    for (@lists) {
        if (-d "$rdir/$_") { # a directory
            $stat{dirs} ++;
            my $rv = get_dir_cnt("$dir/$_");
            if ($rv) {
                $stat{dirs} += $rv->{dirs};
                $stat{files} += $rv->{files};
                $stat{sizes} += $rv->{sizes};
            }
        }else {
            $stat{files}++;
            $stat{sizes} += (stat "$rdir/$_")[7];
        }
    }

    return {
        dirs => $stat{dirs},
        files => $stat{files},
        sizes => $stat{sizes},
    };
}

# Utils funct*
#
# name2mdir - convert a given folder name, aka 'Inbox' or 'Trash' etc,
# to a dir, which makes sense to low level operation.

sub fixslash {
    my $path = shift;
    $path =~ s#/{2,}#/#g;
    $path;
}

# fixpath - an important function to remove dangerous
sub fixpath {
    my $path = shift;
    # fix bug, old bug code: $path =~ s#\.{2,}/##g;
    $path =~ s#/\.+##g; # strip /.. or /... etc
    $path =~ s#\.+/##g; # strip ../ or .../ etc
    $path =~ s#\\\.+##g; # strip \. or \... etc
    $path =~ s#\\+##g;   # strip \\ or \\\ etc
    $path;
}

1;


syntax highlighted by Code2HTML, v. 0.9.1