diff options
-rwxr-xr-x | lib/Travelynx.pm | 251 | ||||
-rw-r--r-- | lib/Travelynx/Command/database.pm | 26 | ||||
-rw-r--r-- | lib/Travelynx/Command/work.pm | 45 | ||||
-rwxr-xr-x | lib/Travelynx/Controller/Api.pm | 20 | ||||
-rw-r--r-- | lib/Travelynx/Controller/Traewelling.pm | 104 | ||||
-rwxr-xr-x | lib/Travelynx/Controller/Traveling.pm | 30 | ||||
-rw-r--r-- | lib/Travelynx/Helper/Traewelling.pm | 332 | ||||
-rw-r--r-- | lib/Travelynx/Model/Traewelling.pm | 204 | ||||
-rw-r--r-- | templates/account.html.ep | 22 | ||||
-rw-r--r-- | templates/traewelling.html.ep | 220 |
10 files changed, 1213 insertions, 41 deletions
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm index 4f58f57..ab1f7ef 100755 --- a/lib/Travelynx.pm +++ b/lib/Travelynx.pm @@ -20,7 +20,9 @@ use Travelynx::Helper::DBDB; use Travelynx::Helper::HAFAS; use Travelynx::Helper::IRIS; use Travelynx::Helper::Sendmail; +use Travelynx::Helper::Traewelling; use Travelynx::Model::Journeys; +use Travelynx::Model::Traewelling; use Travelynx::Model::Users; use XML::LibXML; @@ -293,6 +295,26 @@ sub startup { ); $self->helper( + traewelling => sub { + my ($self) = @_; + state $trwl = Travelynx::Model::Traewelling->new( pg => $self->pg ); + } + ); + + $self->helper( + traewelling_api => sub { + my ($self) = @_; + state $trwl_api = Travelynx::Helper::Traewelling->new( + log => $self->app->log, + model => $self->traewelling, + root_url => $self->url_for('/')->to_abs, + user_agent => $self->ua, + version => $self->app->config->{version}, + ); + } + ); + + $self->helper( journeys => sub { my ($self) = @_; state $journeys = Travelynx::Model::Journeys->new( @@ -389,9 +411,12 @@ sub startup { $self->helper( 'checkin' => sub { - my ( $self, $station, $train_id, $uid ) = @_; + my ( $self, %opt ) = @_; - $uid //= $self->current_user->{id}; + my $station = $opt{station}; + my $train_id = $opt{train_id}; + my $uid = $opt{uid} // $self->current_user->{id}; + my $db = $opt{db} // $self->pg->db; my $status = $self->iris->get_departures( station => $station, @@ -409,7 +434,7 @@ sub startup { } else { - my $user = $self->get_user_status($uid); + my $user = $self->get_user_status( $uid, $db ); if ( $user->{checked_in} or $user->{cancelled} ) { if ( $user->{train_id} eq $train_id @@ -420,12 +445,17 @@ sub startup { } # Otherwise, someone forgot to check out first - $self->checkout( $station, 1, $uid ); + $self->checkout( + station => $station, + force => 1, + uid => $uid, + db => $db + ); } eval { my $json = JSON->new; - $self->pg->db->insert( + $db->insert( 'in_transit', { user_id => $uid, @@ -459,8 +489,12 @@ sub startup { "Checkin($uid): INSERT failed: $@"); return ( undef, 'INSERT failed: ' . $@ ); } - $self->add_route_timestamps( $uid, $train, 1 ); - $self->run_hook( $uid, 'checkin' ); + if ( not $opt{in_transaction} ) { + + # mustn't be called during a transaction + $self->add_route_timestamps( $uid, $train, 1 ); + $self->run_hook( $uid, 'checkin' ); + } return ( $train, undef ); } } @@ -547,16 +581,19 @@ sub startup { $self->helper( 'checkout' => sub { - my ( $self, $station, $force, $uid ) = @_; + my ( $self, %opt ) = @_; - my $db = $self->pg->db; - my $status = $self->iris->get_departures( + my $station = $opt{station}; + my $force = $opt{force}; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->pg->db; + my $status = $self->iris->get_departures( station => $station, lookbehind => 120, lookahead => 120 ); $uid //= $self->current_user->{id}; - my $user = $self->get_user_status($uid); + my $user = $self->get_user_status( $uid, $db ); my $train_id = $user->{train_id}; if ( not $user->{checked_in} and not $user->{cancelled} ) { @@ -671,7 +708,11 @@ sub startup { } } if ( not $force ) { - $self->run_hook( $uid, 'update' ); + + # mustn't be called during a transaction + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'update' ); + } return ( 1, undef ); } } @@ -680,7 +721,10 @@ sub startup { eval { - my $tx = $db->begin; + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } if ( defined $train and not $train->arrival and not $force ) { my $train_no = $train->train_no; @@ -778,7 +822,9 @@ sub startup { ); } - $tx->commit; + if ( not $opt{in_transaction} ) { + $tx->commit; + } }; if ($@) { @@ -787,27 +833,33 @@ sub startup { } if ( $has_arrived or $force ) { - $self->run_hook( $uid, 'checkout' ); + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'checkout' ); + } return ( 0, undef ); } - $self->run_hook( $uid, 'update' ); - $self->add_route_timestamps( $uid, $train, 0 ); + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'update' ); + $self->add_route_timestamps( $uid, $train, 0 ); + } return ( 1, undef ); } ); $self->helper( 'update_in_transit_comment' => sub { - my ( $self, $comment, $uid ) = @_; + my ( $self, $comment, $uid, $db ) = @_; $uid //= $self->current_user->{id}; + $db //= $self->pg->db; - my $status = $self->pg->db->select( 'in_transit', ['user_data'], - { user_id => $uid } )->expand->hash; + my $status + = $db->select( 'in_transit', ['user_data'], { user_id => $uid } ) + ->expand->hash; if ( not $status ) { return; } $status->{user_data}{comment} = $comment; - $self->pg->db->update( + $db->update( 'in_transit', { user_data => JSON->new->encode( $status->{user_data} ) }, { user_id => $uid } @@ -1872,11 +1924,11 @@ sub startup { $self->helper( 'get_user_status' => sub { - my ( $self, $uid ) = @_; + my ( $self, $uid, $db ) = @_; $uid //= $self->current_user->{id}; + $db //= $self->pg->db; - my $db = $self->pg->db; my $now = DateTime->now( time_zone => 'Europe/Berlin' ); my $epoch = $now->epoch; @@ -2316,6 +2368,157 @@ sub startup { ); $self->helper( + 'traewelling_to_travelynx' => sub { + my ( $self, %opt ) = @_; + my $traewelling = $opt{traewelling}; + my $user_data = $opt{user_data}; + my $uid = $user_data->{user_id}; + + if ( not $traewelling->{checkin} + or $self->now->epoch - $traewelling->{checkin}->epoch > 900 ) + { + $self->log->debug("... not checked in"); + return; + } + if ( $traewelling->{status_id} + and $user_data->{data}{latest_pull_status_id} + and $traewelling->{status_id} + == $user_data->{data}{latest_pull_status_id} ) + { + $self->log->debug("... already handled"); + return; + } + $self->log->debug("... checked in"); + my $user_status = $self->get_user_status($uid); + if ( $user_status->{checked_in} ) { + $self->log->debug( + "... also checked in via travelynx. aborting."); + return; + } + + if ( $traewelling->{category} + !~ m{^ (?: nationalExpress | regional | suburban ) $ }x ) + { + $self->log->debug("... status is not a train"); + $self->traewelling->log( + uid => $uid, + message => +"$traewelling->{line} nach $traewelling->{arr_name} ist keine Zugfahrt", + status_id => $traewelling->{status_id}, + ); + $self->traewelling->set_latest_pull_status_id( + uid => $uid, + status_id => $traewelling->{status_id} + ); + return; + } + + my $dep = $self->iris->get_departures( + station => $traewelling->{dep_eva}, + lookbehind => 60, + lookahead => 40 + ); + if ( $dep->{errstr} ) { + $self->traewelling->log( + uid => $uid, + message => +"Fehler bei $traewelling->{line} nach $traewelling->{arr_name}: $dep->{errstr}", + status_id => $traewelling->{status_id}, + is_error => 1, + ); + return; + } + my ( $train_ref, $train_id ); + for my $train ( @{ $dep->{results} } ) { + if ( $train->line ne $traewelling->{line} ) { + next; + } + if ( not $train->sched_departure + or $train->sched_departure->epoch + != $traewelling->{dep_dt}->epoch ) + { + next; + } + if ( + not List::Util::first { $_ eq $traewelling->{arr_name} } + $train->route_post + ) + { + next; + } + $train_id = $train->train_id; + $train_ref = $train; + last; + } + if ($train_id) { + $self->log->debug("... found train: $train_id"); + + my $db = $self->pg->db; + my $tx = $db->begin; + + my ( undef, $err ) = $self->checkin( + station => $traewelling->{dep_eva}, + train_id => $train_id, + uid => $uid, + in_transaction => 1, + db => $db + ); + + if ( not $err ) { + ( undef, $err ) = $self->checkout( + station => $traewelling->{arr_eva}, + train_id => 0, + uid => $uid, + in_transaction => 1, + db => $db + ); + if ( not $err ) { + $self->log->debug("... success!"); + if ( $traewelling->{message} ) { + $self->update_in_transit_comment( + $traewelling->{message}, + $uid, $db ); + } + $self->traewelling->log( + uid => $uid, + db => $db, + message => +"Eingecheckt in $traewelling->{line} nach $traewelling->{arr_name}", + status_id => $traewelling->{status_id}, + ); + $self->traewelling->set_latest_pull_status_id( + uid => $uid, + status_id => $traewelling->{status_id}, + db => $db + ); + + $tx->commit; + } + } + if ($err) { + $self->log->debug("... error: $err"); + $self->traewelling->log( + uid => $uid, + message => +"Fehler bei $traewelling->{line} nach $traewelling->{arr_name}: $err", + status_id => $traewelling->{status_id}, + is_error => 1 + ); + } + } + else { + $self->traewelling->log( + uid => $uid, + message => +"$traewelling->{line} nach $traewelling->{arr_name} nicht gefunden", + status_id => $traewelling->{status_id}, + is_error => 1 + ); + } + } + ); + + $self->helper( 'journeys_to_map_data' => sub { my ( $self, %opt ) = @_; @@ -2647,6 +2850,7 @@ sub startup { $authed_r->get('/account')->to('account#account'); $authed_r->get('/account/privacy')->to('account#privacy'); $authed_r->get('/account/hooks')->to('account#webhook'); + $authed_r->get('/account/traewelling')->to('traewelling#settings'); $authed_r->get('/account/insight')->to('account#insight'); $authed_r->get('/ajax/status_card.html')->to('traveling#status_card'); $authed_r->get('/cancelled')->to('traveling#cancelled'); @@ -2668,6 +2872,7 @@ sub startup { $authed_r->get('/confirm_mail/:token')->to('account#confirm_mail'); $authed_r->post('/account/privacy')->to('account#privacy'); $authed_r->post('/account/hooks')->to('account#webhook'); + $authed_r->post('/account/traewelling')->to('traewelling#settings'); $authed_r->post('/account/insight')->to('account#insight'); $authed_r->post('/journey/add')->to('traveling#add_journey_form'); $authed_r->post('/journey/comment')->to('traveling#comment_form'); diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm index 59e41d9..e92dd4b 100644 --- a/lib/Travelynx/Command/database.pm +++ b/lib/Travelynx/Command/database.pm @@ -1012,6 +1012,32 @@ my @migrations = ( } ); }, + + # v21 -> v22 + sub { + my ($db) = @_; + $db->query( + qq{ + create table traewelling ( + user_id integer not null references users (id) primary key, + email varchar(256) not null, + push_sync boolean not null, + pull_sync boolean not null, + errored boolean, + token text, + data jsonb, + latest_run timestamptz + ); + comment on table traewelling is 'Token and Status for Traewelling'; + create view traewelling_str as select + user_id, email, push_sync, pull_sync, errored, token, data, + extract(epoch from latest_run) as latest_run_ts + from traewelling + ; + update schema_version set version = 22; + } + ); + }, ); sub setup_db { diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm index 9c870d8..593735f 100644 --- a/lib/Travelynx/Command/work.pm +++ b/lib/Travelynx/Command/work.pm @@ -108,7 +108,11 @@ sub run { # check out (adds a cancelled journey and resets journey state # to checkin - $self->app->checkout( $arr, 1, $uid ); + $self->app->checkout( + station => $arr, + force => 1, + uid => $uid + ); } } else { @@ -201,7 +205,11 @@ sub run { { # check out (adds a cancelled journey and resets journey state # to destination selection) - $self->app->checkout( $arr, 0, $uid ); + $self->app->checkout( + station => $arr, + force => 0, + uid => $uid + ); } } else { @@ -209,7 +217,11 @@ sub run { } } elsif ( $entry->{real_arr_ts} ) { - my ( undef, $error ) = $self->app->checkout( $arr, 1, $uid ); + my ( undef, $error ) = $self->app->checkout( + station => $arr, + force => 1, + uid => $uid + ); if ($error) { die("${error}\n"); } @@ -222,6 +234,31 @@ sub run { eval { } } + for my $account_data ( $self->app->traewelling->get_pull_accounts ) { + + # $account_data->{user_id} is the travelynx uid + # $account_data->{user_name} is the Träwelling username + $self->app->log->debug( + "Pulling Traewelling status for UID $account_data->{user_id}"); + $self->app->traewelling_api->get_status_p( + username => $account_data->{data}{user_name}, + token => $account_data->{token} + )->then( + sub { + my ($traewelling) = @_; + $self->app->traewelling_to_travelynx( + traewelling => $traewelling, + user_data => $account_data + ); + } + )->catch( + sub { + my ($err) = @_; + $self->app->log->debug("Error $err"); + } + )->wait; + } + # Computing yearly stats may take a while, but we've got all time in the # world here. This means users won't have to wait when loading their # own by-year journey log. @@ -232,6 +269,8 @@ sub run { year => $now->year ); } + + # TODO wait until all background jobs have terminated } 1; diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm index c7e58a9..8d2d2a7 100755 --- a/lib/Travelynx/Controller/Api.pm +++ b/lib/Travelynx/Controller/Api.pm @@ -258,14 +258,21 @@ sub travel_v1 { $train_id = $train->train_id; } - my ( $train, $error ) - = $self->checkin( $from_station, $train_id, $uid ); + my ( $train, $error ) = $self->checkin( + station => $from_station, + train_id => $train_id, + uid => $uid + ); if ( $payload->{comment} and not $error ) { $self->update_in_transit_comment( sanitize( q{}, $payload->{comment} ), $uid ); } if ( $to_station and not $error ) { - ( $train, $error ) = $self->checkout( $to_station, 0, $uid ); + ( $train, $error ) = $self->checkout( + station => $to_station, + force => 0, + uid => $uid + ); } if ($error) { $self->render( @@ -307,8 +314,11 @@ sub travel_v1 { sanitize( q{}, $payload->{comment} ), $uid ); } - my ( $train, $error ) - = $self->checkout( $to_station, $payload->{force} ? 1 : 0, $uid ); + my ( $train, $error ) = $self->checkout( + station => $to_station, + force => $payload->{force} ? 1 : 0, + uid => $uid + ); if ($error) { $self->render( json => { diff --git a/lib/Travelynx/Controller/Traewelling.pm b/lib/Travelynx/Controller/Traewelling.pm new file mode 100644 index 0000000..78c501f --- /dev/null +++ b/lib/Travelynx/Controller/Traewelling.pm @@ -0,0 +1,104 @@ +package Travelynx::Controller::Traewelling; +use Mojo::Base 'Mojolicious::Controller'; +use Mojo::Promise; + +sub settings { + my ($self) = @_; + + my $uid = $self->current_user->{id}; + + if ( $self->param('action') + and $self->validation->csrf_protect->has_error('csrf_token') ) + { + $self->render( + 'traewelling', + invalid => 'csrf', + ); + return; + } + + if ( $self->param('action') and $self->param('action') eq 'login' ) { + my $email = $self->param('email'); + my $password = $self->param('password'); + $self->render_later; + $self->traewelling_api->login_p( + uid => $uid, + email => $email, + password => $password + )->then( + sub { + my $traewelling = $self->traewelling->get($uid); + $self->param( sync_source => 'none' ); + $self->render( + 'traewelling', + traewelling => $traewelling, + new_traewelling => 1, + ); + } + )->catch( + sub { + my ($err) = @_; + $self->render( + 'traewelling', + traewelling => {}, + new_traewelling => 1, + login_error => $err, + ); + } + )->wait; + return; + } + elsif ( $self->param('action') and $self->param('action') eq 'logout' ) { + $self->render_later; + my $traewelling = $self->traewelling->get($uid); + $self->traewelling_api->logout_p( + uid => $uid, + token => $traewelling->{token} + )->then( + sub { + $self->flash( success => 'traewelling' ); + $self->redirect_to('account'); + } + )->catch( + sub { + my ($err) = @_; + $self->render( + 'traewelling', + traewelling => {}, + new_traewelling => 1, + logout_error => $err, + ); + } + )->wait; + return; + } + elsif ( $self->param('action') and $self->param('action') eq 'config' ) { + $self->traewelling->set_sync( + uid => $uid, + push_sync => $self->param('sync_source') eq 'travelynx' ? 1 : 0, + pull_sync => $self->param('sync_source') eq 'traewelling' ? 1 : 0 + ); + $self->flash( success => 'traewelling' ); + $self->redirect_to('account'); + return; + } + + my $traewelling = $self->traewelling->get($uid); + + if ( $traewelling->{push_sync} ) { + $self->param( sync_source => 'travelynx' ); + } + elsif ( $traewelling->{pull_sync} ) { + $self->param( sync_source => 'traewelling' ); + } + else { + $self->param( sync_source => 'none' ); + } + + $self->render( + 'traewelling', + traewelling => $traewelling, + ); +} + +1; diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm index f5e3255..4df1558 100755 --- a/lib/Travelynx/Controller/Traveling.pm +++ b/lib/Travelynx/Controller/Traveling.pm @@ -424,8 +424,10 @@ sub log_action { if ( $params->{action} eq 'checkin' ) { - my ( $train, $error ) - = $self->checkin( $params->{station}, $params->{train} ); + my ( $train, $error ) = $self->checkin( + station => $params->{station}, + train_id => $params->{train} + ); my $destination = $params->{dest}; if ($error) { @@ -447,8 +449,10 @@ sub log_action { else { # Silently ignore errors -- if they are permanent, the user will see # them when selecting the destination manually. - my ( $still_checked_in, undef ) - = $self->checkout( $destination, 0 ); + my ( $still_checked_in, undef ) = $self->checkout( + station => $destination, + force => 0 + ); my $station_link = '/s/' . $destination; $self->render( json => { @@ -459,8 +463,10 @@ sub log_action { } } elsif ( $params->{action} eq 'checkout' ) { - my ( $still_checked_in, $error ) - = $self->checkout( $params->{station}, $params->{force} ); + my ( $still_checked_in, $error ) = $self->checkout( + station => $params->{station}, + force => $params->{force} + ); my $station_link = '/s/' . $params->{station}; if ($error) { @@ -505,8 +511,10 @@ sub log_action { } } elsif ( $params->{action} eq 'cancelled_from' ) { - my ( undef, $error ) - = $self->checkin( $params->{station}, $params->{train} ); + my ( undef, $error ) = $self->checkin( + station => $params->{station}, + train_id => $params->{train} + ); if ($error) { $self->render( @@ -526,8 +534,10 @@ sub log_action { } } elsif ( $params->{action} eq 'cancelled_to' ) { - my ( undef, $error ) - = $self->checkout( $params->{station}, 1 ); + my ( undef, $error ) = $self->checkout( + station => $params->{station}, + force => 1 + ); if ($error) { $self->render( diff --git a/lib/Travelynx/Helper/Traewelling.pm b/lib/Travelynx/Helper/Traewelling.pm new file mode 100644 index 0000000..3c7bec2 --- /dev/null +++ b/lib/Travelynx/Helper/Traewelling.pm @@ -0,0 +1,332 @@ +package Travelynx::Helper::Traewelling; + +use strict; +use warnings; +use 5.020; + +use Mojo::Promise; + +sub new { + my ( $class, %opt ) = @_; + + my $version = $opt{version}; + + $opt{header} + = { 'User-Agent' => +"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx" + }; + + return bless( \%opt, $class ); +} + +sub get_status_p { + my ( $self, %opt ) = @_; + + my $username = $opt{username}; + my $token = $opt{token}; + my $promise = Mojo::Promise->new; + + my $header = { + 'User-Agent' => $self->{header}{'User-Agent'}, + 'Authorization' => "Bearer $token", + }; + + $self->{user_agent}->request_timeout(20) + ->get_p( "https://traewelling.de/api/v0/user/${username}" => $header ) + ->then( + sub { + my ($tx) = @_; + if ( my $err = $tx->error ) { + my $err_msg = "HTTP $err->{code} $err->{message}"; + $promise->reject($err_msg); + return; + } + else { + if ( my $status = $tx->result->json->{statuses}{data}[0] ) { + my $strp = DateTime::Format::Strptime->new( + pattern => '%Y-%m-%dT%H:%M:%S.000000Z', + time_zone => 'UTC', + ); + my $status_id = $status->{id}; + my $message = $status->{body}; + my $checkin_at + = $strp->parse_datetime( $status->{created_at} ); + + my $dep_dt = $strp->parse_datetime( + $status->{train_checkin}{departure} ); + my $arr_dt = $strp->parse_datetime( + $status->{train_checkin}{arrival} ); + + my $dep_eva + = $status->{train_checkin}{origin}{ibnr}; + my $arr_eva + = $status->{train_checkin}{destination}{ibnr}; + + my $dep_name + = $status->{train_checkin}{origin}{name}; + my $arr_name + = $status->{train_checkin}{destination}{name}; + + my $category + = $status->{train_checkin}{hafas_trip}{category}; + my $trip_id + = $status->{train_checkin}{hafas_trip}{trip_id}; + my $linename + = $status->{train_checkin}{hafas_trip}{linename}; + my ( $train_type, $train_line ) = split( qr{ }, $linename ); + $promise->resolve( + { + status_id => $status_id, + message => $message, + checkin => $checkin_at, + dep_dt => $dep_dt, + dep_eva => $dep_eva, + dep_name => $dep_name, + arr_dt => $arr_dt, + arr_eva => $arr_eva, + arr_name => $arr_name, + trip_id => $trip_id, + train_type => $train_type, + line => $linename, + line_no => $train_line, + category => $category, + } + ); + return; + } + else { + $promise->reject("unknown error"); + return; + } + } + } + )->catch( + sub { + my ($err) = @_; + $promise->reject($err); + return; + } + )->wait; + + return $promise; +} + +sub get_user_p { + my ( $self, $uid, $token ) = @_; + my $ua = $self->{user_agent}->request_timeout(20); + + my $header = { + 'User-Agent' => $self->{header}{'User-Agent'}, + 'Authorization' => "Bearer $token", + }; + my $promise = Mojo::Promise->new; + + $ua->get_p( "https://traewelling.de/api/v0/getuser" => $header )->then( + sub { + my ($tx) = @_; + if ( my $err = $tx->error ) { + my $err_msg + = "HTTP $err->{code} $err->{message} bei Abfrage der Nutzerdaten"; + $promise->reject($err_msg); + return; + } + else { + my $user_data = $tx->result->json; + $self->{model}->set_user( + uid => $uid, + trwl_id => $user_data->{id}, + screen_name => $user_data->{name}, + user_name => $user_data->{username}, + ); + $promise->resolve; + return; + } + } + )->catch( + sub { + my ($err) = @_; + $promise->reject("$err bei Abfrage der Nutzerdaten"); + return; + } + )->wait; + + return $promise; +} + +sub login_p { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $email = $opt{email}; + my $password = $opt{password}; + + my $ua = $self->{user_agent}->request_timeout(20); + + my $request = { + email => $email, + password => $password, + }; + + my $promise = Mojo::Promise->new; + my $token; + + $ua->post_p( + "https://traewelling.de/api/v0/auth/login" => $self->{header} => + json => $request )->then( + sub { + my ($tx) = @_; + if ( my $err = $tx->error ) { + my $err_msg = "HTTP $err->{code} $err->{message} bei Login"; + $promise->reject($err_msg); + return; + } + else { + $token = $tx->result->json->{token}; + $self->{model}->link( + uid => $uid, + email => $email, + token => $token + ); + return $self->get_user_p( $uid, $token ); + } + } + )->then( + sub { + $promise->resolve; + return; + } + )->catch( + sub { + my ($err) = @_; + if ($token) { + + # We have a token, but couldn't complete the login. For now, we + # solve this by logging out and invalidating the token. + $self->logout_p( + uid => $uid, + token => $token + )->finally( + sub { + $promise->reject($err); + return; + } + ); + } + else { + $promise->reject($err); + } + return; + } + )->wait; + + return $promise; +} + +sub logout_p { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $token = $opt{token}; + + my $ua = $self->{user_agent}->request_timeout(20); + + my $header = { + 'User-Agent' => $self->{header}{'User-Agent'}, + 'Authorization' => "Bearer $token", + }; + my $request = {}; + + $self->{model}->unlink( uid => $uid ); + + my $promise = Mojo::Promise->new; + + $ua->post_p( + "https://traewelling.de/api/v0/auth/logout" => $header => json => + $request )->then( + sub { + my ($tx) = @_; + if ( my $err = $tx->error ) { + my $err_msg = "HTTP $err->{code} $err->{message}"; + $promise->reject($err_msg); + return; + } + else { + $promise->resolve; + return; + } + } + )->catch( + sub { + my ($err) = @_; + $promise->reject($err); + return; + } + )->wait; + + return $promise; +} + +sub checkin { + my ( $self, $uid ) = @_; + if ( my $token = $self->get_traewelling_push_token($uid) ) { + my $user = $self->get_user_status; + +# TODO delete previous traewelling status if the train's destination has been changed +# TODO delete traewelling status when undoing a travelynx checkin + if ( $user->{checked_in} and $user->{extra_data}{trip_id} ) { + my $traewelling = $self->{model}->get($uid); + if ( $traewelling->{data}{trip_id} eq $user->{extra_data}{trip_id} ) + { + return; + } + my $header = { + 'User-Agent' => 'travelynx/' . $self->{version}, + 'Authorization' => "Bearer $token", + }; + + my $request = { + tripID => $user->{extra_data}{trip_id}, + start => q{} . $user->{dep_eva}, + destination => q{} . $user->{arr_eva}, + }; + my $trip_req = sprintf( + "tripID=%s&lineName=%s%%20%s&start=%s", + $user->{extra_data}{trip_id}, $user->{train_type}, + $user->{train_line} // $user->{train_no}, $user->{dep_eva} + ); + $self->{user_agent}->request_timeout(20) + ->get_p( + "https://traewelling.de/api/v0/trains/trip?$trip_req" => + $header )->then( + sub { + return $self->{user_agent}->request_timeout(20) + ->post_p( + "https://traewelling.de/api/v0/trains/checkin" => + $header => json => $request ); + } + )->then( + sub { + my ($tx) = @_; + if ( my $err = $tx->error ) { + my $err_msg = "HTTP $err->{code} $err->{message}"; + $self->mark_trwl_checkin_error( $uid, $user, $err_msg ); + } + else { + # TODO check for traewelling error ("error" key in response) + # TODO store ID of resulting status (request /user/{name} and store status ID) + $self->mark_trwl_checkin_success( $uid, $user ); + + # mark success: checked into (trip_id, start, destination) + } + } + )->catch( + sub { + my ($err) = @_; + $self->mark_trwl_checkin_error( $uid, $user, $err ); + } + )->wait; + } + } +} + +1; diff --git a/lib/Travelynx/Model/Traewelling.pm b/lib/Travelynx/Model/Traewelling.pm new file mode 100644 index 0000000..7f08b0d --- /dev/null +++ b/lib/Travelynx/Model/Traewelling.pm @@ -0,0 +1,204 @@ +package Travelynx::Model::Traewelling; + +use strict; +use warnings; +use 5.020; + +use DateTime; + +sub epoch_to_dt { + my ($epoch) = @_; + + # Bugs (and user errors) may lead to undefined timestamps. Set them to + # 1970-01-01 to avoid crashing and show obviously wrong data instead. + $epoch //= 0; + + return DateTime->from_epoch( + epoch => $epoch, + time_zone => 'Europe/Berlin', + locale => 'de-DE', + ); + +} + +sub new { + my ( $class, %opt ) = @_; + + return bless( \%opt, $class ); +} + +sub now { + return DateTime->now( time_zone => 'Europe/Berlin' ); +} + +sub link { + my ( $self, %opt ) = @_; + + my $log = [ [ $self->now->epoch, "Erfolgreich angemeldet" ] ]; + + my $data = { + user_id => $opt{uid}, + email => $opt{email}, + push_sync => 0, + pull_sync => 0, + token => $opt{token}, + data => JSON->new->encode( { log => $log } ), + }; + + $self->{pg}->db->insert( + 'traewelling', + $data, + { + on_conflict => \ +'(user_id) do update set email = EXCLUDED.email, token = EXCLUDED.token, push_sync = false, pull_sync = false, data = null, errored = false, latest_run = null' + } + ); + + return $data; +} + +sub set_user { + my ( $self, %opt ) = @_; + + my $res_h + = $self->{pg} + ->db->select( 'traewelling', 'data', { user_id => $opt{uid} } ) + ->expand->hash; + + $res_h->{data}{user_id} = $opt{trwl_id}; + $res_h->{data}{screen_name} = $opt{screen_name}; + $res_h->{data}{user_name} = $opt{user_name}; + + $self->{pg}->db->update( + 'traewelling', + { data => JSON->new->encode( $res_h->{data} ) }, + { user_id => $opt{uid} } + ); +} + +sub unlink { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + + $self->{pg}->db->delete( 'traewelling', { user_id => $uid } ); +} + +sub get { + my ( $self, $uid ) = @_; + $uid //= $self->current_user->{id}; + + my $res_h + = $self->{pg}->db->select( 'traewelling_str', '*', { user_id => $uid } ) + ->expand->hash; + + $res_h->{latest_run} = epoch_to_dt( $res_h->{latest_run_ts} ); + for my $log_entry ( @{ $res_h->{data}{log} // [] } ) { + $log_entry->[0] = epoch_to_dt( $log_entry->[0] ); + } + + return $res_h; +} + +sub log { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $message = $opt{message}; + my $is_error = $opt{is_error}; + my $db = $opt{db} // $self->{pg}->db; + my $res_h + = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash; + splice( @{ $res_h->{data}{log} // [] }, 9 ); + unshift( + @{ $res_h->{data}{log} }, + [ $self->now->epoch, $message, $opt{status_id} ] + ); + + if ($is_error) { + $res_h->{data}{error} = $message; + } + $db->update( + 'traewelling', + { + errored => $is_error ? 1 : 0, + latest_run => $self->now, + data => JSON->new->encode( $res_h->{data} ) + }, + { user_id => $uid } + ); +} + +sub set_latest_pull_status_id { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $status_id = $opt{status_id}; + my $db = $opt{db} // $self->{pg}->db; + + my $res_h + = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash; + + $res_h->{data}{latest_pull_status_id} = $status_id; + + $db->update( + 'traewelling', + { data => JSON->new->encode( $res_h->{data} ) }, + { user_id => $uid } + ); +} + +sub set_latest_push_status_id { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $status_id = $opt{status_id}; + my $db = $opt{db} // $self->{pg}->db; + + my $res_h + = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash; + + $res_h->{data}{latest_push_status_id} = $status_id; + + $db->update( + 'traewelling', + { data => JSON->new->encode( $res_h->{data} ) }, + { user_id => $uid } + ); +} + +sub set_sync { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $push_sync = $opt{push_sync}; + my $pull_sync = $opt{pull_sync}; + + $self->{pg}->db->update( + 'traewelling', + { + push_sync => $push_sync, + pull_sync => $pull_sync + }, + { user_id => $uid } + ); +} + +sub get_push_accounts { + my ($self) = @_; + my $res = $self->{pg}->db->select( + 'traewelling', + [ 'user_id', 'token', 'data' ], + { push_sync => 1 } + ); + return $res->expand->hashes->each; +} + +sub get_pull_accounts { + my ($self) = @_; + my $res = $self->{pg}->db->select( + 'traewelling', + [ 'user_id', 'token', 'data' ], + { pull_sync => 1 } + ); + return $res->expand->hashes->each; +} + +1; diff --git a/templates/account.html.ep b/templates/account.html.ep index 9b049a3..8943e3f 100644 --- a/templates/account.html.ep +++ b/templates/account.html.ep @@ -16,6 +16,9 @@ % elsif ($success eq 'privacy') { <span class="card-title">Einstellungen zu öffentlichen Account-Daten geändert</span> % } + % elsif ($success eq 'traewelling') { + <span class="card-title">Traewelling-Verknüpfung aktualisiert</span> + % } % elsif ($success eq 'use_history') { <span class="card-title">Einstellungen zu vorgeschlagenen Verbindungen geändert</span> % } @@ -31,6 +34,7 @@ <h1>Account</h1> % my $acc = current_user(); % my $hook = get_webhook(); +% my $traewelling = traewelling->get($acc->{id}); % my $use_history = users->use_history(uid => $acc->{id}); <div class="row"> <div class="col s12"> @@ -111,6 +115,24 @@ </td> </tr> <tr> + <th scope="row">Traewelling</th> + <td> + <a href="/account/traewelling"><i class="material-icons">edit</i></a> + % if (not ($traewelling->{token})) { + <span style="color: #999999;">Nicht verknüpft</span> + % } + % elsif ($traewelling->{errored}) { + Fehlerhaft <i class="material-icons">error</i> + % } + % elsif (not ($traewelling->{push_sync} or $traewelling->{pull_sync})) { + <span style="color: #999999;">Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %>, Synchronisierung inaktiv</span> + % } + % else { + Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %> + % } + </td> + </tr> + <tr> <th scope="row">Registriert am</th> <td><%= $acc->{registered_at}->strftime('%d.%m.%Y %H:%M') %></td> </tr> diff --git a/templates/traewelling.html.ep b/templates/traewelling.html.ep new file mode 100644 index 0000000..3a5eab3 --- /dev/null +++ b/templates/traewelling.html.ep @@ -0,0 +1,220 @@ +% if (my $invalid = stash('invalid')) { + %= include '_invalid_input', invalid => $invalid +% } + +<h1>Träwelling</h1> + +<div class="row"> + <div class="col s12"> + <div class="card purple"> + <div class="card-content white-text"> + <span class="card-title">Beta-Feature</span> + <p>Die Verbindung von Checkinservices bietet viele Möglichkeiten für interessante Fehlerbilder. + Falls etwas nicht klappt, bitte mit möglichst detaillierten Angaben zum Hergang einen Bug melden.</p> + <p> + Bekannte Probleme: Hooks werden bei einem Checkin via Träwelling nicht ausgelöst. + </p> + </div> + <div class="card-action"> + <a href="https://github.com/derf/travelynx/issues" class="waves-effect waves-light btn-flat white-text"> + <i class="material-icons left">bug_report</i>Bug melden + </a> + </div> + </div> + </div> +</div> + +% if (stash('new_traewelling')) { + <div class="row"> + <div class="col s12"> + % if ($traewelling->{token}) { + <div class="card success-color"> + <div class="card-content white-text"> + <span class="card-title">Träwelling verknüpft</span> + % my $user = $traewelling->{data}{user_name} // $traewelling->{email}; + <p>Dein travelynx-Account hat nun ein Jahr lang Zugriff auf + den Träwelling-Account <b>@<%= $user %></b>.</p> + </div> + </div> + % } + % elsif (my $login_err = stash('login_error')) { + <div class="card caution-color"> + <div class="card-content white-text"> + <span class="card-title">Login-Fehler</span> + <p>Der Login bei Träwelling ist fehlgeschlagen: <%= $login_err %></p> + </div> + </div> + % } + % elsif (my $logout_err = stash('logout_error')) { + <div class="card caution-color"> + <div class="card-content white-text"> + <span class="card-title">Logout-Fehler</span> + <p>Der Logout bei Träwelling ist fehlgeschlagen: <%= $logout_err %>. + Dein Login-Token bei travelynx wurde dennoch gelöscht, so + dass nun kein Zugriff von travelynx auf Träwelling mehr + möglich ist. In den <a + href="https://traewelling.de/settings">Träwelling-Einstellungen</a> + kannst du ihn vollständig löschen.</p> + </div> + </div> + % } + </div> + </div> +% } + +% if (not $traewelling->{token}) { + <div class="row"> + <div class="col s12"> + <p> + Hier hast du die Möglichkeit, deinen travelynx-Account mit einem + Account bei <a href="https://traewelling.de">Träwelling</a> zu + verknüpfen. Dies erlaubt die automatische Übernahme von Checkins + zwischen den beiden Diensten. Träwelling-Checkins in + Nahverkehrsmittel und Züge außerhalb des deutschen Schienennetzes + werden nicht unterstützt und ignoriert. + </p> + <p> + Mit E-Mail und Passwort wird ein Login über die Träwelling-API + durchgeführt. Die E-Mail und das dabei generierte Token werden + von travelynx gespeichert. Das Passwort wird ausschließlich für + den Login verwendet und nicht gespeichert. Der Login kann jederzeit + sowohl auf dieser Seite als auch über die <a + href="https://traewelling.de/settings">Träwelling-Einstellungen</a> + widerrufen werden. Nach einem Jahr läuft er automatisch ab. + </p> + </div> + </div> + <div class="row"> + %= form_for '/account/traewelling' => (method => 'POST') => begin + %= csrf_field + <div class="input-field col s12"> + <i class="material-icons prefix">account_circle</i> + %= text_field 'email', id => 'email', class => 'validate', required => undef, maxlength => 250 + <label for="email">E-Mail</label> + </div> + <div class="input-field col s12"> + <i class="material-icons prefix">lock</i> + %= password_field 'password', id => 'password', class => 'validate', required => undef + <label for="password">Passwort</label> + </div> + <div class="col s12 center-align"> + <button class="btn waves-effect waves-light" type="submit" name="action" value="login"> + Verknüpfen + <i class="material-icons right">send</i> + </button> + </div> + %= end + </div> +% } +% else { + <div class="row"> + <div class="col s12"> + <p> + Dieser travelynx-Account ist mit dem Träwelling-Account + % if (my $user = $traewelling->{data}{user_name}) { + <a href="https://traewelling.de/profile/<%= $user %>"><%= $user %></a> + % } + % else { + %= $traewelling->{email} + % } + verknüpft. + </p> + </div> + </div> + %= form_for '/account/traewelling' => (method => 'POST') => begin + <div class="row"> + %= csrf_field + <div class="input-field col s12"> + <div> + <label> + %= radio_button sync_source => 'none' + <span>Keine Synchronisierung</span> + </label> + </div> + </div> + <div class="input-field col s12"> + <div> + <label> + %= radio_button sync_source => 'travelynx', disabled => undef + <span>Checkin-Synchronisierung travelynx → Träwelling</span> + </label> + </div> + <p>Die Synchronisierung erfolgt spätestens drei Minuten nach der + Zielwahl. Träwelling-Checkins können von travelynx noch nicht + rückgängig gemacht werden.</p> + </div> + <div class="input-field col s12"> + <div> + <label> + %= radio_button sync_source => 'traewelling' + <span>Checkin-Synchronisierung Träwelling → travelynx</span> + </label> + </div> + <p>Alle drei Minuten wird dein Status auf Träwelling abgefragt. + Falls du gerade in einen Zug eingecheckt bist, wird dieser von + travelynx übernommen. Träwelling-Checkins in Nahverkehrsmittel + und Züge außerhalb des deutschen Schienennetzes werden nicht + unterstützt.</p> + </div> + </div> + <div class="row hide-on-small-only"> + <div class="col s12 m6 l6 center-align"> + <button class="btn waves-effect waves-light red" type="submit" name="action" value="logout"> + Abmelden + <i class="material-icons right" aria-hidden="true">sync_disabled</i> + </button> + </div> + <div class="col s12 m6 l6 center-align"> + <button class="btn waves-effect waves-light" type="submit" name="action" value="config"> + Speichern + <i class="material-icons right" aria-hidden="true">send</i> + </button> + </div> + </div> + <div class="row hide-on-med-and-up"> + <div class="col s12 m6 l6 center-align"> + <button class="btn waves-effect waves-light" type="submit" name="action" value="config"> + Speichern + <i class="material-icons right" aria-hidden="true">send</i> + </button> + </div> + <div class="col s12 m6 l6 center-align" style="margin-top: 1em;"> + <button class="btn waves-effect waves-light red" type="submit" name="action" value="logout"> + Abmelden + <i class="material-icons right" aria-hidden="true">sync_disabled</i> + </button> + </div> + </div> + %= end + <h2>Status</h2> + <div class="row"> + <div class="col s12""> + % if ($traewelling->{latest_run}->epoch) { + Letzter Kontakt mit Träwelling <%= $traewelling->{latest_run}->strftime('am %d.%m.%Y um %H:%M:%S') %><br/> + % if ($traewelling->{errored}) { + <i class="material-icons left">error</i> + Fehler: <%= $traewelling->{data}{error} %> + % } + % } + % else { + Bisher wurde noch keine Synchronisierung durchgeführt. + % } + </div> + </div> + <h2>Log</h2> + <div class="row"> + <div class="col s12""> + <ul> + % for my $log_entry (@{$traewelling->{data}{log} // []}) { + <li> + <%= $log_entry->[0]->strftime('%d.%m.%Y %H:%M:%S') %> – + % if ($log_entry->[2]) { + Träwelling <a href="https://traewelling.de/status/<%= $log_entry->[2] %>">#<%= $log_entry->[2] %></a> – + % } + %= $log_entry->[1] + </li> + % } + </ul> + </div> + </div> +% } |