#!/usr/bin/perl # Copyright © 2006, 2007, 2008, 2009, 2010, 2011 # Łukasz Indeka , # Piotr Lewandowski , # Jakub Wilk . # # This program is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License, version 2, as published # by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # more details. # # You should have received a copy of the GNU General Public License along with # this program; if not, see . # # Linking mbank-cli statically or dynamically with other modules is making a # combined work based on mbank-cli. Thus, the terms and conditions of the GNU # General Public License cover the whole combination. # # In addition, as a special exception, the copyright holders of mbank-cli give # you permission to combine mbank-cli with the OpenSSL library (or modified # versions of this library, with unchanged license). You may copy and # distribute such a system following the terms of the GNU GPL for mbank-cli # and the licenses of the OpenSSL library, provided that you include the # source code of that other code when and as the GNU GPL requires distribution # of source code. # # Note that people who make modified versions of mbank-cli are not obligated # to grant this special exception for their modified versions; it is their # choice whether to do so. The GNU General Public License gives permission to # release a modified version without this exception; this exception also makes # it possible to release a modified version which carries forward this # exception. use strict; use warnings; no encoding; use Carp (); use Crypt::SSLeay (); use Digest (); use Encode (); use File::Basename qw(dirname); use Getopt::Long qw(:config gnu_compat permute no_getopt_compat no_ignore_case); use HTML::Entities (); use HTML::Form (); use HTTP::Cookies (); use HTTP::Request::Common qw(GET POST); use I18N::Langinfo qw(langinfo CODESET); use LWP::UserAgent (); use POSIX qw(mktime strftime); use Text::ParseWords (); use constant { EXIT_OK => 0, EXIT_USER_ERROR => 1, EXIT_HTTP_ERROR => 2, EXIT_API_ERROR => 3, WEB_CODESET => 'ISO-8859-2', FAKE_DOMAIN => 'mbank-cli.invalid', GPG_EXECUTABLE => '/usr/bin/gpg' }; chdir dirname($0) or die "Can't change working directory: $!"; my $mbank_host = undef; # use set_country() to define my $mbank = undef; # use set_country() to define my $cookie_jar_file = './cookie-jar.txt'; my $config_file = './mbank-cli.conf'; sub set_country($) { my ($cc) = @_; $cc = lc $cc; $cc =~ m/^[a-z][a-z]$/ or user_error("Invalid country code: $cc"); $mbank_host = $cc eq 'pl' ? 'www.mbank.com.pl' : "$cc.mbank.eu"; $mbank = "https://$mbank_host"; } set_country('pl'); # Strings that differ across countries: my %messages = ( account_rename => qr{Zmiana nazwy rachunku}, account_rename_confirm => qr{Zatwierd\xbc}, account_rename_successful => qr{Operacja wykonana poprawnie}, bad_date => qr{Nieprawid\xB3owa data lub data poza dopuszczalnym zakresem}, future_none => qr{Brak planowanych operacji}, history_details => qr{Zobacz szczeg\xf3\xb3y operacji}, history_previous => qr{Poprzednie operacje}, history_show => qr{Zobacz[^"]*operacje[^"]*}, invalid_session_key => qr{Alarm bezpiecze\xf1stwa[.] Nieprawid\xb3owy lub niewa\xbfny klucz sesji[.]}, login_error => qr{B\xb3\xb1d logowania}, logout => qr{mBank - wylogowanie}, menu_savings => qr{Oszcz\xeadno\xb6ci}, menu_term_deposits => qr{mLokaty}, menu_funds => qr{Fundusze inwestycyjne}, menu_investments => qr{Inwestycje}, notices => qr{Wiadomo\xb6(\xe6|ci)}, select_this_account => qr{Wybierz ten rachunek}, system_error => qr{B\xb3\xb1d systemu}, ); $::locale_codeset = langinfo(CODESET); %::fallback_map = ( 0x104 => 'A', 0x105 => 'a', # letter A with ogonek 0x0c1 => 'A', 0x0e1 => 'a', # letter A with acute 0x0c4 => 'A', 0x0e4 => 'a', # letter A with diaeresis 0x106 => 'C', 0x107 => 'c', # letter C with acute 0x10c => 'C', 0x10d => 'c', # letter C with caron 0x10e => 'D', 0x10f => 'd', # letter D with caron 0x118 => 'E', 0x119 => 'e', # letter E with ogonek 0x0c9 => 'E', 0x0e9 => 'e', # letter E with acute 0x11a => 'E', 0x11b => 'e', # letter E with caron 0x0cd => 'I', 0x0ed => 'i', # letter I with acute 0x141 => 'L', 0x142 => 'l', # letter L with stroke 0x139 => 'L', 0x13a => 'l', # letter L with acute 0x13d => 'L', 0x13e => 'l', # letter L with caron 0x143 => 'N', 0x144 => 'n', # letter N with acute 0x147 => 'N', 0x148 => 'n', # letter N with caron 0x0d3 => 'O', 0x0f3 => 'o', # letter O with acute 0x0d4 => 'O', 0x0f4 => 'o', # letter O with circumflex 0x154 => 'R', 0x155 => 'r', # letter R with acute 0x158 => 'R', 0x159 => 'r', # letter R with caron 0x15a => 'S', 0x15b => 's', # letter S with acute 0x160 => 'S', 0x161 => 's', # letter S with caron 0x164 => 'T', 0x165 => 't', # letter T with caron 0x0da => 'U', 0x0fa => 'u', # letter U with acute 0x16e => 'U', 0x16f => 'u', # letter U with ring above 0x0dd => 'Y', 0x0fd => 'y', # letter Y with acute 0x179 => 'Z', 0x17a => 'z', # letter Z with acute 0x17b => 'Z', 0x17c => 'z', # letter Z with dot above 0x17d => 'Z', 0x17e => 'z', # letter Z with caron ); sub show_help() { print STDERR < | --from [--to ] ] { | -M ... | -A} mbank-cli future { | -M ... | -A} mbank-cli withholdings { | -M ... | -A} mbank-cli funds mbank-cli deposits mbank-cli notices mbank-cli rename mbank-cli logout mbank-cli void Common options: --verbose --debug --config --cookie-jar EOF exit EXIT_OK; } sub encoding_fallback($) { (local $_) = @_; return $::fallback_map{$_} if exists $::fallback_map{$_}; return sprintf "", $_; } sub widen_string($;$) { (local $_, my $codeset) = @_; $codeset //= $::locale_codeset; return Encode::decode($codeset, $_); } sub localize_html_string($;$) { (local $_, my $codeset) = @_; $codeset //= WEB_CODESET; $_ = widen_string $_, $codeset; $_ = HTML::Entities::decode_entities $_; return Encode::encode($::locale_codeset, $_, \&encoding_fallback); } sub lwp_init() { my $ca_dir = $ENV{HTTPS_CA_DIR}; $ca_dir //= '/etc/ssl/certs/'; map { delete $ENV{$_}; } grep(/^HTTPS_/, keys %ENV); $ENV{'HTTPS_VERSION'} = 3; $ENV{'HTTPS_DEBUG'} = 0; $ENV{'HTTPS_CA_DIR'} = $ca_dir; umask(umask() | 077); my $ua = new LWP::UserAgent( agent => 'Mozilla/5.0', cookie_jar => HTTP::Cookies->new(file => $cookie_jar_file, autosave => 1, ignore_discard => 1), requests_redirectable => ['GET', 'POST'], protocols_allowed => ['https'], timeout => 30 ); return $ua; } my $ua; my $verbose = 0; my $debug_directory = undef; sub write_log($) { local ($_) = @_; return unless defined $debug_directory; my $logfile = "$debug_directory/log"; open(LOG, '>>', $logfile) or die "Can't create $logfile: $!"; print LOG "$_\n"; close(LOG) or die "Can't close $logfile: $!"; } sub debug($) { local ($_) = @_; write_log $_; print STDERR "$_\n" if $verbose; } sub user_error($) { local ($_) = @_; write_log $_; print STDERR "$_\n"; exit EXIT_USER_ERROR; } sub api_error($) { local ($_) = @_; $_ = sprintf 'Oops, API error! [%s]', $_; write_log $_; Carp::cluck $_; exit EXIT_API_ERROR; } sub http_error($) { my ($request) = @_; $_ = sprintf 'HTTP error while processing request <%s %s>', $request->method, $request->uri; if ($@) { my $extra_message = $@; $extra_message =~ s/^/| /gm; $_ = "$_\n$extra_message"; } write_log $_; Carp::cluck $_; exit EXIT_HTTP_ERROR; } sub check_for_error($) { local ($_) = @_; return $1 if m{ \s*

\s* (.*?) \s*

\s* }x; return ''; } sub download($) { my ($request) = @_; # LWP does not check if hostname matches CN, we need to check that manually. my $subject_regex = qr(/CN=\Q$mbank_host\E$); $request->header('If-SSL-Cert-Subject' => $subject_regex); debug sprintf('Download <%s %s>', $request->method, $request->uri); my $response = $ua->request($request); http_error $request unless $response->is_success; my $content = $response->content; $content =~ s/\r//g; my $error = check_for_error($content); my $filename = $request->uri; $filename =~ s{^\w+://.*?/}{}; $filename =~ s/[?].*//; $filename =~ s/[^[:alnum:].]/_/g; $filename =~ s/(?:[.]\w+)?$/.html/; $filename = 'index.html' if $filename eq '.html'; if (defined $debug_directory) { my $debugfile = "$debug_directory/$filename"; open(LOG, '>', "$debugfile") or die "Can't create $debugfile: $!"; print LOG $content; close(LOG) or die "Can't close $debugfile: $!"; } return { response => $response, content => $content, error => $error }; } sub preread_config() { BEGIN { our $digest_module; eval { $digest_module = Digest->new('SHA-256'); }; eval { $digest_module = Digest->new('SHA-1'); } if $@; $digest_module = Digest->new('MD5') if $@; } user_error "Can't open the config file: $!" unless open CONFIG, '<', $config_file; my $prev_digest = ''; $main::digest_module->new(); my $header = ''; read CONFIG, $header, 28; my $need_decrypt = $header eq "-----BEGIN PGP MESSAGE-----\n" || $header =~ /^\x85\x02/; $main::digest_module->add($header); $main::digest_module->addfile(*CONFIG); close CONFIG or die "Can't close config file: $!"; my $digest = $main::digest_module->b64digest(); $ua->cookie_jar->scan( sub { my ($version, $key, $val, $path, $domain) = @_; $prev_digest = $val if $domain eq FAKE_DOMAIN and $path eq '/config/' and $key eq 'sha1'; # for compatibily reasons, key is named 'sha1' rather than 'digest' } ); if ($digest ne $prev_digest) { debug 'Your personality has just changed'; $ua->cookie_jar->clear(); } $ua->cookie_jar->set_cookie(0, 'sha1', $digest, '/config/', FAKE_DOMAIN, undef, undef, undef, 1 << 25, undef); return $need_decrypt; } sub read_config_file($) { my ($fp) = @_; my %result = (); my $error; local $_; while (<$fp>) { next if /^(?:#|\s*)$/; if (/^\s*([\w-]+)\s+(.*\S)\s*$/) { my $key = lc $1; my ($value) = Text::ParseWords::parse_line('^$', 0, $2); $result{$key} = $value; } elsif (not defined $error) { $error = $.; } } if (defined $error) { user_error("Can't parse the config file (line $.)"); } return %result; } sub read_config($) { my ($need_decrypt) = @_; user_error "Can't open the config file: $!" unless open STDIN, '<', $config_file; my $fp; if ($need_decrypt) { open GPG, '-|', GPG_EXECUTABLE, '--decrypt' or die "Can't invoke gpg: $!"; $fp = \*GPG; } else { $fp = \*STDIN; } my %config = read_config_file($fp); close GPG or user_error q(Can't read the config file) if $need_decrypt; close STDIN or die "Can't close pipe: $!"; my $login = $config{'login'}; user_error('No login name provided') unless defined $login; user_error("Invalid login name '$login'") unless $login =~ /^\d+$/; my $password = $config{'password'}; user_error('No password provided') unless defined $password; user_error("Invalid password '$password'") unless length($password) > 0; return ($login, $password) } sub do_logout() { debug 'Logging out...'; $ua->protocols_allowed(['http', 'https']); my $have_cookies = 0; $ua->cookie_jar->scan( sub { my ($version, $key, $val, $path, $domain) = @_; $have_cookies = 1 unless $domain eq FAKE_DOMAIN; } ); user_error 'You are not logged in' unless $have_cookies; my $web_logout = download GET("$mbank/logout.aspx"); $ua->cookie_jar->clear(); debug 'Cookies have been wiped out'; if (check_session_expiry($web_logout)) { debug 'Probably you have been already logged out' } else { $web_logout->{content} =~ m($messages{logout}) or api_error('logout-failed'); } } sub do_login($$) { debug 'Logging in...'; my ($web_in, $need_decrypt) = @_; my @forms = HTML::Form->parse($web_in->{response}); $#forms == 0 or api_error('login-form'); my ($form) = @forms; my $in_login = $form->find_input('customer', 'text'); my $in_passw = $form->find_input('password', 'password'); api_error('login-field') unless defined $in_login; api_error('password-field') unless defined $in_passw; api_error('login-button') unless $web_in->{content} =~ m{}) { my $onclick = $1; my @forms = HTML::Form->parse($web->{response}); $#forms == 0 or api_error('history-table-button'); my ($form) = @forms; foreach my $input ($form->inputs) { $input->disabled(1) if defined $input->name and $input->name =~ '^lastdays_\w+$'; } my $prev_req = onclick_to_req($form, $onclick); $web = download $prev_req; next WEB_LOOP; } last WEB_LOOP; } } sub correct_date($) { local ($_) = @_; return undef unless defined $_; my $time; return $_ if $_ eq 'now'; if (m/(\d{4})-(\d{2})-(\d{2})/) { $time = mktime 0, 0, 0, $3, $2-1, $1-1900; @_ = localtime $time; return $_ if $3 == $_[3] and $2 == $_[4] + 1 and $1 == $_[5] + 1900 and $1 >= 1900; } debug "Invalid date: $_"; return undef; } sub ground_date($$) { my ($date, $now) = @_; $date = $now if $date eq 'now'; $date =~ m/^(\d{4})-(\d\d)-(\d\d)$/ or die; return ($1, $2, $3); } sub check_session_expiry($) { my ($web) = @_; return 1 if ($web->{error} =~ m{^$messages{system_error}$} and $web->{content} =~ m{ $messages{invalid_session_key}

}x); } my ($opt_from, $opt_to); my $opt_range = undef; my $opt_multiple_accounts = 0; GetOptions( 'verbose' => \$verbose, 'debug=s' => \$debug_directory, 'config=s' => \$config_file, 'cookie-jar=s' => \$cookie_jar_file, 'from=s' => sub { shift; $opt_from = correct_date shift; $opt_range = 0; }, 'to=s' => sub { shift; $opt_to = correct_date shift; $opt_range = 0; }, 'range=s{2}' => sub { shift; if (($opt_range || 0) == 0) { $opt_from = correct_date shift; $opt_to = undef; $opt_range = 1; } else { $opt_to = correct_date shift; $opt_range = 0; } }, 'M|multiple-accounts' => sub { $opt_multiple_accounts = 1; }, 'A|all-accounts' => sub { $opt_multiple_accounts = 99; }, 'h|help' => \&show_help, ) or exit EXIT_USER_ERROR; $ua = lwp_init(); my $need_decrypt_config = preread_config(); my $action = shift @ARGV; $action //= 'list'; my $selected_accounts; my $new_account_name; debug "Action: $action"; exit EXIT_OK if $action eq 'void'; if ($action eq 'logout') { do_logout(); exit EXIT_OK; } elsif (grep $action eq $_, qw(history future withholdings)) { $opt_multiple_accounts++ if $opt_multiple_accounts == 0 and $#ARGV > 0; if ($opt_multiple_accounts > 1) { $selected_accounts = qr(^); } else { user_error 'No account selected' if $#ARGV < 0; @_ = map { widen_string $_ } @ARGV; @_ = map quotemeta, @_; $_ = join '|', @_; s/\\\*/.*/g; $selected_accounts = qr/^($_)$/; } if ($action eq 'history') { if (defined $opt_range) { $opt_to = correct_date 'now' if defined $opt_from and not defined $opt_to; user_error 'No or invalid time range selected' unless defined $opt_from and defined $opt_to and ($opt_to ge $opt_from); debug "Using time range $opt_from ... $opt_to"; } else { debug 'Using default time range'; } } } elsif ($action eq 'rename') { user_error 'No account selected' if $#ARGV < 0; user_error 'No new account name provided' if $#ARGV < 1; $_ = widen_string shift; $_ = quotemeta $_; s/\\\*/.*/g; $selected_accounts = qr/^$_$/; $_ = widen_string shift; user_error 'Invalid new account name' if not defined $_; $new_account_name = $_; } elsif (grep $action eq $_, qw(list funds deposits notices)) { } else { user_error 'Invalid action'; } my $need_login = 1; my $web_accounts_list; $ua->cookie_jar->scan( sub { my ($version, $key, $val, $path, $domain) = @_; $need_login = 0 if $domain eq FAKE_DOMAIN and $path eq '/login-options/'; } ); if (!$need_login) { debug 'Trying to reuse previous session'; $web_accounts_list = download GET("$mbank/accounts_list.aspx"); if (check_session_expiry($web_accounts_list)) { debug 'Invalid or expired session key'; $need_login = 1; } elsif ($web_accounts_list->{error} ne '') { api_error('pre-login ' . $web_accounts_list->{error}); } } if ($need_login) { debug 'A new session will be created'; $need_login = 1; my $web_login = download GET("$mbank/"); my $web_frames = do_login($web_login, $need_decrypt_config); $ua->cookie_jar->set_cookie(0, 'dummy', '', '/login-options/', FAKE_DOMAIN, undef, undef, undef, 604800, undef); $web_accounts_list = download GET("$mbank/accounts_list.aspx"); } my @accounts_list_forms = HTML::Form->parse($web_accounts_list->{response}); $#accounts_list_forms == 0 or api_error('account-list'); my ($accounts_list_form) = @accounts_list_forms; if ($action eq 'funds') { $web_accounts_list->{content} =~ m{]+?>$messages{menu_investments}} or api_error('funds-link-1'); my $web_investments_list = download onclick_to_req($accounts_list_form, $1); $web_investments_list->{content} =~ m{]+?>$messages{menu_funds}} or api_error('funds-link-2'); my $web_funds_list = download onclick_to_req($accounts_list_form, $1); my $fund_re = qr{]+?>([^<]+?)<.*?>([0-9 ,]+) ([A-Z]+)}; while ($web_funds_list->{content} =~ m{$fund_re}go) { my $name = localize_html_string $1; my $amount = parse_amount $2; defined $amount or api_error('funds-amount'); my $currency = $3; printf "%s\t%8.2f %s\n", $name, $amount, $currency; } exit; } if ($action eq 'deposits') { $web_accounts_list->{content} =~ m{]+?>$messages{menu_savings}} or api_error('savings-link'); my $web_deposits_types = download onclick_to_req($accounts_list_form, $1); $web_deposits_types->{content} =~ m{]+?>$messages{menu_term_deposits}} or api_error('term-deposits-link'); my $web_deposits_list = download onclick_to_req($accounts_list_form, $1); my $deposit_re = qr{]+?>([^<]+?)<.*?

]+?>(\d\d\d\d)-(\d\d)-(\d\d)(\d\d\d\d)-(\d\d)-(\d\d).*?

]+?>([^<]+?).*?

]+?>([0-9,]+)[^<]+?.*?

]+?>([^<]*?).*?

]+?>([0-9 ,]+) ([A-Z]+)}; while ($web_deposits_list->{content} =~ m{$deposit_re}go) { my $name = localize_html_string $1; my $establishment_date = "$2-$3-$4"; my $maturity_date = "$5-$6-$7"; my $length = localize_html_string $8; my $interest = parse_amount $9; defined $interest or api_error('term-deposits-interest'); my $status = $10; my $amount = parse_amount $11; defined $amount or api_error('term-deposits-amount'); my $currency = $12; printf "%s\t%s\t%s\t%s\t%8.2f%%\t%s\t%8.2f %s\n", $name, $establishment_date, $maturity_date, $length, $interest, $status, $amount, $currency; } exit; } if ($action eq 'notices') { $web_accounts_list->{content} =~ m{]+?>$messages{notices}} or api_error('notices-link'); my $web_notices_list = download onclick_to_req($accounts_list_form, $1); my $notice_re = qr{

]+?>(\d\d)-(\d\d)-(\d\d\d\d).*?]+?>([^<]+)}; while ($web_notices_list->{content} =~ m{$notice_re}go) { my $new = ''; $new = 'N' if index($1, 'selected') > -1; my $date = "$4-$3-$2"; my $subject = localize_html_string $5; printf "%s\t%s\t%s\n", $new, $date, $subject; } exit; } my $accounts_re = qr{.*

(.*?)
.*}s; $web_accounts_list->{content} =~ s/$accounts_re/$1/ or api_error('accounts-list'); $accounts_re = qr( ]*?> (?: (.+?) [ ] (\d\d[ ]\d\d\d\d[ ]\d\d\d\d[ ]\d\d\d\d[ ]\d\d\d\d[ ]\d\d\d\d[ ]\d\d\d\d) | Konto [ ] MOBILE )

(?: ]*? onclick="([^"]*)" [^>]*?> (-?[0-9 ,]+) [ ] ([A-Z]+) | [0-9]+ [ ] MIN )

(?: ]*> (-?[0-9 ,]+) [ ] ([A-Z]+) )?

)x; $web_accounts_list->{content} =~ m{$accounts_re} or api_error('accounts-list-item'); my $n_matches = 0; while ($web_accounts_list->{content} =~ m{(.*?)}go) { my $line = $1; $line =~ m{$accounts_re}go or api_error('accounts-list-item'); next unless defined $3; my $account_details_req = onclick_to_req($accounts_list_form, $1); my $name = localize_html_string $2; my $no = $3; my $operations_req = onclick_to_req($accounts_list_form, $4); my $balance = parse_amount $5; defined $balance or api_error('accounts-list-balance'); my $balance_c = $6; my $resources = parse_amount $7; defined $resources or api_error('accounts-list-resources'); my $resources_c = $8; next if defined $selected_accounts and widen_string($name) !~ m/$selected_accounts/; $n_matches++; if ($action eq 'list') { printf "%s\t%32s\t%8.2f %s\t%8.2f %s\n", $name, $no, $balance, $balance_c, $resources, $resources_c; } elsif ($action eq 'history') { my $web_operations = download $operations_req; my @forms = HTML::Form->parse($web_operations->{response}); $#forms == 0 or api_error('history-form'); my ($form) = @forms; if (defined $opt_range) { api_error('history-date') unless $web_operations->{content} =~ m{DateValidator[(]theform[.]daterange_from_day, '19010101', '(\d{4})(\d\d)(\d\d)', '', ''[)]}; my $now = "$1-$2-$3"; my ($y, $m, $d) = ground_date($opt_from, $now); $form->value('daterange_from_day', $d); $form->value('daterange_from_month', $m); $form->value('daterange_from_year', $y); ($y, $m, $d) = ground_date($opt_to, $now); $form->value('daterange_to_day', $d); $form->value('daterange_to_month', $m); $form->value('daterange_to_year', $y); $form->value('rangepanel_group', 'daterange_radio'); } foreach my $input ($form->inputs) { $input->disabled(1) if defined $input->name and $input->name =~ '^lastdays_(days|period)|ctl[0-9]+$'; } api_error('history-button') unless $web_operations->{content} =~ m{ }x or api_error('rename-button'); my $web_rename = download onclick_to_req($form, $1); @forms = HTML::Form->parse($web_rename->{response}); $#forms == 0 or api_error('rename-submit'); ($form) = @forms; $form->value('tbVarPartAccName', $new_account_name); $web_rename->{content} =~ m{ $messages{account_rename_confirm} }x or api_error('rename-confirm'); my ($submit, $onclick) = ($1, $2); $form->value($submit, undef); $web_rename = download onclick_to_req($form, $onclick); $web_rename->{content} =~ m{

$messages{account_rename_successful}

} or api_error 'RenameAfter'; } } if ($n_matches == 0) { user_error('No such account name'); } # vim:ts=2 sw=2 et fenc=utf-8