diff options
Diffstat (limited to 'lib')
30 files changed, 13549 insertions, 3139 deletions
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm index 1c5b449..4749d65 100755 --- a/lib/Travelynx.pm +++ b/lib/Travelynx.pm @@ -1,4 +1,9 @@ package Travelynx; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + use Mojo::Base 'Mojolicious'; use Mojo::Pg; @@ -8,21 +13,29 @@ use Cache::File; use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64); use DateTime; use DateTime::Format::Strptime; -use Encode qw(decode encode); -use Geo::Distance; +use Encode qw(decode encode); +use File::Slurp qw(read_file); use JSON; -use List::Util qw(first); -use List::MoreUtils qw(after_incl before_incl); +use List::Util; +use List::UtilsBy qw(uniq_by); +use List::MoreUtils qw(first_index); use Travel::Status::DE::DBWagenreihung; -use Travel::Status::DE::IRIS; -use Travel::Status::DE::IRIS::Stations; +use Travelynx::Helper::DBDB; +use Travelynx::Helper::HAFAS; +use Travelynx::Helper::IRIS; use Travelynx::Helper::Sendmail; -use XML::LibXML; +use Travelynx::Helper::Traewelling; +use Travelynx::Model::InTransit; +use Travelynx::Model::Journeys; +use Travelynx::Model::JourneyStatsCache; +use Travelynx::Model::Stations; +use Travelynx::Model::Traewelling; +use Travelynx::Model::Users; sub check_password { my ( $password, $hash ) = @_; - if ( bcrypt( $password, $hash ) eq $hash ) { + if ( bcrypt( substr( $password, 0, 10000 ), $hash ) eq $hash ) { return 1; } return 0; @@ -37,22 +50,11 @@ sub epoch_to_dt { return DateTime->from_epoch( epoch => $epoch, - time_zone => 'Europe/Berlin' + time_zone => 'Europe/Berlin', + locale => 'de-DE', ); } -sub get_station { - my ($station_name) = @_; - - my @candidates - = Travel::Status::DE::IRIS::Stations::get_station($station_name); - - if ( @candidates == 1 ) { - return $candidates[0]; - } - return undef; -} - sub startup { my ($self) = @_; @@ -60,6 +62,7 @@ sub startup { $self->defaults( layout => 'default' ); + $self->types->type( csv => 'text/csv; charset=utf-8' ); $self->types->type( json => 'application/json; charset=utf-8' ); $self->plugin('Config'); @@ -68,6 +71,9 @@ sub startup { $self->secrets( $self->config->{secrets} ); } + chomp $self->config->{version}; + $self->defaults( version => $self->config->{version} // 'UNKNOWN' ); + $self->plugin( authentication => { autoload_user => 1, @@ -78,7 +84,8 @@ sub startup { }, validate_user => sub { my ( $self, $username, $password, $extradata ) = @_; - my $user_info = $self->get_user_password($username); + my $user_info + = $self->users->get_login_data( name => $username ); if ( not $user_info ) { return undef; } @@ -93,6 +100,23 @@ sub startup { }, } ); + + if ( my $oa = $self->config->{traewelling}{oauth} ) { + $self->plugin( + OAuth2 => { + providers => { + traewelling => { + key => $oa->{id}, + secret => $oa->{secret}, + authorize_url => +'https://traewelling.de/oauth/authorize?response_type=code', + token_url => 'https://traewelling.de/oauth/token', + } + } + } + ); + } + $self->sessions->default_expiration( 60 * 60 * 24 * 180 ); # Starting with v8.11, Mojolicious sends SameSite=Lax Cookies by default. @@ -100,7 +124,7 @@ sub startup { # security and usability for websites that want to maintain user's logged-in # session after the user arrives from an external link". In practice, # Safari (both iOS and macOS) does not send a SameSite=lax cookie when - # following a link from an external site. So, marudor.de providing a + # following a link from an external site. So, bahn.expert providing a # checkin link to travelynx.de/s/whatever does not work because the user # is not logged in due to Safari not sending the cookie. # @@ -116,10 +140,10 @@ sub startup { before_dispatch => sub { my ($self) = @_; - # The "theme" cookie is set client-side if the theme we delivered was - # changed by dark mode detection or by using the theme switcher). It's - # not part of Mojolicious' session data (and can't be, due to - # signing and HTTPOnly), so we need to add it here. + # The "theme" cookie is set client-side if the theme we delivered was + # changed by dark mode detection or by using the theme switcher. It's + # not part of Mojolicious' session data (and can't be, due to + # signing and HTTPOnly), so we need to add it here. for my $cookie ( @{ $self->req->cookies } ) { if ( $cookie->name eq 'theme' ) { $self->session( theme => $cookie->value ); @@ -154,40 +178,132 @@ sub startup { ); $self->attr( - token_type => sub { - return { - status => 1, - history => 2, - action => 3, - }; + coordinates_by_station => sub { + my $legacy_names = $self->app->renamed_station; + my $location = $self->stations->get_latlon_by_name; + while ( my ( $old_name, $new_name ) = each %{$legacy_names} ) { + $location->{$old_name} = $location->{$new_name}; + } + return $location; } ); + + # https://de.wikipedia.org/wiki/Liste_nach_Gemeinden_und_Regionen_benannter_IC/ICE-Fahrzeuge#Namensgebung_ICE-Triebz%C3%BCge_nach_Gemeinden + # via https://github.com/marudor/bahn.expert/blob/main/src/server/coachSequence/TrainNames.ts $self->attr( - token_types => sub { - return [qw(status history action)]; + ice_name => sub { + my $id_to_name = JSON->new->utf8->decode( + scalar read_file('share/ice_names.json') ); + return $id_to_name; } ); $self->attr( - coordinates_by_station => sub { - my %location; - for - my $station ( Travel::Status::DE::IRIS::Stations::get_stations() ) + renamed_station => sub { + my $legacy_to_new = JSON->new->utf8->decode( + scalar read_file('share/old_station_names.json') ); + return $legacy_to_new; + } + ); + + if ( not $self->app->config->{base_url} ) { + $self->app->log->error( +"travelynx.conf: 'base_url' is missing. Links in maintenance/work/worker-generated E-Mails will be incorrect. This variable was introduced in travelynx 1.22; see examples/travelynx.conf for documentation." + ); + } + + $self->helper( + base_url_for => sub { + my ( $self, $path ) = @_; + if ( ( my $url = $self->url_for($path) )->base ne q{} + or not $self->app->config->{base_url} ) { - if ( $station->[3] ) { - $location{ $station->[1] } - = [ $station->[4], $station->[3] ]; - } + return $url; } - return \%location; + return $self->url_for($path) + ->base( $self->app->config->{base_url} ); } ); $self->helper( - sendmail => sub { - state $sendmail = Travelynx::Helper::Sendmail->new( - config => ( $self->config->{mail} // {} ), - log => $self->log + hafas => sub { + my ($self) = @_; + state $hafas = Travelynx::Helper::HAFAS->new( + log => $self->app->log, + main_cache => $self->app->cache_iris_main, + realtime_cache => $self->app->cache_iris_rt, + root_url => $self->base_url_for('/')->to_abs, + user_agent => $self->ua, + version => $self->app->config->{version}, + ); + } + ); + + $self->helper( + iris => sub { + my ($self) = @_; + state $iris = Travelynx::Helper::IRIS->new( + log => $self->app->log, + main_cache => $self->app->cache_iris_main, + realtime_cache => $self->app->cache_iris_rt, + root_url => $self->base_url_for('/')->to_abs, + version => $self->app->config->{version}, + ); + } + ); + + $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->base_url_for('/')->to_abs, + user_agent => $self->ua, + version => $self->app->config->{version}, + ); + } + ); + + $self->helper( + in_transit => sub { + my ($self) = @_; + state $in_transit = Travelynx::Model::InTransit->new( + log => $self->app->log, + pg => $self->pg, + ); + } + ); + + $self->helper( + journey_stats_cache => sub { + my ($self) = @_; + state $journey_stats_cache + = Travelynx::Model::JourneyStatsCache->new( + log => $self->app->log, + pg => $self->pg, + ); + } + ); + + $self->helper( + journeys => sub { + my ($self) = @_; + state $journeys = Travelynx::Model::Journeys->new( + log => $self->app->log, + pg => $self->pg, + in_transit => $self->in_transit, + stats_cache => $self->journey_stats_cache, + renamed_station => $self->app->renamed_station, + latlon_by_station => $self->app->coordinates_by_station, + stations => $self->stations, ); } ); @@ -206,6 +322,52 @@ sub startup { state $pg = Mojo::Pg->new("postgresql://${user}\@${host}:${port}/${dbname}") ->password($pw); + + $pg->on( + connection => sub { + my ( $pg, $dbh ) = @_; + $dbh->do("set time zone 'Europe/Berlin'"); + } + ); + + return $pg; + } + ); + + $self->helper( + sendmail => sub { + state $sendmail = Travelynx::Helper::Sendmail->new( + config => ( $self->config->{mail} // {} ), + log => $self->log + ); + } + ); + + $self->helper( + stations => sub { + my ($self) = @_; + state $stations + = Travelynx::Model::Stations->new( pg => $self->pg ); + } + ); + + $self->helper( + users => sub { + my ($self) = @_; + state $users = Travelynx::Model::Users->new( pg => $self->pg ); + } + ); + + $self->helper( + dbdb => sub { + my ($self) = @_; + state $dbdb = Travelynx::Helper::DBDB->new( + log => $self->app->log, + cache => $self->app->cache_iris_main, + root_url => $self->base_url_for('/')->to_abs, + user_agent => $self->ua, + version => $self->app->config->{version}, + ); } ); @@ -232,261 +394,266 @@ sub startup { ); $self->helper( - 'get_departures' => sub { - my ( $self, $station, $lookbehind, $lookahead, $with_related ) = @_; - - $lookbehind //= 180; - $lookahead //= 30; - $with_related //= 0; - - my @station_matches - = Travel::Status::DE::IRIS::Stations::get_station($station); - - if ( @station_matches == 1 ) { - $station = $station_matches[0][0]; - my $status = Travel::Status::DE::IRIS->new( - station => $station, - main_cache => $self->app->cache_iris_main, - realtime_cache => $self->app->cache_iris_rt, - lookbehind => 20, - datetime => DateTime->now( time_zone => 'Europe/Berlin' ) - ->subtract( minutes => $lookbehind ), - lookahead => $lookbehind + $lookahead, - lwp_options => { - timeout => 10, - agent => 'travelynx/' . $self->app->config->{version}, - }, - with_related => $with_related, - ); - return { - results => [ $status->results ], - errstr => $status->errstr, - station_ds100 => ( - $status->station ? $status->station->{ds100} : 'undef' - ), - station_name => - ( $status->station ? $status->station->{name} : 'undef' ), - related_stations => [ $status->related_stations ], - }; + 'sprintf_km' => sub { + my ( $self, $km ) = @_; + + if ( $km < 1 ) { + return sprintf( '%.f m', $km * 1000 ); } - elsif ( @station_matches > 1 ) { - return { - results => [], - errstr => 'Ambiguous station name', - }; - } - else { - return { - results => [], - errstr => 'Unknown station name', - }; + if ( $km < 10 ) { + return sprintf( '%.1f km', $km ); } + return sprintf( '%.f km', $km ); } ); $self->helper( - 'grep_unknown_stations' => sub { - my ( $self, @stations ) = @_; - - my @unknown_stations; - for my $station (@stations) { - my $station_info = get_station($station); - if ( not $station_info ) { - push( @unknown_stations, $station ); - } + 'load_icon' => sub { + my ( $self, $load ) = @_; + my $first = $load->{FIRST} // 0; + my $second = $load->{SECOND} // 0; + + my @symbols + = ( + qw(help_outline person_outline people priority_high not_interested) + ); + + return ( $symbols[$first], $symbols[$second] ); + } + ); + + $self->helper( + 'visibility_icon' => sub { + my ( $self, $visibility ) = @_; + if ( $visibility eq 'public' ) { + return 'language'; + } + if ( $visibility eq 'travelynx' ) { + return 'lock_open'; + } + if ( $visibility eq 'followers' ) { + return 'group'; + } + if ( $visibility eq 'unlisted' ) { + return 'lock_outline'; + } + if ( $visibility eq 'private' ) { + return 'lock'; } - return @unknown_stations; + return 'help_outline'; } ); - # Returns (journey id, error) - # Must be called during a transaction. - # Must perform a rollback on error. $self->helper( - 'add_journey' => sub { + 'checkin_p' => sub { my ( $self, %opt ) = @_; - my $db = $opt{db}; - my $uid = $self->current_user->{id}; - my $now = DateTime->now( time_zone => 'Europe/Berlin' ); - my $dep_station = get_station( $opt{dep_station} ); - my $arr_station = get_station( $opt{arr_station} ); + 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 $hafas; - if ( not $dep_station ) { - return ( undef, 'Unbekannter Startbahnhof' ); + my $user = $self->get_user_status( $uid, $db ); + if ( $user->{checked_in} or $user->{cancelled} ) { + return Mojo::Promise->reject('You are already checked in'); } - if ( not $arr_station ) { - return ( undef, 'Unbekannter Zielbahnhof' ); - } - - my @route = ( [ $dep_station->[1], {}, undef ] ); - - if ( $opt{route} ) { - my @unknown_stations; - for my $station ( @{ $opt{route} } ) { - my $station_info = get_station($station); - if ( not $station_info ) { - push( @unknown_stations, $station ); - } - push( @route, [ $station_info->[1], {}, undef ] ); - } - if ( @unknown_stations == 1 ) { - return ( undef, - "Unbekannter Unterwegshalt: $unknown_stations[0]" ); - } - elsif (@unknown_stations) { - return ( undef, - 'Unbekannte Unterwegshalte: ' - . join( ', ', @unknown_stations ) ); - } + if ( $train_id =~ m{[|]} ) { + return $self->_checkin_hafas_p(%opt); } - push( @route, [ $arr_station->[1], {}, undef ] ); + my $promise = Mojo::Promise->new; - if ( $route[0][0] eq $route[1][0] ) { - shift(@route); - } + $self->iris->get_departures_p( + station => $station, + lookbehind => 140, + lookahead => 40 + )->then( + sub { + my ($status) = @_; - if ( $route[-2][0] eq $route[-1][0] ) { - pop(@route); - } + if ( $status->{errstr} ) { + $promise->reject( $status->{errstr} ); + return; + } - my $entry = { - user_id => $uid, - train_type => $opt{train_type}, - train_line => $opt{train_line}, - train_no => $opt{train_no}, - train_id => 'manual', - checkin_station_id => $self->get_station_id( - ds100 => $dep_station->[0], - name => $dep_station->[1], - ), - checkin_time => $now, - sched_departure => $opt{sched_departure}, - real_departure => $opt{rt_departure}, - checkout_station_id => $self->get_station_id( - ds100 => $arr_station->[0], - name => $arr_station->[1], - ), - sched_arrival => $opt{sched_arrival}, - real_arrival => $opt{rt_arrival}, - checkout_time => $now, - edited => 0x3fff, - cancelled => $opt{cancelled} ? 1 : 0, - route => JSON->new->encode( \@route ), - }; + my $eva = $status->{station_eva}; + my $train = List::Util::first { $_->train_id eq $train_id } + @{ $status->{results} }; - if ( $opt{comment} ) { - $entry->{user_data} - = JSON->new->encode( { comment => $opt{comment} } ); - } + if ( not defined $train ) { + $promise->reject("Train ${train_id} not found"); + return; + } - my $journey_id = undef; - eval { - $journey_id - = $db->insert( 'journeys', $entry, { returning => 'id' } ) - ->hash->{id}; - $self->invalidate_stats_cache( $opt{rt_departure}, $db ); - }; + eval { + $self->in_transit->add( + uid => $uid, + db => $db, + departure_eva => $eva, + train => $train, + route => [ $self->iris->route_diff($train) ], + ); + }; + if ($@) { + $self->app->log->error( + "Checkin($uid): INSERT failed: $@"); + $promise->reject( 'INSERT failed: ' . $@ ); + return; + } - if ($@) { - $self->app->log->error("add_journey($uid): $@"); - return ( undef, 'add_journey failed: ' . $@ ); - } + # mustn't be called during a transaction + if ( not $opt{in_transaction} ) { + $self->add_route_timestamps( $uid, $train, 1 ); + $self->run_hook( $uid, 'checkin' ); + } - return ( $journey_id, undef ); + $promise->resolve($train); + return; + } + )->catch( + sub { + my ( $err, $status ) = @_; + $promise->reject( $status->{errstr} ); + return; + } + )->wait; + + return $promise; } ); $self->helper( - 'checkin' => sub { - my ( $self, $station, $train_id ) = @_; + '_checkin_hafas_p' => sub { + my ( $self, %opt ) = @_; - my $status = $self->get_departures( $station, 140, 40, 0 ); - if ( $status->{errstr} ) { - return ( undef, $status->{errstr} ); - } - else { - my ($train) - = first { $_->train_id eq $train_id } @{ $status->{results} }; - if ( not defined $train ) { - return ( undef, "Train ${train_id} not found" ); - } - else { + 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 $hafas; - my $user = $self->get_user_status; - if ( $user->{checked_in} or $user->{cancelled} ) { + my $promise = Mojo::Promise->new; - if ( $user->{train_id} eq $train_id - and $user->{dep_ds100} eq $status->{station_ds100} ) + $self->hafas->get_journey_p( + trip_id => $train_id, + with_polyline => 1 + )->then( + sub { + my ($journey) = @_; + my $found; + for my $stop ( $journey->route ) { + if ( $stop->loc->name eq $station + or $stop->loc->eva == $station ) { - # checking in twice is harmless - return ( $train, undef ); + $found = $stop; + last; } - - # Otherwise, someone forgot to check out first - $self->checkout( $station, 1 ); } - + if ( not $found ) { + $promise->reject( + "Did not find journey $train_id at $station"); + return; + } + for my $stop ( $journey->route ) { + $self->stations->add_or_update( + stop => $stop, + db => $db, + ); + } eval { - my $json = JSON->new; - $self->pg->db->insert( - 'in_transit', - { - user_id => $self->current_user->{id}, - cancelled => $train->departure_is_cancelled - ? 1 - : 0, - checkin_station_id => $self->get_station_id( - ds100 => $status->{station_ds100}, - name => $status->{station_name} - ), - checkin_time => - DateTime->now( time_zone => 'Europe/Berlin' ), - dep_platform => $train->platform, - train_type => $train->type, - train_line => $train->line_no, - train_no => $train->train_no, - train_id => $train->train_id, - sched_departure => $train->sched_departure, - real_departure => $train->departure, - route => $json->encode( - [ $self->route_diff($train) ] - ), - messages => $json->encode( - [ - map { [ $_->[0]->epoch, $_->[1] ] } - $train->messages - ] - ) - } + $self->in_transit->add( + uid => $uid, + db => $db, + journey => $journey, + stop => $found, ); }; if ($@) { - my $uid = $self->current_user->{id}; $self->app->log->error( "Checkin($uid): INSERT failed: $@"); - return ( undef, 'INSERT failed: ' . $@ ); + $promise->reject( 'INSERT failed: ' . $@ ); + return; + } + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => { trip_id => $journey->id } + ); + + my $polyline; + if ( $journey->polyline ) { + my @station_list; + my @coordinate_list; + for my $coord ( $journey->polyline ) { + if ( $coord->{name} ) { + push( + @coordinate_list, + [ + $coord->{lon}, $coord->{lat}, + $coord->{eva} + ] + ); + push( @station_list, $coord->{name} ); + } + else { + push( @coordinate_list, + [ $coord->{lon}, $coord->{lat} ] ); + } + } + + # equal length → polyline only consists of straight + # lines between stops. that's not helpful. + if ( @station_list == @coordinate_list ) { + $self->log->debug( 'Ignoring polyline for ' + . $journey->line + . ' as it only consists of straight lines between stops.' + ); + } + else { + $polyline = { + from_eva => ( $journey->route )[0]->loc->eva, + to_eva => ( $journey->route )[-1]->loc->eva, + coords => \@coordinate_list, + }; + } + } + + if ($polyline) { + $self->in_transit->set_polyline( + uid => $uid, + db => $db, + polyline => $polyline, + ); } - $self->add_route_timestamps( $self->current_user->{id}, - $train, 1 ); - $self->run_hook( $self->current_user->{id}, 'checkin' ); - return ( $train, undef ); + + # mustn't be called during a transaction + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'checkin' ); + } + + $promise->resolve($journey); } - } + )->catch( + sub { + my ($err) = @_; + $promise->reject($err); + return; + } + )->wait; + + return $promise; } ); $self->helper( 'undo' => sub { - my ( $self, $journey_id ) = @_; - my $uid = $self->current_user->{id}; + my ( $self, $journey_id, $uid ) = @_; + $uid //= $self->current_user->{id}; if ( $journey_id eq 'in_transit' ) { - eval { - $self->pg->db->delete( 'in_transit', { user_id => $uid } ); - }; + eval { $self->in_transit->delete( uid => $uid ); }; if ($@) { $self->app->log->error("Undo($uid, $journey_id): $@"); return "Undo($journey_id): $@"; @@ -502,20 +669,10 @@ sub startup { my $db = $self->pg->db; my $tx = $db->begin; - my $journey = $db->select( - 'journeys', - '*', - { - user_id => $uid, - id => $journey_id - } - )->hash; - $db->delete( - 'journeys', - { - user_id => $uid, - id => $journey_id - } + my $journey = $self->journeys->pop( + uid => $uid, + db => $db, + journey_id => $journey_id ); if ( $journey->{edited} ) { @@ -527,7 +684,30 @@ sub startup { delete $journey->{edited}; delete $journey->{id}; - $db->insert( 'in_transit', $journey ); + # users may force checkouts at stations that are not part of + # the train's scheduled (or real-time) route. re-adding those + # to in-transit violates the assumption that each train has + # a valid destination. Remove the target in this case. + my $route = JSON->new->decode( $journey->{route} ); + my $found_checkout_id; + for my $stop ( @{$route} ) { + if ( $stop->[1] == $journey->{checkout_station_id} ) { + $found_checkout_id = 1; + last; + } + } + if ( not $found_checkout_id ) { + $journey->{checkout_station_id} = undef; + $journey->{checkout_time} = undef; + $journey->{arr_platform} = undef; + $journey->{sched_arrival} = undef; + $journey->{real_arrival} = undef; + } + + $self->in_transit->add_from_journey( + db => $db, + journey => $journey + ); my $cache_ts = DateTime->now( time_zone => 'Europe/Berlin' ); if ( $journey->{real_departure} @@ -539,7 +719,11 @@ sub startup { ); } - $self->invalidate_stats_cache( $cache_ts, $db ); + $self->journey_stats_cache->invalidate( + ts => $cache_ts, + db => $db, + uid => $uid + ); $tx->commit; }; @@ -552,601 +736,395 @@ sub startup { } ); - # Statistics are partitioned by real_departure, which must be provided - # when calling this function e.g. after journey deletion or editing. - # If a joureny's real_departure has been edited, this function must be - # called twice: once with the old and once with the new value. $self->helper( - 'invalidate_stats_cache' => sub { - my ( $self, $ts, $db, $uid ) = @_; + 'checkout_p' => sub { + my ( $self, %opt ) = @_; - $uid //= $self->current_user->{id}; - $db //= $self->pg->db; + my $station = $opt{station}; + my $dep_eva = $opt{dep_eva}; + my $arr_eva = $opt{arr_eva}; + my $with_related = $opt{with_related} // 0; + my $force = $opt{force}; + my $uid = $opt{uid} // $self->current_user->{id}; + my $db = $opt{db} // $self->pg->db; + my $user = $self->get_user_status( $uid, $db ); + my $train_id = $user->{train_id}; - $self->pg->db->delete( - 'journey_stats', - { - user_id => $uid, - year => $ts->year, - month => $ts->month, - } - ); - $self->pg->db->delete( - 'journey_stats', - { - user_id => $uid, - year => $ts->year, - month => 0, - } - ); - } - ); - - $self->helper( - 'checkout' => sub { - my ( $self, $station, $force, $uid ) = @_; + my $promise = Mojo::Promise->new; - my $db = $self->pg->db; - my $status = $self->get_departures( $station, 120, 120, 0 ); - $uid //= $self->current_user->{id}; - my $user = $self->get_user_status($uid); - my $train_id = $user->{train_id}; + if ( not $station ) { + $self->app->log->error("Checkout($uid): station is empty"); + return $promise->resolve( 1, + 'BUG: Checkout station is empty.' ); + } if ( not $user->{checked_in} and not $user->{cancelled} ) { - return ( 0, 'You are not checked into any train' ); + return $promise->resolve( 0, + 'You are not checked into any train' ); + } + + if ( $dep_eva and $dep_eva != $user->{dep_eva} ) { + return $promise->resolve( 0, 'race condition' ); } - if ( $status->{errstr} and not $force ) { - return ( 1, $status->{errstr} ); + if ( $arr_eva and $arr_eva != $user->{arr_eva} ) { + return $promise->resolve( 0, 'race condition' ); } - my $now = DateTime->now( time_zone => 'Europe/Berlin' ); - my $journey - = $db->select( 'in_transit', '*', { user_id => $uid } ) - ->expand->hash; - my ($train) - = first { $_->train_id eq $train_id } @{ $status->{results} }; - - # When a checkout is triggered by a checkin, there is an edge case - # with related stations. - # Assume a user travels from A to B1, then from B2 to C. B1 and B2 are - # relatd stations (e.g. "Frankfurt Hbf" and "Frankfurt Hbf(tief)"). - # Now, if they check in for the journey from B2 to C, and have not yet - # checked out of the previous train, $train is undef as B2 is not B1. - # Redo the request with with_related => 1 to avoid this case. - # While at it, we increase the lookahead to handle long journeys as - # well. - if ( not $train ) { - $status = $self->get_departures( $station, 120, 180, 1 ); - ($train) - = first { $_->train_id eq $train_id } @{ $status->{results} }; + if ( $train_id =~ m{[|]} ) { + return $self->_checkout_hafas_p(%opt); } - # Store the intended checkout station regardless of this operation's - # success. - my $new_checkout_station_id = $self->get_station_id( - ds100 => $status->{station_ds100}, - name => $status->{station_name} - ); - $db->update( - 'in_transit', - { - checkout_station_id => $new_checkout_station_id, - }, - { user_id => $uid } + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + my $journey = $self->in_transit->get( + uid => $uid, + with_data => 1 ); - # If in_transit already contains arrival data for another estimated - # destination, we must invalidate it. - if ( defined $journey->{checkout_station_id} - and $journey->{checkout_station_id} - != $new_checkout_station_id ) - { - $db->update( - 'in_transit', - { - checkout_time => undef, - arr_platform => undef, - sched_arrival => undef, - real_arrival => undef, - }, - { user_id => $uid } - ); - } - - if ( not defined $train ) { + $self->iris->get_departures_p( + station => $station, + lookbehind => 120, + lookahead => 180, + with_related => $with_related, + )->then( + sub { + my ($status) = @_; + + my $new_checkout_station_id = $status->{station_eva}; + + # Store the intended checkout station regardless of this operation's + # success. + # TODO for with_related == 1, the correct EVA may be different + # and should be fetched from $train later on + $self->in_transit->set_arrival_eva( + uid => $uid, + db => $db, + arrival_eva => $new_checkout_station_id + ); - # Arrival time via IRIS is unknown, so the train probably has not - # arrived yet. Fall back to HAFAS. - if ( my $station_data - = first { $_->[0] eq $station } @{ $journey->{route} } ) - { - $station_data = $station_data->[1]; - if ( $station_data->{sched_arr} ) { - my $sched_arr - = epoch_to_dt( $station_data->{sched_arr} ); - my $rt_arr = $sched_arr->clone; - if ( $station_data->{adelay} - and $station_data->{adelay} =~ m{^\d+$} ) - { - $rt_arr->add( minutes => $station_data->{adelay} ); - } - $db->update( - 'in_transit', - { - sched_arrival => $sched_arr, - real_arrival => $rt_arr - }, - { user_id => $uid } + # If in_transit already contains arrival data for another estimated + # destination, we must invalidate it. + if ( defined $journey->{checkout_station_id} + and $journey->{checkout_station_id} + != $new_checkout_station_id ) + { + $self->in_transit->unset_arrival_data( + uid => $uid, + db => $db ); } - } - if ( not $force ) { - $self->run_hook( $uid, 'update' ); - return ( 1, undef ); - } - } - my $has_arrived = 0; + # Note that a train may pass the same station several times. + # Notable example: S41 / S42 ("Ringbahn") both starts and + # terminates at Berlin Südkreuz + my $train = List::Util::first { + $_->train_id eq $train_id + and $_->sched_arrival + and $_->sched_arrival->epoch + > $user->{sched_departure}->epoch + } + @{ $status->{results} }; - eval { + $train //= List::Util::first { $_->train_id eq $train_id } + @{ $status->{results} }; - my $tx = $db->begin; + if ( not defined $train ) { - if ( defined $train ) { - $has_arrived = $train->arrival->epoch < $now->epoch ? 1 : 0; - my $json = JSON->new; - $db->update( - 'in_transit', + # Arrival time via IRIS is unknown, so the train probably + # has not arrived yet. Fall back to HAFAS. + # TODO support cases where $station is EVA or DS100 code + if ( + my $station_data + = List::Util::first { $_->[0] eq $station } + @{ $journey->{route} } + ) { - checkout_time => $now, - arr_platform => $train->platform, - sched_arrival => $train->sched_arrival, - real_arrival => $train->arrival, - cancelled => $train->arrival_is_cancelled ? 1 : 0, - route => - $json->encode( [ $self->route_diff($train) ] ), - messages => $json->encode( - [ - map { [ $_->[0]->epoch, $_->[1] ] } - $train->messages - ] - ) - }, - { user_id => $uid } - ); - if ($has_arrived) { - my @unknown_stations - = $self->grep_unknown_stations( $train->route ); - if (@unknown_stations) { - $self->app->log->warn( - 'Encountered unknown stations: ' - . join( ', ', @unknown_stations ) ); + $station_data = $station_data->[2]; + if ( $station_data->{sched_arr} ) { + my $sched_arr + = epoch_to_dt( $station_data->{sched_arr} ); + my $rt_arr + = epoch_to_dt( $station_data->{rt_arr} ); + if ( $rt_arr->epoch == 0 ) { + $rt_arr = $sched_arr->clone; + if ( $station_data->{arr_delay} + and $station_data->{arr_delay} + =~ m{^\d+$} ) + { + $rt_arr->add( minutes => + $station_data->{arr_delay} ); + } + } + $self->in_transit->set_arrival_times( + uid => $uid, + db => $db, + sched_arrival => $sched_arr, + rt_arrival => $rt_arr + ); + } } - } - } + if ( not $force ) { - $journey - = $db->select( 'in_transit', '*', { user_id => $uid } )->hash; - - if ( $has_arrived or $force ) { - delete $journey->{data}; - $journey->{edited} = 0; - $journey->{checkout_time} = $now; - $db->insert( 'journeys', $journey ); - $db->delete( 'in_transit', { user_id => $uid } ); - - my $cache_ts = $now->clone; - if ( $journey->{real_departure} - =~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x ) - { - $cache_ts->set( - year => $+{year}, - month => $+{month} - ); + # mustn't be called during a transaction + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'update' ); + } + $promise->resolve( 1, undef ); + return; + } } - $self->invalidate_stats_cache( $cache_ts, $db, $uid ); - } - - $tx->commit; - }; - - if ($@) { - $self->app->log->error("Checkout($uid): $@"); - return ( 1, 'Checkout error: ' . $@ ); - } + my $has_arrived = 0; - if ( $has_arrived or $force ) { - $self->run_hook( $uid, 'checkout' ); - return ( 0, undef ); - } - $self->run_hook( $uid, 'update' ); - $self->add_route_timestamps( $self->current_user->{id}, $train, 0 ); - return ( 1, undef ); - } - ); - - $self->helper( - 'mark_seen' => sub { - my ( $self, $uid ) = @_; - - $self->pg->db->update( - 'users', - { last_seen => DateTime->now( time_zone => 'Europe/Berlin' ) }, - { id => $uid } - ); - } - ); + eval { - $self->helper( - 'update_journey_part' => sub { - my ( $self, $db, $journey_id, $key, $value ) = @_; - my $rows; - - my $journey = $self->get_journey( - db => $db, - journey_id => $journey_id, - with_datetime => 1, - ); + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } - eval { - if ( $key eq 'sched_departure' ) { - $rows = $db->update( - 'journeys', + if ( defined $train + and not $train->arrival + and not $force ) { - sched_departure => $value, - edited => $journey->{edited} | 0x0001, - }, - { - id => $journey_id, + my $train_no = $train->train_no; + die("Train ${train_no} has no arrival timestamp\n"); } - )->rows; - } - elsif ( $key eq 'rt_departure' ) { - $rows = $db->update( - 'journeys', - { - real_departure => $value, - edited => $journey->{edited} | 0x0002, - }, - { - id => $journey_id, + elsif ( defined $train and $train->arrival ) { + $self->in_transit->set_arrival( + uid => $uid, + db => $db, + train => $train, + route => [ $self->iris->route_diff($train) ] + ); + + $has_arrived + = $train->arrival->epoch < $now->epoch ? 1 : 0; + if ($has_arrived) { + my @unknown_stations + = $self->stations->grep_unknown( + $train->route ); + if (@unknown_stations) { + $self->app->log->warn( + sprintf( +'Route of %s %s (%s -> %s) contains unknown stations: %s', + $train->type, + $train->train_no, + $train->origin, + $train->destination, + join( ', ', @unknown_stations ) + ) + ); + } + } } - )->rows; - # stats are partitioned by rt_departure -> both the cache for - # the old value (see bottom of this function) and the new value - # (here) must be invalidated. - $self->invalidate_stats_cache( $value, $db ); - } - elsif ( $key eq 'sched_arrival' ) { - $rows = $db->update( - 'journeys', - { - sched_arrival => $value, - edited => $journey->{edited} | 0x0100, - }, - { - id => $journey_id, - } - )->rows; - } - elsif ( $key eq 'rt_arrival' ) { - $rows = $db->update( - 'journeys', - { - real_arrival => $value, - edited => $journey->{edited} | 0x0200, - }, - { - id => $journey_id, + $journey = $self->in_transit->get( + uid => $uid, + db => $db + ); + + if ( $has_arrived or $force ) { + $self->journeys->add_from_in_transit( + db => $db, + journey => $journey + ); + $self->in_transit->delete( + uid => $uid, + db => $db + ); + + my $cache_ts = $now->clone; + if ( $journey->{real_departure} + =~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x + ) + { + $cache_ts->set( + year => $+{year}, + month => $+{month} + ); + } + $self->journey_stats_cache->invalidate( + ts => $cache_ts, + db => $db, + uid => $uid + ); } - )->rows; - } - elsif ( $key eq 'route' ) { - my @new_route = map { [ $_, {}, undef ] } @{$value}; - $rows = $db->update( - 'journeys', + elsif ( defined $train + and $train->arrival_is_cancelled ) { - route => JSON->new->encode( \@new_route ), - edited => $journey->{edited} | 0x0010, - }, - { - id => $journey_id, + + # This branch is only taken if the deparure was not cancelled, + # i.e., if the train was supposed to go here but got + # redirected or cancelled on the way and not from the start on. + # If the departure itself was cancelled, the user route is + # cancelled_from action -> 'cancelled journey' panel on main page + # -> cancelled_to action -> force checkout (causing the + # previous branch to be taken due to $force) + $journey->{cancelled} = 1; + $self->journeys->add_from_in_transit( + db => $db, + journey => $journey + ); + $self->in_transit->set_cancelled_destination( + uid => $uid, + db => $db, + cancelled_destination => $train->station, + ); } - )->rows; - } - elsif ( $key eq 'comment' ) { - $journey->{user_data}{comment} = $value; - $rows = $db->update( - 'journeys', - { - user_data => - JSON->new->encode( $journey->{user_data} ), - }, - { - id => $journey_id, + + if ( not $opt{in_transaction} ) { + $tx->commit; } - )->rows; - } - else { - die("Invalid key $key\n"); - } - }; + }; - if ($@) { - $self->app->log->error( - "update_journey_part($journey_id, $key): $@"); - return "update_journey_part($key): $@"; - } - if ( $rows == 1 ) { - $self->invalidate_stats_cache( $journey->{rt_departure}, $db ); - return undef; - } - return 'UPDATE failed: did not match any journey part'; - } - ); + if ($@) { + $self->app->log->error("Checkout($uid): $@"); + $promise->resolve( 1, 'Checkout error: ' . $@ ); + return; + } - $self->helper( - 'journey_sanity_check' => sub { - my ( $self, $journey ) = @_; + if ( $has_arrived or $force ) { + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'checkout' ); + } + $promise->resolve( 0, undef ); + return; + } + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'update' ); + $self->add_route_timestamps( $uid, $train, 0, 1 ); + } + $promise->resolve( 1, undef ); + return; - if ( $journey->{sched_duration} and $journey->{sched_duration} < 0 ) - { - return -'Die geplante Dauer dieser Zugfahrt ist negativ. Zeitreisen werden aktuell nicht unterstützt.'; - } - if ( $journey->{rt_duration} and $journey->{rt_duration} < 0 ) { - return -'Die Dauer dieser Zugfahrt ist negativ. Zeitreisen werden aktuell nicht unterstützt.'; - } - if ( $journey->{sched_duration} - and $journey->{sched_duration} > 60 * 60 * 24 ) - { - return 'Die Zugfahrt ist länger als 24 Stunden.'; - } - if ( $journey->{rt_duration} - and $journey->{rt_duration} > 60 * 60 * 24 ) - { - return 'Die Zugfahrt ist länger als 24 Stunden.'; - } - if ( $journey->{kmh_route} > 500 or $journey->{kmh_beeline} > 500 ) - { - return 'Zugfahrten mit über 500 km/h? Schön wär\'s.'; - } - if ( $journey->{edited} & 0x0010 ) { - my @unknown_stations - = $self->grep_unknown_stations( map { $_->[0] } - @{ $journey->{route} } ); - if (@unknown_stations) { - return 'Unbekannte Station(en): ' - . join( ', ', @unknown_stations ); } - } + )->catch( + sub { + my ($err) = @_; + $promise->resolve( 1, $err ); + return; + } + )->wait; - return undef; + return $promise; } ); $self->helper( - 'get_station_id' => sub { + '_checkout_hafas_p' => sub { my ( $self, %opt ) = @_; - my $res = $self->pg->db->select( 'stations', ['id'], - { ds100 => $opt{ds100} } ); - my $res_h = $res->hash; - - if ($res_h) { - $res->finish; - return $res_h->{id}; - } + my $station = $opt{station}; + my $force = $opt{force}; + my $uid = $opt{uid} // $self->current_user->{id}; + my $db = $opt{db} // $self->pg->db; - if ( $opt{readonly} ) { - return; - } + my $promise = Mojo::Promise->new; - $self->pg->db->insert( - 'stations', - { - ds100 => $opt{ds100}, - name => $opt{name}, - } + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + my $journey = $self->in_transit->get( + uid => $uid, + db => $db, + with_data => 1, + with_timestamps => 1, + with_visibility => 1, + postprocess => 1, ); - $res = $self->pg->db->select( 'stations', ['id'], - { ds100 => $opt{ds100} } ); - my $id = $res->hash->{id}; - $res->finish; - return $id; - } - ); - - $self->helper( - 'verify_registration_token' => sub { - my ( $self, $uid, $token ) = @_; - my $db = $self->pg->db; - my $tx = $db->begin; - - my $res = $db->select( - 'pending_registrations', - 'count(*) as count', - { - user_id => $uid, - token => $token + # with_visibility needed due to postprocess + + my $found; + my $has_arrived; + for my $stop ( @{ $journey->{route_after} } ) { + if ( $station eq $stop->[0] or $station eq $stop->[1] ) { + $found = 1; + $self->in_transit->set_arrival_eva( + uid => $uid, + db => $db, + arrival_eva => $stop->[1], + ); + if ( defined $journey->{checkout_station_id} + and $journey->{checkout_station_id} != $stop->{eva} ) + { + $self->in_transit->unset_arrival_data( + uid => $uid, + db => $db + ); + } + $self->in_transit->set_arrival_times( + uid => $uid, + db => $db, + sched_arrival => $stop->[2]{sched_arr}, + rt_arrival => + ( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} ) + ); + if ( + $now > ( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} ) ) + { + $has_arrived = 1; + } + last; } - ); - - if ( $res->hash->{count} ) { - $db->update( 'users', { status => 1 }, { id => $uid } ); - $db->delete( 'pending_registrations', { user_id => $uid } ); - $tx->commit; - return 1; } - return; - } - ); - - $self->helper( - 'get_uid_by_name_and_mail' => sub { - my ( $self, $name, $email ) = @_; - - my $res = $self->pg->db->select( - 'users', - ['id'], - { - name => $name, - email => $email, - status => 1 - } - ); - - if ( my $user = $res->hash ) { - return $user->{id}; + if ( not $found ) { + return $promise->resolve( 1, 'station not found in route' ); } - return; - } - ); - $self->helper( - 'get_privacy_by_name' => sub { - my ( $self, $name ) = @_; - - my $res = $self->pg->db->select( - 'users', - [ 'id', 'public_level' ], - { - name => $name, - status => 1 + eval { + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; } - ); - - if ( my $user = $res->hash ) { - return $user; - } - return; - } - ); - - $self->helper( - 'set_privacy' => sub { - my ( $self, $uid, $public_level ) = @_; - - $self->pg->db->update( - 'users', - { public_level => $public_level }, - { id => $uid } - ); - } - ); - - $self->helper( - 'mark_for_password_reset' => sub { - my ( $self, $db, $uid, $token ) = @_; - my $res = $db->select( - 'pending_passwords', - 'count(*) as count', - { user_id => $uid } - ); - if ( $res->hash->{count} ) { - return 'in progress'; - } + if ( $has_arrived or $force ) { + $journey = $self->in_transit->get( + uid => $uid, + db => $db + ); + $self->journeys->add_from_in_transit( + db => $db, + journey => $journey + ); + $self->in_transit->delete( + uid => $uid, + db => $db + ); - $db->insert( - 'pending_passwords', - { - user_id => $uid, - token => $token, - requested_at => - DateTime->now( time_zone => 'Europe/Berlin' ) + my $cache_ts = $now->clone; + if ( $journey->{real_departure} + =~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x ) + { + $cache_ts->set( + year => $+{year}, + month => $+{month} + ); + } + $self->journey_stats_cache->invalidate( + ts => $cache_ts, + db => $db, + uid => $uid + ); } - ); - - return undef; - } - ); - - $self->helper( - 'verify_password_token' => sub { - my ( $self, $uid, $token ) = @_; - my $res = $self->pg->db->select( - 'pending_passwords', - 'count(*) as count', - { - user_id => $uid, - token => $token + if ($tx) { + $tx->commit; } - ); + }; - if ( $res->hash->{count} ) { - return 1; + if ($@) { + $self->app->log->error("Checkout($uid): $@"); + return $promise->resolve( 1, 'Checkout error: ' . $@ ); } - return; - } - ); - - $self->helper( - 'mark_for_mail_change' => sub { - my ( $self, $db, $uid, $email, $token ) = @_; - $db->insert( - 'pending_mails', - { - user_id => $uid, - email => $email, - token => $token, - requested_at => - DateTime->now( time_zone => 'Europe/Berlin' ) - }, - { - on_conflict => \ -'(user_id) do update set email = EXCLUDED.email, token = EXCLUDED.token, requested_at = EXCLUDED.requested_at' - }, - ); - } - ); - - $self->helper( - 'change_mail_with_token' => sub { - my ( $self, $uid, $token ) = @_; - - my $db = $self->pg->db; - my $tx = $db->begin; - - my $res_h = $db->select( - 'pending_mails', - ['email'], - { - user_id => $uid, - token => $token + if ( $has_arrived or $force ) { + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'checkout' ); } - )->hash; - - if ($res_h) { - $db->update( - 'users', - { email => $res_h->{email} }, - { id => $uid } - ); - $db->delete( 'pending_mails', { user_id => $uid } ); - $tx->commit; - return 1; + return $promise->resolve( 0, undef ); } - return; - } - ); - - $self->helper( - 'remove_password_token' => sub { - my ( $self, $uid, $token ) = @_; - - $self->pg->db->delete( - 'pending_passwords', - { - user_id => $uid, - token => $token - } - ); + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'update' ); + } + return $promise->resolve( 1, undef ); } ); @@ -1159,124 +1137,7 @@ sub startup { $uid //= $self->current_user->{id}; - my $user_data = $self->pg->db->select( - 'users', - 'id, name, status, public_level, email, ' - . 'extract(epoch from registered_at) as registered_at_ts, ' - . 'extract(epoch from last_seen) as last_seen_ts, ' - . 'extract(epoch from deletion_requested) as deletion_requested_ts', - { id => $uid } - )->hash; - - if ($user_data) { - return { - id => $user_data->{id}, - name => $user_data->{name}, - status => $user_data->{status}, - is_public => $user_data->{public_level}, - email => $user_data->{email}, - registered_at => DateTime->from_epoch( - epoch => $user_data->{registered_at_ts}, - time_zone => 'Europe/Berlin' - ), - last_seen => DateTime->from_epoch( - epoch => $user_data->{last_seen_ts}, - time_zone => 'Europe/Berlin' - ), - deletion_requested => $user_data->{deletion_requested_ts} - ? DateTime->from_epoch( - epoch => $user_data->{deletion_requested_ts}, - time_zone => 'Europe/Berlin' - ) - : undef, - }; - } - return undef; - } - ); - - $self->helper( - 'get_api_token' => sub { - my ( $self, $uid ) = @_; - $uid //= $self->current_user->{id}; - - my $token = {}; - my $res = $self->pg->db->select( - 'tokens', - [ 'type', 'token' ], - { user_id => $uid } - ); - - for my $entry ( $res->hashes->each ) { - $token->{ $self->app->token_types->[ $entry->{type} - 1 ] } - = $entry->{token}; - } - - return $token; - } - ); - - $self->helper( - 'get_webhook' => sub { - my ( $self, $uid ) = @_; - $uid //= $self->current_user->{id}; - - my $res_h - = $self->pg->db->select( 'webhooks_str', '*', - { user_id => $uid } )->hash; - - $res_h->{latest_run} = epoch_to_dt( $res_h->{latest_run_ts} ); - - return $res_h; - } - ); - - $self->helper( - 'set_webhook' => sub { - my ( $self, %opt ) = @_; - - $opt{uid} //= $self->current_user->{id}; - - if ( $opt{token} ) { - $opt{token} =~ tr{\r\n}{}d; - } - - my $res = $self->pg->db->insert( - 'webhooks', - { - user_id => $opt{uid}, - enabled => $opt{enabled}, - url => $opt{url}, - token => $opt{token} - }, - { - on_conflict => \ -'(user_id) do update set enabled = EXCLUDED.enabled, url = EXCLUDED.url, token = EXCLUDED.token, errored = null, latest_run = null, output = null' - } - ); - } - ); - - $self->helper( - 'mark_hook_status' => sub { - my ( $self, $uid, $url, $success, $text ) = @_; - - if ( length($text) > 1000 ) { - $text = substr( $text, 0, 1000 ) . '…'; - } - - $self->pg->db->update( - 'webhooks', - { - errored => $success ? 0 : 1, - latest_run => DateTime->now( time_zone => 'Europe/Berlin' ), - output => $text, - }, - { - user_id => $uid, - url => $url - } - ); + return $self->users->get( uid => $uid ); } ); @@ -1284,7 +1145,7 @@ sub startup { 'run_hook' => sub { my ( $self, $uid, $reason, $callback ) = @_; - my $hook = $self->get_webhook($uid); + my $hook = $self->users->get_webhook( uid => $uid ); if ( not $hook->{enabled} or not $hook->{url} =~ m{^ https?:// }x ) { @@ -1294,7 +1155,7 @@ sub startup { return; } - my $status = $self->get_user_status_json_v1($uid); + my $status = $self->get_user_status_json_v1( uid => $uid ); my $header = {}; my $hook_body = { reason => $reason, @@ -1303,6 +1164,8 @@ sub startup { if ( $hook->{token} ) { $header->{Authorization} = "Bearer $hook->{token}"; + $header->{'User-Agent'} + = 'travelynx/' . $self->app->config->{version}; } my $ua = $self->ua; @@ -1317,1181 +1180,320 @@ sub startup { sub { my ($tx) = @_; if ( my $err = $tx->error ) { - $self->mark_hook_status( $uid, $hook->{url}, 0, - "HTTP $err->{code} $err->{message}" ); + $self->users->update_webhook_status( + uid => $uid, + url => $hook->{url}, + success => 0, + text => "HTTP $err->{code} $err->{message}" + ); } else { - $self->mark_hook_status( $uid, $hook->{url}, 1, - $tx->result->body ); + $self->users->update_webhook_status( + uid => $uid, + url => $hook->{url}, + success => 1, + text => $tx->result->body + ); } if ($callback) { &$callback(); } + return; } )->catch( sub { my ($err) = @_; - $self->mark_hook_status( $uid, $hook->{url}, 0, $err ); + $self->users->update_webhook_status( + uid => $uid, + url => $hook->{url}, + success => 0, + text => $err + ); if ($callback) { &$callback(); } + return; } )->wait; } ); - $self->helper( - 'get_user_password' => sub { - my ( $self, $name ) = @_; - - my $res_h = $self->pg->db->select( - 'users', - 'id, name, status, password as password_hash', - { name => $name } - )->hash; - - return $res_h; - } - ); - - $self->helper( - 'add_user' => sub { - my ( $self, $db, $user_name, $email, $token, $password ) = @_; - - # This helper must be called during a transaction, as user creation - # may fail even after the database entry has been generated, e.g. if - # the registration mail cannot be sent. We therefore use $db (the - # database handle performing the transaction) instead of $self->pg->db - # (which may be a new handle not belonging to the transaction). - - my $now = DateTime->now( time_zone => 'Europe/Berlin' ); - - my $res = $db->insert( - 'users', - { - name => $user_name, - status => 0, - public_level => 0, - email => $email, - password => $password, - registered_at => $now, - last_seen => $now, - }, - { returning => 'id' } - ); - my $uid = $res->hash->{id}; - - $db->insert( - 'pending_registrations', - { - user_id => $uid, - token => $token - } - ); - - return $uid; - } - ); - - $self->helper( - 'flag_user_deletion' => sub { - my ( $self, $uid ) = @_; - - my $now = DateTime->now( time_zone => 'Europe/Berlin' ); - - $self->pg->db->update( - 'users', - { deletion_requested => $now }, - { - id => $uid, - } - ); - } - ); - - $self->helper( - 'unflag_user_deletion' => sub { - my ( $self, $uid ) = @_; - - $self->pg->db->update( - 'users', - { - deletion_requested => undef, - }, - { - id => $uid, - } - ); - } - ); - - $self->helper( - 'set_user_password' => sub { - my ( $self, $uid, $password ) = @_; - - $self->pg->db->update( - 'users', - { password => $password }, - { id => $uid } - ); - } - ); - - $self->helper( - 'check_if_user_name_exists' => sub { - my ( $self, $user_name ) = @_; - - my $count = $self->pg->db->select( - 'users', - 'count(*) as count', - { name => $user_name } - )->hash->{count}; - - if ($count) { - return 1; - } - return 0; - } - ); - - $self->helper( - 'check_if_mail_is_blacklisted' => sub { - my ( $self, $mail ) = @_; - - my $count = $self->pg->db->select( - 'users', - 'count(*) as count', - { - email => $mail, - status => 0, - } - )->hash->{count}; - - if ($count) { - return 1; - } - - $count = $self->pg->db->select( - 'mail_blacklist', - 'count(*) as count', - { - email => $mail, - num_tries => { '>', 1 }, - } - )->hash->{count}; - - if ($count) { - return 1; - } - return 0; - } - ); - - $self->helper( - 'delete_journey' => sub { - my ( $self, $journey_id, $checkin_epoch, $checkout_epoch ) = @_; - my $uid = $self->current_user->{id}; - - my @journeys = $self->get_user_travels( - uid => $uid, - journey_id => $journey_id - ); - if ( @journeys == 0 ) { - return 'Journey not found'; - } - my $journey = $journeys[0]; - - # Double-check (comparing both ID and action epoch) to make sure we - # are really deleting the right journey and the user isn't just - # playing around with POST requests. - if ( $journey->{id} != $journey_id - or $journey->{checkin_ts} != $checkin_epoch - or $journey->{checkout_ts} != $checkout_epoch ) - { - return 'Invalid journey data'; - } - - my $rows; - eval { - $rows = $self->pg->db->delete( - 'journeys', - { - user_id => $uid, - id => $journey_id, - } - )->rows; - }; - - if ($@) { - $self->app->log->error("Delete($uid, $journey_id): $@"); - return 'DELETE failed: ' . $@; - } - - if ( $rows == 1 ) { - $self->invalidate_stats_cache( - epoch_to_dt( $journey->{rt_dep_ts} ) ); - return undef; - } - return sprintf( 'Deleted %d rows, expected 1', $rows ); - } - ); - - $self->helper( - 'get_journey_stats' => sub { - my ( $self, %opt ) = @_; - - if ( $opt{cancelled} ) { - $self->app->log->warning( -'get_journey_stats called with illegal option cancelled => 1' - ); - return {}; - } - - my $uid = $opt{uid} // $self->current_user->{id}; - my $year = $opt{year} // 0; - my $month = $opt{month} // 0; - - # Assumption: If the stats cache contains an entry it is up-to-date. - # -> Cache entries must be explicitly invalidated whenever the user - # checks out of a train or manually edits/adds a journey. - - my $res = $self->pg->db->select( - 'journey_stats', - ['data'], - { - user_id => $uid, - year => $year, - month => $month - } - ); - - my $res_h = $res->expand->hash; - - if ($res_h) { - $res->finish; - return $res_h->{data}; - } - - my $interval_start = DateTime->new( - time_zone => 'Europe/Berlin', - year => 2000, - month => 1, - day => 1, - hour => 0, - minute => 0, - second => 0, - ); - - # I wonder if people will still be traveling by train in the year 3000 - my $interval_end = $interval_start->clone->add( years => 1000 ); - - if ( $opt{year} and $opt{month} ) { - $interval_start->set( - year => $opt{year}, - month => $opt{month} - ); - $interval_end = $interval_start->clone->add( months => 1 ); - } - elsif ( $opt{year} ) { - $interval_start->set( year => $opt{year} ); - $interval_end = $interval_start->clone->add( years => 1 ); - } - - my @journeys = $self->get_user_travels( - uid => $uid, - cancelled => $opt{cancelled} ? 1 : 0, - verbose => 1, - after => $interval_start, - before => $interval_end - ); - my $stats = $self->compute_journey_stats(@journeys); - - eval { - $self->pg->db->insert( - 'journey_stats', - { - user_id => $uid, - year => $year, - month => $month, - data => JSON->new->encode($stats), - } - ); - }; - if ( my $err = $@ ) { - if ( $err =~ m{duplicate key value violates unique constraint} ) - { - # When a user opens the same history page several times in - # short succession, there is a race condition where several - # Mojolicious workers execute this helper, notice that there is - # no up-to-date history, compute it, and insert it using the - # statement above. This will lead to a uniqueness violation - # in each successive insert. However, this is harmless, and - # thus ignored. - } - else { - # Otherwise we probably have a problem. - die($@); - } - } - - return $stats; - } - ); - - $self->helper( - 'history_years' => sub { - my ( $self, $uid ) = @_; - $uid //= $self->current_user->{id}, - - my $res = $self->pg->db->select( - 'journeys', - 'distinct extract(year from real_departure) as year', - { user_id => $uid }, - { order_by => { -asc => 'year' } } - ); - - my @ret; - for my $row ( $res->hashes->each ) { - push( @ret, [ $row->{year}, $row->{year} ] ); - } - return @ret; - } - ); - - $self->helper( - 'history_months' => sub { - my ( $self, $uid ) = @_; - $uid //= $self->current_user->{id}, - - my $res = $self->pg->db->select( - 'journeys', - "distinct to_char(real_departure, 'YYYY.MM') as yearmonth", - { user_id => $uid }, - { order_by => { -asc => 'yearmonth' } } - ); - - my @ret; - for my $row ( $res->hashes->each ) { - my ( $year, $month ) = split( qr{[.]}, $row->{yearmonth} ); - push( @ret, [ "${year}/${month}", "${month}.${year}" ] ); - } - return @ret; - } - ); - - $self->helper( - 'route_diff' => sub { - my ( $self, $train ) = @_; - my @json_route; - my @route = $train->route; - my @sched_route = $train->sched_route; - - my $route_idx = 0; - my $sched_idx = 0; - - while ( $route_idx <= $#route and $sched_idx <= $#sched_route ) { - if ( $route[$route_idx] eq $sched_route[$sched_idx] ) { - push( @json_route, [ $route[$route_idx], {}, undef ] ); - $route_idx++; - $sched_idx++; - } - - # this branch is inefficient, but won't be taken frequently - elsif ( not( grep { $_ eq $route[$route_idx] } @sched_route ) ) - { - push( @json_route, - [ $route[$route_idx], {}, 'additional' ], - ); - $route_idx++; - } - else { - push( @json_route, - [ $sched_route[$sched_idx], {}, 'cancelled' ], - ); - $sched_idx++; - } - } - while ( $route_idx <= $#route ) { - push( @json_route, [ $route[$route_idx], {}, 'additional' ], ); - $route_idx++; - } - while ( $sched_idx <= $#sched_route ) { - push( @json_route, - [ $sched_route[$sched_idx], {}, 'cancelled' ], - ); - $sched_idx++; - } - return @json_route; - } - ); - - $self->helper( - 'get_dbdb_station_p' => sub { - my ( $self, $ds100 ) = @_; - - my $url = "https://lib.finalrewind.org/dbdb/s/${ds100}.json"; - - my $cache = $self->app->cache_iris_main; - my $promise = Mojo::Promise->new; - - if ( my $content = $cache->thaw($url) ) { - $promise->resolve($content); - return $promise; - } - - $self->ua->request_timeout(5)->get_p($url)->then( - sub { - my ($tx) = @_; - my $body = decode( 'utf-8', $tx->res->body ); - - my $json = JSON->new->decode($body); - $cache->freeze( $url, $json ); - $promise->resolve($json); - } - )->catch( - sub { - my ($err) = @_; - $promise->reject($err); - } - )->wait; - return $promise; - } - ); - - $self->helper( - 'get_wagonorder_p' => sub { - my ( $self, $ts, $train_no ) = @_; - my $api_ts = $ts->strftime('%Y%m%d%H%M'); - my $url - = "https://www.apps-bahn.de/wr/wagenreihung/1.0/${train_no}/${api_ts}"; - - my $cache = $self->app->cache_iris_main; - my $promise = Mojo::Promise->new; - - if ( my $content = $cache->thaw($url) ) { - $promise->resolve($content); - return $promise; - } - - $self->ua->request_timeout(5)->get_p($url)->then( - sub { - my ($tx) = @_; - my $body = decode( 'utf-8', $tx->res->body ); - - my $json = JSON->new->decode($body); - $cache->freeze( $url, $json ); - $promise->resolve($json); - } - )->catch( - sub { - my ($err) = @_; - $promise->reject($err); - } - )->wait; - return $promise; - } - ); - - $self->helper( - 'get_hafas_json_p' => sub { - my ( $self, $url ) = @_; - - my $cache = $self->app->cache_iris_main; - my $promise = Mojo::Promise->new; - - if ( my $content = $cache->thaw($url) ) { - $promise->resolve($content); - return $promise; - } - - $self->ua->request_timeout(5)->get_p($url)->then( - sub { - my ($tx) = @_; - my $body = decode( 'ISO-8859-15', $tx->res->body ); - - $body =~ s{^TSLs[.]sls = }{}; - $body =~ s{;$}{}; - $body =~ s{(}{(}g; - $body =~ s{)}{)}g; - my $json = JSON->new->decode($body); - $cache->freeze( $url, $json ); - $promise->resolve($json); - } - )->catch( - sub { - my ($err) = @_; - $self->app->log->warning("get($url): $err"); - $promise->reject($err); - } - )->wait; - return $promise; - } - ); - - $self->helper( - 'get_hafas_xml_p' => sub { - my ( $self, $url ) = @_; - - my $cache = $self->app->cache_iris_rt; - my $promise = Mojo::Promise->new; - - if ( my $content = $cache->thaw($url) ) { - $promise->resolve($content); - return $promise; - } - - $self->ua->request_timeout(5)->get_p($url)->then( - sub { - my ($tx) = @_; - my $body = decode( 'ISO-8859-15', $tx->res->body ); - my $tree; - - my $traininfo = { - station => {}, - messages => [], - }; - - # <SDay text="... > ..."> is invalid HTML, but present in - # regardless. As it is the last tag, we just throw it away. - $body =~ s{<SDay [^>]*/>}{}s; - eval { $tree = XML::LibXML->load_xml( string => $body ) }; - if ($@) { - $self->app->log->warning("load_xml($url): $@"); - $cache->freeze( $url, $traininfo ); - $promise->resolve($traininfo); - return; - } - - for my $station ( $tree->findnodes('/Journey/St') ) { - my $name = $station->getAttribute('name'); - my $adelay = $station->getAttribute('adelay'); - my $ddelay = $station->getAttribute('ddelay'); - $traininfo->{station}{$name} = { - adelay => $adelay, - ddelay => $ddelay, - }; - } - - for my $message ( $tree->findnodes('/Journey/HIMMessage') ) - { - my $header = $message->getAttribute('header'); - my $lead = $message->getAttribute('lead'); - my $display = $message->getAttribute('display'); - push( - @{ $traininfo->{messages} }, - { - header => $header, - lead => $lead, - display => $display - } - ); - } - - $cache->freeze( $url, $traininfo ); - $promise->resolve($traininfo); - } - )->catch( - sub { - my ($err) = @_; - $self->app->log->warning("get($url): $err"); - $promise->reject($err); - } - )->wait; - return $promise; - } - ); - + # This helper is only ever called from an IRIS context. + # HAFAS already has all relevant information. $self->helper( 'add_route_timestamps' => sub { - my ( $self, $uid, $train, $is_departure ) = @_; + my ( $self, $uid, $train, $is_departure, $update_polyline ) = @_; $uid //= $self->current_user->{id}; my $db = $self->pg->db; - my $journey = $db->select( - 'in_transit_str', - [ 'arr_ds100', 'dep_ds100', 'route' ], - { user_id => $uid } - )->expand->hash; + # TODO "with_timestamps" is misleading, there are more differences between in_transit and in_transit_str + # Here it's only needed because of dep_eva / arr_eva names + my $in_transit = $self->in_transit->get( + db => $db, + uid => $uid, + with_data => 1, + with_timestamps => 1 + ); - if ( not $journey ) { + if ( not $in_transit ) { return; } - my ($platform) = ( ( $train->platform // 0 ) =~ m{(\d+)} ); - - my $route = $journey->{route}; - - my $base - = 'https://reiseauskunft.bahn.de/bin/trainsearch.exe/dn?L=vs_json.vs_hap&start=yes&rt=1'; - my $date_yy = $train->start->strftime('%d.%m.%y'); - my $date_yyyy = $train->start->strftime('%d.%m.%Y'); - my $train_no = $train->type . ' ' . $train->train_no; - - $self->app->log->debug("add_route_timestamps"); - - my ( $trainlink, $route_data ); - - $self->get_hafas_json_p( - "${base}&date=${date_yy}&trainname=${train_no}")->then( - sub { - my ($trainsearch) = @_; - - # Fallback: Take first result - $trainlink = $trainsearch->{suggestions}[0]{trainLink}; - - # Try finding a result for the current date - for - my $suggestion ( @{ $trainsearch->{suggestions} // [] } ) - { + my $route = $in_transit->{route}; - # Drunken API, sail with care. Both date formats are used interchangeably - if ( $suggestion->{depDate} eq $date_yy - or $suggestion->{depDate} eq $date_yyyy ) - { - # Train numbers are not unique, e.g. IC 149 refers both to the - # InterCity service Amsterdam -> Berlin and to the InterCity service - # Koebenhavns Lufthavn st -> Aarhus. One workaround is making - # requests with the stationFilter=80 parameter. Checking the origin - # station seems to be the more generic solution, so we do that - # instead. - if ( $suggestion->{dep} eq $train->origin ) { - $trainlink = $suggestion->{trainLink}; - last; - } - } - } - - if ( not $trainlink ) { - $self->app->log->debug("trainlink not found"); - return Mojo::Promise->reject("trainlink not found"); - } - my $base2 - = 'https://reiseauskunft.bahn.de/bin/traininfo.exe/dn'; - return $self->get_hafas_json_p( -"${base2}/${trainlink}?rt=1&date=${date_yy}&L=vs_json.vs_hap" - ); - } - )->then( + $self->hafas->get_tripid_p( train => $train )->then( sub { - my ($traininfo) = @_; - if ( not $traininfo or $traininfo->{error} ) { - $self->app->log->debug("traininfo error"); - return Mojo::Promise->reject("traininfo error"); - } - my $routeinfo - = $traininfo->{suggestions}[0]{locations}; + my ($trip_id) = @_; - my $strp = DateTime::Format::Strptime->new( - pattern => '%d.%m.%y %H:%M', - time_zone => 'Europe/Berlin', + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => { trip_id => $trip_id } ); - $route_data = {}; - - for my $station ( @{$routeinfo} ) { - my $arr - = $strp->parse_datetime( - $station->{arrDate} . ' ' . $station->{arrTime} ); - my $dep - = $strp->parse_datetime( - $station->{depDate} . ' ' . $station->{depTime} ); - $route_data->{ $station->{name} } = { - sched_arr => $arr ? $arr->epoch : 0, - sched_dep => $dep ? $dep->epoch : 0, - }; - } - - my $base2 - = 'https://reiseauskunft.bahn.de/bin/traininfo.exe/dn'; - return $self->get_hafas_xml_p( - "${base2}/${trainlink}?rt=1&date=${date_yy}&L=vs_java3" + return $self->hafas->get_route_timestamps_p( + train => $train, + trip_id => $trip_id, + with_polyline => ( + $update_polyline + or not $in_transit->{polyline} + ) ? 1 : 0, ); } )->then( sub { - my ($traininfo2) = @_; + my ( $route_data, $journey, $polyline ) = @_; - for my $station ( keys %{$route_data} ) { - for my $key ( - keys %{ $traininfo2->{station}{$station} // {} } ) + for my $station ( @{$route} ) { + if ( $station->[0] + =~ m{^Betriebsstelle nicht bekannt (\d+)$} ) { - $route_data->{$station}{$key} - = $traininfo2->{station}{$station}{$key}; + my $eva = $1; + if ( $route_data->{$eva} ) { + $station->[0] = $route_data->{$eva}{name}; + $station->[1] = $route_data->{$eva}{eva}; + } } - } + if ( my $sd = $route_data->{ $station->[0] } ) { + $station->[1] = $sd->{eva}; + if ( $station->[2]{isAdditional} ) { + $sd->{isAdditional} = 1; + } + if ( $station->[2]{isCancelled} ) { + $sd->{isCancelled} = 1; + } - for my $station ( @{$route} ) { - $station->[1] - = $route_data->{ $station->[0] }; + # keep rt_dep / rt_arr if they are no longer present + my %old; + for my $k (qw(rt_arr rt_dep arr_delay dep_delay)) { + $old{$k} = $station->[2]{$k}; + } + $station->[2] = $sd; + if ( not $station->[2]{rt_arr} ) { + $station->[2]{rt_arr} = $old{rt_arr}; + $station->[2]{arr_delay} = $old{arr_delay}; + } + if ( not $station->[2]{rt_dep} ) { + $station->[2]{rt_dep} = $old{rt_dep}; + $station->[2]{dep_delay} = $old{dep_delay}; + } + } } - my $res = $db->select( 'in_transit', ['data'], - { user_id => $uid } ); - my $res_h = $res->expand->hash; - my $data = $res_h->{data} // {}; + my @messages; + for my $m ( $journey->messages ) { + if ( not $m->code ) { + push( + @messages, + { + header => $m->short, + lead => $m->text, + } + ); + } + } - $data->{delay_msg} = [ map { [ $_->[0]->epoch, $_->[1] ] } - $train->delay_messages ]; - $data->{qos_msg} = [ map { [ $_->[0]->epoch, $_->[1] ] } - $train->qos_messages ]; + $self->in_transit->set_route_data( + uid => $uid, + db => $db, + route => $route, + delay_messages => [ + map { [ $_->[0]->epoch, $_->[1] ] } + $train->delay_messages + ], + qos_messages => [ + map { [ $_->[0]->epoch, $_->[1] ] } + $train->qos_messages + ], + him_messages => \@messages, + ); - $data->{him_msg} = $traininfo2->{messages}; + if ($polyline) { + $self->in_transit->set_polyline( + uid => $uid, + db => $db, + polyline => $polyline, + old_id => $in_transit->{polyline_id}, + ); + } - $db->update( - 'in_transit', - { - route => JSON->new->encode($route), - data => JSON->new->encode($data) - }, - { user_id => $uid } - ); + return; + } + )->catch( + sub { + my ($err) = @_; + $self->app->log->debug("add_route_timestamps: $err"); + return; } )->wait; - if ( $train->type =~ m{[EI]C} and $train->sched_departure ) { - $self->get_wagonorder_p( $train->sched_departure, + if ( $train->sched_departure ) { + $self->dbdb->has_wagonorder_p( $train->sched_departure, $train->train_no )->then( sub { + my ($api) = @_; + return $self->dbdb->get_wagonorder_p( $api, + $train->sched_departure, $train->train_no ); + } + )->then( + sub { my ($wagonorder) = @_; - my $res = $db->select( 'in_transit', ['data'], - { user_id => $uid } ); - my $res_h = $res->expand->hash; - my $data = $res_h->{data} // {}; + my $data = {}; + my $user_data = {}; - if ($is_departure) { - $data->{wagonorder_dep} = $wagonorder; + if ( $is_departure and not exists $wagonorder->{error} ) + { + $data->{wagonorder_dep} = $wagonorder; + $user_data->{wagongroups} = []; + for my $group ( + @{ + $wagonorder->{data}{istformation} + {allFahrzeuggruppe} // [] + } + ) + { + my @wagons; + for + my $wagon ( @{ $group->{allFahrzeug} // [] } ) + { + push( + @wagons, + { + id => $wagon->{fahrzeugnummer}, + number => + $wagon->{wagenordnungsnummer}, + type => $wagon->{fahrzeugtyp}, + } + ); + } + push( + @{ $user_data->{wagongroups} }, + { + name => + $group->{fahrzeuggruppebezeichnung}, + from => + $group->{startbetriebsstellename}, + to => $group->{zielbetriebsstellename}, + no => $group->{verkehrlichezugnummer}, + wagons => [@wagons], + } + ); + if ( $group->{fahrzeuggruppebezeichnung} + and $group->{fahrzeuggruppebezeichnung} eq + 'ICE0304' ) + { + $data->{wagonorder_pride} = 1; + } + } + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => $data + ); + $self->in_transit->update_user_data( + uid => $uid, + db => $db, + user_data => $user_data + ); } - else { + elsif ( not $is_departure + and not exists $wagonorder->{error} ) + { $data->{wagonorder_arr} = $wagonorder; + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => $data + ); } - - $db->update( - 'in_transit', - { data => JSON->new->encode($data) }, - { user_id => $uid } - ); + return; + } + )->catch( + sub { + # no wagonorder? no problem. + return; } )->wait; } if ($is_departure) { - $self->get_dbdb_station_p( $journey->{dep_ds100} )->then( + $self->dbdb->get_stationinfo_p( $in_transit->{dep_eva} )->then( sub { my ($station_info) = @_; + my $data = { stationinfo_dep => $station_info }; - my $res = $db->select( 'in_transit', ['data'], - { user_id => $uid } ); - my $res_h = $res->expand->hash; - my $data = $res_h->{data} // {}; - - $data->{stationinfo_dep} = $station_info; - - $db->update( - 'in_transit', - { data => JSON->new->encode($data) }, - { user_id => $uid } + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => $data ); + return; + } + )->catch( + sub { + # no stationinfo? no problem. + return; } )->wait; } - if ( $journey->{arr_ds100} and not $is_departure ) { - $self->get_dbdb_station_p( $journey->{arr_ds100} )->then( + if ( $in_transit->{arr_eva} and not $is_departure ) { + $self->dbdb->get_stationinfo_p( $in_transit->{arr_eva} )->then( sub { my ($station_info) = @_; + my $data = { stationinfo_arr => $station_info }; - my $res = $db->select( 'in_transit', ['data'], - { user_id => $uid } ); - my $res_h = $res->expand->hash; - my $data = $res_h->{data} // {}; - - $data->{stationinfo_arr} = $station_info; - - $db->update( - 'in_transit', - { data => JSON->new->encode($data) }, - { user_id => $uid } + $self->in_transit->update_data( + uid => $uid, + db => $db, + data => $data ); + return; } - )->wait; - } - } - ); - - $self->helper( - 'get_oldest_journey_ts' => sub { - my ($self) = @_; - - my $res_h = $self->pg->db->select( - 'journeys_str', - ['sched_dep_ts'], - { - user_id => $self->current_user->{id}, - }, - { - limit => 1, - order_by => { - -asc => 'real_dep_ts', - }, - } - )->hash; - - if ($res_h) { - return epoch_to_dt( $res_h->{sched_dep_ts} ); - } - return undef; - } - ); - - $self->helper( - 'get_latest_dest_id' => sub { - my ( $self, %opt ) = @_; - - my $uid = $opt{uid} // $self->current_user->{id}; - my $db = $opt{db} // $self->pg->db; - - my $journey = $db->select( 'in_transit', ['checkout_station_id'], - { user_id => $uid } )->hash; - if ( not $journey ) { - $journey = $db->select( - 'journeys', - ['checkout_station_id'], - { - user_id => $uid, - cancelled => 0 - }, - { - limit => 1, - order_by => { -desc => 'real_departure' } - } - )->hash; - } - - if ( not $journey ) { - return; - } - - return $journey->{checkout_station_id}; - } - ); - - $self->helper( - 'get_connection_targets' => sub { - my ( $self, %opt ) = @_; - - my $uid = $opt{uid} //= $self->current_user->{id}; - my $threshold = $opt{threshold} - // DateTime->now( time_zone => 'Europe/Berlin' ) - ->subtract( months => 4 ); - my $db = $opt{db} //= $self->pg->db; - my $min_count = $opt{min_count} // 3; - - my $dest_id; - - if ( $opt{ds100} ) { - $dest_id = $self->get_station_id( - ds100 => $opt{ds100}, - readonly => 1 - ); - } - else { - $dest_id = $self->get_latest_dest_id(%opt); - } - - if ( not $dest_id ) { - return; - } - - my $res = $db->query( - qq{ - select - count(stations.name) as count, - stations.name as dest - from journeys - left outer join stations on checkout_station_id = stations.id - where user_id = ? - and checkin_station_id = ? - and real_departure > ? - group by stations.name - order by count desc; - }, - $uid, - $dest_id, - $threshold - ); - my @destinations - = $res->hashes->grep( sub { shift->{count} >= $min_count } ) - ->map( sub { shift->{dest} } )->each; - return @destinations; - } - ); - - $self->helper( - 'get_connecting_trains' => sub { - my ( $self, %opt ) = @_; - - my $uid = $opt{uid} //= $self->current_user->{id}; - my $use_history = $self->account_use_history($uid); - - my ( $ds100, $exclude_via, $exclude_train_id, $exclude_before ); - - if ( $opt{ds100} ) { - if ( $use_history & 0x01 ) { - $ds100 = $opt{ds100}; - } - } - else { - if ( $use_history & 0x02 ) { - my $status = $self->get_user_status; - $ds100 = $status->{arr_ds100}; - $exclude_via = $status->{dep_name}; - $exclude_train_id = $status->{train_id}; - $exclude_before = $status->{real_arrival}->epoch; - } - } - - if ( not $ds100 ) { - return; - } - - my @destinations = $self->get_connection_targets(%opt); - - if ($exclude_via) { - @destinations = grep { $_ ne $exclude_via } @destinations; - } - - if ( not @destinations ) { - return; - } - - my $stationboard = $self->get_departures( $ds100, 0, 40, 1 ); - if ( $stationboard->{errstr} ) { - return; - } - @{ $stationboard->{results} } = map { $_->[0] } - sort { $a->[1] <=> $b->[1] } - map { [ $_, $_->departure ? $_->departure->epoch : 0 ] } - @{ $stationboard->{results} }; - my @results; - my @cancellations; - my %via_count = map { $_ => 0 } @destinations; - for my $train ( @{ $stationboard->{results} } ) { - if ( not $train->departure ) { - next; - } - if ( $exclude_before - and $train->departure->epoch < $exclude_before ) - { - next; - } - if ( $exclude_train_id - and $train->train_id eq $exclude_train_id ) - { - next; - } - - # In general, this function is meant to return feasible - # connections. However, cancelled connections may also be of - # interest and are also useful for logging cancellations. - # To satisfy both demands with (hopefully) little confusion and - # UI clutter, this function returns two concatenated arrays: - # actual connections (ordered by actual departure time) followed - # by cancelled connections (ordered by scheduled departure time). - # This is easiest to achieve in two separate loops. - # - # Note that a cancelled train may still have a matching destination - # in its route_post, e.g. if it leaves out $ds100 due to - # unscheduled route changes but continues on schedule afterwards - # -- so it is only cancelled at $ds100, not on the remainder of - # the route. Also note that this specific case is not yet handled - # properly by the cancellation logic etc. - - if ( $train->departure_is_cancelled ) { - my @via - = ( $train->sched_route_post, $train->sched_route_end ); - for my $dest (@destinations) { - if ( List::Util::any { $_ eq $dest } @via ) { - push( @cancellations, [ $train, $dest ] ); - next; - } - } - } - else { - my @via = ( $train->route_post, $train->route_end ); - for my $dest (@destinations) { - if ( $via_count{$dest} < 2 - and List::Util::any { $_ eq $dest } @via ) - { - push( @results, [ $train, $dest ] ); - $via_count{$dest}++; - next; - } - } - } - } - - @results = map { $_->[0] } - sort { $a->[1] <=> $b->[1] } - map { - [ - $_, - $_->[0]->departure->epoch // $_->[0]->sched_departure->epoch - ] - } @results; - @cancellations = map { $_->[0] } - sort { $a->[1] <=> $b->[1] } - map { [ $_, $_->[0]->sched_departure->epoch ] } @cancellations; - - return ( @results, @cancellations ); - } - ); - - $self->helper( - 'account_use_history' => sub { - my ( $self, $uid, $value ) = @_; - - if ($value) { - $self->pg->db->update( - 'users', - { use_history => $value }, - { id => $uid } - ); - } - else { - return $self->pg->db->select( 'users', ['use_history'], - { id => $uid } )->hash->{use_history}; - } - } - ); - - $self->helper( - 'get_user_travels' => sub { - my ( $self, %opt ) = @_; - - my $uid = $opt{uid} || $self->current_user->{id}; - - # If get_user_travels is called from inside a transaction, db - # specifies the database handle performing the transaction. - # Otherwise, we grab a fresh one. - my $db = $opt{db} // $self->pg->db; - - my %where = ( - user_id => $uid, - cancelled => 0 - ); - my %order = ( - order_by => { - -desc => 'real_dep_ts', - } - ); - - if ( $opt{cancelled} ) { - $where{cancelled} = 1; - } - - if ( $opt{limit} ) { - $order{limit} = $opt{limit}; - } - - if ( $opt{journey_id} ) { - $where{journey_id} = $opt{journey_id}; - delete $where{cancelled}; - } - elsif ( $opt{after} and $opt{before} ) { - $where{real_dep_ts} = { - -between => [ $opt{after}->epoch, $opt{before}->epoch, ] }; - } - - my @travels; - - my $res = $db->select( 'journeys_str', '*', \%where, \%order ); - - for my $entry ( $res->expand->hashes->each ) { - - my $ref = { - id => $entry->{journey_id}, - type => $entry->{train_type}, - line => $entry->{train_line}, - no => $entry->{train_no}, - from_name => $entry->{dep_name}, - checkin_ts => $entry->{checkin_ts}, - sched_dep_ts => $entry->{sched_dep_ts}, - rt_dep_ts => $entry->{real_dep_ts}, - to_name => $entry->{arr_name}, - checkout_ts => $entry->{checkout_ts}, - sched_arr_ts => $entry->{sched_arr_ts}, - rt_arr_ts => $entry->{real_arr_ts}, - messages => $entry->{messages}, - route => $entry->{route}, - edited => $entry->{edited}, - user_data => $entry->{user_data}, - }; - - if ( $opt{with_datetime} ) { - $ref->{checkin} = epoch_to_dt( $ref->{checkin_ts} ); - $ref->{sched_departure} - = epoch_to_dt( $ref->{sched_dep_ts} ); - $ref->{rt_departure} = epoch_to_dt( $ref->{rt_dep_ts} ); - $ref->{checkout} = epoch_to_dt( $ref->{checkout_ts} ); - $ref->{sched_arrival} = epoch_to_dt( $ref->{sched_arr_ts} ); - $ref->{rt_arrival} = epoch_to_dt( $ref->{rt_arr_ts} ); - } - - if ( $opt{verbose} ) { - $ref->{cancelled} = $entry->{cancelled}; - my @parsed_messages; - for my $message ( @{ $ref->{messages} // [] } ) { - my ( $ts, $msg ) = @{$message}; - push( @parsed_messages, [ epoch_to_dt($ts), $msg ] ); + )->catch( + sub { + # no stationinfo? no problem. + return; } - $ref->{messages} = [ reverse @parsed_messages ]; - $ref->{sched_duration} - = $ref->{sched_arr_ts} - ? $ref->{sched_arr_ts} - $ref->{sched_dep_ts} - : undef; - $ref->{rt_duration} - = $ref->{rt_arr_ts} - ? $ref->{rt_arr_ts} - $ref->{rt_dep_ts} - : undef; - my ( $km_route, $km_beeline, $skip ) - = $self->get_travel_distance( $ref->{from_name}, - $ref->{to_name}, $ref->{route} ); - $ref->{km_route} = $km_route; - $ref->{skip_route} = $skip; - $ref->{km_beeline} = $km_beeline; - $ref->{skip_beeline} = $skip; - my $kmh_divisor - = ( $ref->{rt_duration} // $ref->{sched_duration} - // 999999 ) / 3600; - $ref->{kmh_route} - = $kmh_divisor ? $ref->{km_route} / $kmh_divisor : -1; - $ref->{kmh_beeline} - = $kmh_divisor - ? $ref->{km_beeline} / $kmh_divisor - : -1; - } - - push( @travels, $ref ); + )->wait; } - - return @travels; } ); $self->helper( - 'get_journey' => sub { - my ( $self, %opt ) = @_; - - $opt{cancelled} = 'any'; - my @journeys = $self->get_user_travels(%opt); - if ( @journeys == 0 ) { - return undef; - } - - return $journeys[0]; + 'resolve_sb_template' => sub { + my ( $self, $template, %opt ) = @_; + my $ret = $template; + my $name = $opt{name} =~ s{/}{%2F}gr; + $ret =~ s{[{]eva[}]}{$opt{eva}}g; + $ret =~ s{[{]name[}]}{$name}g; + $ret =~ s{[{]tt[}]}{$opt{tt}}g; + $ret =~ s{[{]tn[}]}{$opt{tn}}g; + $ret =~ s{[{]id[}]}{$opt{id}}g; + return $ret; } ); @@ -2559,21 +1561,17 @@ sub startup { for my $station ( @{ $journey->{route_after} } ) { my $station_desc = $station->[0]; - if ( $station->[1]{rt_arr} ) { - $station_desc .= $station->[1]{sched_arr}->strftime(';%s'); - $station_desc .= $station->[1]{rt_arr}->strftime(';%s'); - if ( $station->[1]{rt_dep} ) { - $station_desc - .= $station->[1]{sched_dep}->strftime(';%s'); - $station_desc .= $station->[1]{rt_dep}->strftime(';%s'); - } - else { - $station_desc .= ';0;0'; - } - } - else { - $station_desc .= ';0;0;0;0'; - } + + my $sa = $station->[2]{sched_arr}; + my $ra = $station->[2]{rt_arr} || $station->[2]{sched_arr}; + my $sd = $station->[2]{sched_dep}; + my $rd = $station->[2]{rt_dep} || $station->[2]{sched_dep}; + + $station_desc .= $sa ? $sa->strftime(';%s') : ';0'; + $station_desc .= $ra ? $ra->strftime(';%s') : ';0'; + $station_desc .= $sd ? $sd->strftime(';%s') : ';0'; + $station_desc .= $rd ? $rd->strftime(';%s') : ';0'; + push( @route, $station_desc ); } @@ -2583,151 +1581,87 @@ 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; - my $in_transit - = $db->select( 'in_transit_str', '*', { user_id => $uid } ) - ->expand->hash; + my $in_transit = $self->in_transit->get( + uid => $uid, + db => $db, + with_data => 1, + with_timestamps => 1, + with_visibility => 1, + postprocess => 1, + ); if ($in_transit) { + my $ret = $in_transit; - my @route = @{ $in_transit->{route} // [] }; - my @route_after; - my $dep_info; - my $stop_before_dest; - my $is_after = 0; - for my $station (@route) { - - if ( $in_transit->{arr_name} - and @route_after - and $station->[0] eq $in_transit->{arr_name} ) + my $traewelling = $self->traewelling->get( + uid => $uid, + db => $db + ); + if ( $traewelling->{latest_run} + >= epoch_to_dt( $in_transit->{checkin_ts} ) ) + { + $ret->{traewelling} = $traewelling; + if ( @{ $traewelling->{data}{log} // [] } + and ( my $log_entry = $traewelling->{data}{log}[0] ) ) { - $stop_before_dest = $route_after[-1][0]; - } - if ($is_after) { - push( @route_after, $station ); - } - if ( $station->[0] eq $in_transit->{dep_name} ) { - $is_after = 1; - $self->app->log->debug("is_after"); - if ( @{$station} > 1 ) { - $self->app->log->debug("set dep_info"); - $dep_info = $station->[1]; + if ( $log_entry->[2] ) { + $ret->{traewelling_status} = $log_entry->[2]; + $ret->{traewelling_url} + = 'https://traewelling.de/status/' + . $log_entry->[2]; } + $ret->{traewelling_log_latest} = $log_entry->[1]; } } - my $stop_after_dep = $route_after[0][0]; - - my $ts = $in_transit->{checkout_ts} - // $in_transit->{checkin_ts}; - my $action_time = epoch_to_dt($ts); - - my $ret = { - checked_in => !$in_transit->{cancelled}, - cancelled => $in_transit->{cancelled}, - timestamp => $action_time, - timestamp_delta => $now->epoch - $action_time->epoch, - train_type => $in_transit->{train_type}, - train_line => $in_transit->{train_line}, - train_no => $in_transit->{train_no}, - train_id => $in_transit->{train_id}, - boarding_countdown => -1, - sched_departure => - epoch_to_dt( $in_transit->{sched_dep_ts} ), - real_departure => epoch_to_dt( $in_transit->{real_dep_ts} ), - dep_ds100 => $in_transit->{dep_ds100}, - dep_name => $in_transit->{dep_name}, - dep_platform => $in_transit->{dep_platform}, - sched_arrival => epoch_to_dt( $in_transit->{sched_arr_ts} ), - real_arrival => epoch_to_dt( $in_transit->{real_arr_ts} ), - arr_ds100 => $in_transit->{arr_ds100}, - arr_name => $in_transit->{arr_name}, - arr_platform => $in_transit->{arr_platform}, - route_after => \@route_after, - messages => $in_transit->{messages}, - extra_data => $in_transit->{data}, - }; - my @parsed_messages; - for my $message ( @{ $ret->{messages} // [] } ) { - my ( $ts, $msg ) = @{$message}; - push( @parsed_messages, [ epoch_to_dt($ts), $msg ] ); - } - $ret->{messages} = [ reverse @parsed_messages ]; - - @parsed_messages = (); - for my $message ( @{ $ret->{extra_data}{qos_msg} // [] } ) { - my ( $ts, $msg ) = @{$message}; - push( @parsed_messages, [ epoch_to_dt($ts), $msg ] ); - } - $ret->{extra_data}{qos_msg} = [@parsed_messages]; - - if ( $dep_info and $dep_info->{sched_arr} ) { - $self->app->log->debug( - "dep_info has sched_arr. set countdown."); - $dep_info->{sched_arr} - = epoch_to_dt( $dep_info->{sched_arr} ); - $dep_info->{rt_arr} = $dep_info->{sched_arr}->clone; - if ( $dep_info->{adelay} - and $dep_info->{adelay} =~ m{^\d+$} ) + my $stop_after_dep + = scalar @{ $ret->{route_after} } + ? $ret->{route_after}[0][0] + : undef; + my $stop_before_dest; + for my $i ( 1 .. $#{ $ret->{route_after} } ) { + if ( $ret->{arr_name} + and $ret->{route_after}[$i][0] eq $ret->{arr_name} ) { - $dep_info->{rt_arr} - ->add( minutes => $dep_info->{adelay} ); + $stop_before_dest = $ret->{route_after}[ $i - 1 ][0]; + last; } - $dep_info->{rt_arr_countdown} = $ret->{boarding_countdown} - = $dep_info->{rt_arr}->epoch - $epoch; } - for my $station (@route_after) { - if ( @{$station} > 1 ) { - - # Note: $station->[1]{sched_arr} may already have been - # converted to a DateTime object in $station->[1] is - # $dep_info. This can happen when a station is present - # several times in a train's route, e.g. for Frankfurt - # Flughafen in some nightly connections. - my $times = $station->[1]; - if ( $times->{sched_arr} - and ref( $times->{sched_arr} ) ne 'DateTime' ) - { - $times->{sched_arr} - = epoch_to_dt( $times->{sched_arr} ); - $times->{rt_arr} = $times->{sched_arr}->clone; - if ( $times->{adelay} - and $times->{adelay} =~ m{^\d+$} ) - { - $times->{rt_arr} - ->add( minutes => $times->{adelay} ); - } - $times->{rt_arr_countdown} - = $times->{rt_arr}->epoch - $epoch; - } - if ( $times->{sched_dep} - and ref( $times->{sched_dep} ) ne 'DateTime' ) - { - $times->{sched_dep} - = epoch_to_dt( $times->{sched_dep} ); - $times->{rt_dep} = $times->{sched_dep}->clone; - if ( $times->{ddelay} - and $times->{ddelay} =~ m{^\d+$} ) - { - $times->{rt_dep} - ->add( minutes => $times->{ddelay} ); - } - $times->{rt_dep_countdown} - = $times->{rt_dep}->epoch - $epoch; - } - } + my ($dep_platform_number) + = ( ( $ret->{dep_platform} // 0 ) =~ m{(\d+)} ); + if ( $dep_platform_number + and + exists $ret->{data}{stationinfo_dep}{$dep_platform_number} ) + { + $ret->{dep_direction} = $self->stationinfo_to_direction( + $ret->{data}{stationinfo_dep}{$dep_platform_number}, + $ret->{data}{wagonorder_dep}, + undef, $stop_after_dep + ); } - $ret->{departure_countdown} - = $ret->{real_departure}->epoch - $now->epoch; + my ($arr_platform_number) + = ( ( $ret->{arr_platform} // 0 ) =~ m{(\d+)} ); + if ( $arr_platform_number + and + exists $ret->{data}{stationinfo_arr}{$arr_platform_number} ) + { + $ret->{arr_direction} = $self->stationinfo_to_direction( + $ret->{data}{stationinfo_arr}{$arr_platform_number}, + $ret->{data}{wagonorder_arr}, + $stop_before_dest, + undef + ); + } if ( $ret->{departure_countdown} > 0 and $in_transit->{data}{wagonorder_dep} ) @@ -2739,7 +1673,6 @@ sub startup { from_json => $in_transit->{data}{wagonorder_dep} ); }; if ( $wr - and $wr->sections and $wr->wagons and defined $wr->direction ) { @@ -2747,85 +1680,57 @@ sub startup { } } - if ( $in_transit->{real_arr_ts} ) { - $ret->{arrival_countdown} - = $ret->{real_arrival}->epoch - $now->epoch; - $ret->{journey_duration} - = $ret->{real_arrival}->epoch - - $ret->{real_departure}->epoch; - $ret->{journey_completion} - = $ret->{journey_duration} - ? 1 - - ( $ret->{arrival_countdown} / $ret->{journey_duration} ) - : 1; - if ( $ret->{journey_completion} > 1 ) { - $ret->{journey_completion} = 1; - } - elsif ( $ret->{journey_completion} < 0 ) { - $ret->{journey_completion} = 0; - } - - my ($dep_platform_number) - = ( ( $ret->{dep_platform} // 0 ) =~ m{(\d+)} ); - if ( $dep_platform_number - and exists $in_transit->{data}{stationinfo_dep} - {$dep_platform_number} ) - { - $ret->{dep_direction} - = $self->stationinfo_to_direction( - $in_transit->{data}{stationinfo_dep} - {$dep_platform_number}, - $in_transit->{data}{wagonorder_dep}, - undef, - $stop_after_dep - ); - } - - my ($arr_platform_number) - = ( ( $ret->{arr_platform} // 0 ) =~ m{(\d+)} ); - if ( $arr_platform_number - and exists $in_transit->{data}{stationinfo_arr} - {$arr_platform_number} ) - { - $ret->{arr_direction} - = $self->stationinfo_to_direction( - $in_transit->{data}{stationinfo_arr} - {$arr_platform_number}, - $in_transit->{data}{wagonorder_arr}, - $stop_before_dest, - undef - ); - } - - } - else { - $ret->{arrival_countdown} = undef; - $ret->{journey_duration} = undef; - $ret->{journey_completion} = undef; - } - return $ret; } - my $latest = $db->select( - 'journeys_str', - '*', + my ( $latest, $latest_cancellation ) = $self->journeys->get_latest( + uid => $uid, + db => $db, + ); + + if ( $latest_cancellation and $latest_cancellation->{cancelled} ) { + if ( + my $station = $self->stations->get_by_eva( + $latest_cancellation->{dep_eva} + ) + ) { - user_id => $uid, - cancelled => 0 - }, + $latest_cancellation->{dep_ds100} = $station->{ds100}; + $latest_cancellation->{dep_name} = $station->{name}; + } + if ( + my $station = $self->stations->get_by_eva( + $latest_cancellation->{arr_eva} + ) + ) { - order_by => { -desc => 'journey_id' }, - limit => 1 + $latest_cancellation->{arr_ds100} = $station->{ds100}; + $latest_cancellation->{arr_name} = $station->{name}; } - )->hash; + } + else { + $latest_cancellation = undef; + } if ($latest) { my $ts = $latest->{checkout_ts}; my $action_time = epoch_to_dt($ts); + if ( my $station + = $self->stations->get_by_eva( $latest->{dep_eva} ) ) + { + $latest->{dep_ds100} = $station->{ds100}; + $latest->{dep_name} = $station->{name}; + } + if ( my $station + = $self->stations->get_by_eva( $latest->{arr_eva} ) ) + { + $latest->{arr_ds100} = $station->{ds100}; + $latest->{arr_name} = $station->{name}; + } return { checked_in => 0, cancelled => 0, + cancellation => $latest_cancellation, journey_id => $latest->{journey_id}, timestamp => $action_time, timestamp_delta => $now->epoch - $action_time->epoch, @@ -2836,19 +1741,32 @@ sub startup { sched_departure => epoch_to_dt( $latest->{sched_dep_ts} ), real_departure => epoch_to_dt( $latest->{real_dep_ts} ), dep_ds100 => $latest->{dep_ds100}, + dep_eva => $latest->{dep_eva}, dep_name => $latest->{dep_name}, + dep_lat => $latest->{dep_lat}, + dep_lon => $latest->{dep_lon}, dep_platform => $latest->{dep_platform}, sched_arrival => epoch_to_dt( $latest->{sched_arr_ts} ), real_arrival => epoch_to_dt( $latest->{real_arr_ts} ), arr_ds100 => $latest->{arr_ds100}, + arr_eva => $latest->{arr_eva}, arr_name => $latest->{arr_name}, + arr_lat => $latest->{arr_lat}, + arr_lon => $latest->{arr_lon}, arr_platform => $latest->{arr_platform}, + comment => $latest->{user_data}{comment}, + visibility => $latest->{visibility}, + visibility_str => $latest->{visibility_str}, + effective_visibility => $latest->{effective_visibility}, + effective_visibility_str => + $latest->{effective_visibility_str}, }; } return { checked_in => 0, cancelled => 0, + cancellation => $latest_cancellation, no_journeys_yet => 1, timestamp => epoch_to_dt(0), timestamp_delta => $now->epoch, @@ -2858,8 +1776,11 @@ sub startup { $self->helper( 'get_user_status_json_v1' => sub { - my ( $self, $uid ) = @_; - my $status = $self->get_user_status($uid); + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $privacy = $opt{privacy} + // $self->users->get_privacy_by( uid => $uid ); + my $status = $opt{status} // $self->get_user_status($uid); my $ret = { deprecated => \0, @@ -2867,59 +1788,82 @@ sub startup { $status->{checked_in} or $status->{cancelled} ) ? \1 : \0, + comment => $status->{comment}, fromStation => { ds100 => $status->{dep_ds100}, name => $status->{dep_name}, - uic => undef, - longitude => undef, - latitude => undef, - scheduledTime => $status->{sched_departure}->epoch || undef, - realTime => $status->{real_departure}->epoch || undef, + uic => $status->{dep_eva}, + longitude => $status->{dep_lon}, + latitude => $status->{dep_lat}, + scheduledTime => $status->{sched_departure} + ? $status->{sched_departure}->epoch + : undef, + realTime => $status->{real_departure} + ? $status->{real_departure}->epoch + : undef, }, toStation => { ds100 => $status->{arr_ds100}, name => $status->{arr_name}, - uic => undef, - longitude => undef, - latitude => undef, - scheduledTime => $status->{sched_arrival}->epoch || undef, - realTime => $status->{real_arrival}->epoch || undef, + uic => $status->{arr_eva}, + longitude => $status->{arr_lon}, + latitude => $status->{arr_lat}, + scheduledTime => $status->{sched_arrival} + ? $status->{sched_arrival}->epoch + : undef, + realTime => $status->{real_arrival} + ? $status->{real_arrival}->epoch + : undef, }, train => { - type => $status->{train_type}, - line => $status->{train_line}, - no => $status->{train_no}, - id => $status->{train_id}, + type => $status->{train_type}, + line => $status->{train_line}, + no => $status->{train_no}, + id => $status->{train_id}, + hafasId => $status->{extra_data}{trip_id}, }, - actionTime => $status->{timestamp}->epoch, + intermediateStops => [], + visibility => { + level => $status->{effective_visibility}, + desc => $status->{effective_visibility_str}, + } }; - if ( $status->{dep_ds100} ) { - my @station_descriptions - = Travel::Status::DE::IRIS::Stations::get_station( - $status->{dep_ds100} ); - if ( @station_descriptions == 1 ) { - ( - undef, undef, - $ret->{fromStation}{uic}, - $ret->{fromStation}{longitude}, - $ret->{fromStation}{latitude} - ) = @{ $station_descriptions[0] }; + if ( $opt{public} ) { + if ( not $privacy->{comments_visible} ) { + delete $ret->{comment}; } } + else { + $ret->{actionTime} + = $status->{timestamp} + ? $status->{timestamp}->epoch + : undef; + } - if ( $status->{arr_ds100} ) { - my @station_descriptions - = Travel::Status::DE::IRIS::Stations::get_station( - $status->{arr_ds100} ); - if ( @station_descriptions == 1 ) { - ( - undef, undef, - $ret->{toStation}{uic}, - $ret->{toStation}{longitude}, - $ret->{toStation}{latitude} - ) = @{ $station_descriptions[0] }; + for my $stop ( @{ $status->{route_after} // [] } ) { + if ( $status->{arr_name} and $stop->[0] eq $status->{arr_name} ) + { + last; } + push( + @{ $ret->{intermediateStops} }, + { + name => $stop->[0], + scheduledArrival => $stop->[2]{sched_arr} + ? $stop->[2]{sched_arr}->epoch + : undef, + realArrival => $stop->[2]{rt_arr} + ? $stop->[2]{rt_arr}->epoch + : undef, + scheduledDeparture => $stop->[2]{sched_dep} + ? $stop->[2]{sched_dep}->epoch + : undef, + realDeparture => $stop->[2]{rt_dep} + ? $stop->[2]{rt_dep}->epoch + : undef, + } + ); } return $ret; @@ -2927,140 +1871,464 @@ sub startup { ); $self->helper( - 'get_travel_distance' => sub { - my ( $self, $from, $to, $route_ref ) = @_; - - my $distance_intermediate = 0; - my $distance_beeline = 0; - my $skipped = 0; - my $geo = Geo::Distance->new(); - my @stations = map { $_->[0] } @{$route_ref}; - my @route = after_incl { $_ eq $from } @stations; - @route = before_incl { $_ eq $to } @route; + 'traewelling_to_travelynx_p' => sub { + my ( $self, %opt ) = @_; + my $traewelling = $opt{traewelling}; + my $user_data = $opt{user_data}; + my $uid = $user_data->{user_id}; - if ( @route < 2 ) { + my $promise = Mojo::Promise->new; - # I AM ERROR - return ( 0, 0 ); + if ( not $traewelling->{checkin} + or $self->now->epoch - $traewelling->{checkin}->epoch > 900 ) + { + $self->log->debug("... not checked in"); + return $promise->resolve; } - - my $prev_station = get_station( shift @route ); - if ( not $prev_station ) { - return ( 0, 0 ); + 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 $promise->resolve; + } + $self->log->debug( +"... checked in : $traewelling->{dep_name} $traewelling->{dep_eva} -> $traewelling->{arr_name} $traewelling->{arr_eva}" + ); + my $user_status = $self->get_user_status($uid); + if ( $user_status->{checked_in} ) { + $self->log->debug( + "... also checked in via travelynx. aborting."); + return $promise->resolve; } - # Geo-coordinates for stations outside Germany are not available - # at the moment. When calculating distance with intermediate stops, - # these are simply left out (as if they were not part of the route). - # For beeline distance calculation, we use the route's first and last - # station with known geo-coordinates. - my $from_station_beeline; - my $to_station_beeline; - - for my $station_name (@route) { - if ( my $station = get_station($station_name) ) { - if ( not $from_station_beeline and $#{$prev_station} >= 4 ) - { - $from_station_beeline = $prev_station; - } - if ( $#{$station} >= 4 ) { - $to_station_beeline = $station; + if ( $traewelling->{category} + !~ m{^ (?: national .* | regional .* | suburban ) $ }x ) + { + + my $db = $self->pg->db; + my $tx = $db->begin; + + $self->checkin_p( + station => $traewelling->{dep_eva}, + train_id => $traewelling->{trip_id}, + uid => $uid, + in_transaction => 1, + db => $db + )->then( + sub { + $self->log->debug("... handled origin"); + return $self->checkout_p( + station => $traewelling->{arr_eva}, + train_id => $traewelling->{trip_id}, + uid => $uid, + in_transaction => 1, + db => $db + ); } - if ( $#{$prev_station} >= 4 and $#{$station} >= 4 ) { - $distance_intermediate - += $geo->distance( 'kilometer', $prev_station->[3], - $prev_station->[4], $station->[3], $station->[4] ); + )->then( + sub { + my ( undef, $err ) = @_; + if ($err) { + $self->log->debug("... error: $err"); + return Mojo::Promise->reject($err); + } + $self->log->debug("... handled destination"); + if ( $traewelling->{message} ) { + $self->in_transit->update_user_data( + uid => $uid, + db => $db, + user_data => + { comment => $traewelling->{message} } + ); + } + $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; + $promise->resolve; + return; } - else { - $skipped++; + )->catch( + sub { + my ($err) = @_; + $self->log->debug("... error: $err"); + $self->traewelling->log( + uid => $uid, + message => +"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $err", + status_id => $traewelling->{status_id}, + is_error => 1 + ); + $promise->resolve; + return; } - $prev_station = $station; - } + )->wait; + return $promise; } - if ( $from_station_beeline and $to_station_beeline ) { - $distance_beeline = $geo->distance( - 'kilometer', $from_station_beeline->[3], - $from_station_beeline->[4], $to_station_beeline->[3], - $to_station_beeline->[4] - ); - } + $self->iris->get_departures_p( + station => $traewelling->{dep_eva}, + lookbehind => 60, + lookahead => 40 + )->then( + sub { + my ($dep) = @_; + my ( $train_ref, $train_id ); + + if ( $dep->{errstr} ) { + $self->traewelling->log( + uid => $uid, + message => +"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $dep->{errstr}", + status_id => $traewelling->{status_id}, + is_error => 1, + ); + $promise->resolve; + return; + } + + 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; + } - return ( $distance_intermediate, $distance_beeline, $skipped ); + if ( not $train_id ) { + $self->log->debug( + "... train $traewelling->{line} not found"); + $self->traewelling->log( + uid => $uid, + message => +"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: Zug nicht gefunden", + status_id => $traewelling->{status_id}, + is_error => 1 + ); + return $promise->resolve; + } + + $self->log->debug("... found train: $train_id"); + + my $db = $self->pg->db; + my $tx = $db->begin; + + $self->checkin_p( + station => $traewelling->{dep_eva}, + train_id => $train_id, + uid => $uid, + in_transaction => 1, + db => $db + )->then( + sub { + $self->log->debug("... handled origin"); + return $self->checkout_p( + station => $traewelling->{arr_eva}, + train_id => 0, + uid => $uid, + in_transaction => 1, + db => $db + ); + } + )->then( + sub { + my ( undef, $err ) = @_; + if ($err) { + $self->log->debug("... error: $err"); + return Mojo::Promise->reject($err); + } + $self->log->debug("... handled destination"); + if ( $traewelling->{message} ) { + $self->in_transit->update_user_data( + uid => $uid, + db => $db, + user_data => + { comment => $traewelling->{message} } + ); + } + $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; + $promise->resolve; + return; + } + )->catch( + sub { + my ($err) = @_; + $self->log->debug("... error: $err"); + $self->traewelling->log( + uid => $uid, + message => +"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $err", + status_id => $traewelling->{status_id}, + is_error => 1 + ); + $promise->resolve; + return; + } + )->wait; + } + )->catch( + sub { + my ( $err, $dep ) = @_; + $self->traewelling->log( + uid => $uid, + message => +"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $dep->{errstr}", + status_id => $traewelling->{status_id}, + is_error => 1, + ); + $promise->resolve; + return; + } + )->wait; + + return $promise; } ); $self->helper( - 'compute_journey_stats' => sub { - my ( $self, @journeys ) = @_; - my $km_route = 0; - my $km_beeline = 0; - my $min_travel_sched = 0; - my $min_travel_real = 0; - my $delay_dep = 0; - my $delay_arr = 0; - my $interchange_real = 0; - my $num_trains = 0; - my $num_journeys = 0; - my @inconsistencies; - - my $next_departure = 0; - - for my $journey (@journeys) { - $num_trains++; - $km_route += $journey->{km_route}; - $km_beeline += $journey->{km_beeline}; - if ( $journey->{sched_duration} - and $journey->{sched_duration} > 0 ) + 'journeys_to_map_data' => sub { + my ( $self, %opt ) = @_; + + my @journeys = @{ $opt{journeys} // [] }; + my $route_type = $opt{route_type} // 'polybee'; + my $include_manual = $opt{include_manual} ? 1 : 0; + + my $location = $self->app->coordinates_by_station; + + my $with_polyline = $route_type eq 'beeline' ? 0 : 1; + + if ( not @journeys ) { + return { + skipped_journeys => [], + station_coordinates => [], + polyline_groups => [], + }; + } + + my $json = JSON->new->utf8; + + my $first_departure = $journeys[-1]->{rt_departure}; + my $last_departure = $journeys[0]->{rt_departure}; + + my @stations = List::Util::uniq map { $_->{to_name} } @journeys; + push( @stations, + List::Util::uniq map { $_->{from_name} } @journeys ); + @stations = List::Util::uniq @stations; + my @station_coordinates = map { [ $location->{$_}, $_ ] } + grep { exists $location->{$_} } @stations; + + my @station_pairs; + my @polylines; + my %seen; + + my @skipped_journeys; + my @polyline_journeys = grep { $_->{polyline} } @journeys; + my @beeline_journeys = grep { not $_->{polyline} } @journeys; + + if ( $route_type eq 'polyline' ) { + @beeline_journeys = (); + } + elsif ( $route_type eq 'beeline' ) { + push( @beeline_journeys, @polyline_journeys ); + @polyline_journeys = (); + } + + for my $journey (@polyline_journeys) { + my @polyline = @{ $journey->{polyline} }; + my $from_eva = $journey->{from_eva}; + my $to_eva = $journey->{to_eva}; + + my $from_index + = first_index { $_->[2] and $_->[2] == $from_eva } @polyline; + my $to_index + = first_index { $_->[2] and $_->[2] == $to_eva } @polyline; + + if ( $from_index == -1 + or $to_index == -1 ) { - $min_travel_sched += $journey->{sched_duration} / 60; - } - if ( $journey->{rt_duration} and $journey->{rt_duration} > 0 ) { - $min_travel_real += $journey->{rt_duration} / 60; + # Fall back to route + delete $journey->{polyline}; + next; } - if ( $journey->{sched_dep_ts} and $journey->{rt_dep_ts} ) { - $delay_dep - += ( $journey->{rt_dep_ts} - $journey->{sched_dep_ts} ) - / 60; + + my $key + = $from_eva . '!' + . $to_eva . '!' + . ( $to_index - $from_index ); + + if ( $seen{$key} ) { + next; } - if ( $journey->{sched_arr_ts} and $journey->{rt_arr_ts} ) { - $delay_arr - += ( $journey->{rt_arr_ts} - $journey->{sched_arr_ts} ) - / 60; + + $seen{$key} = 1; + + # direction does not matter at the moment + $key + = $to_eva . '!' + . $from_eva . '!' + . ( $to_index - $from_index ); + $seen{$key} = 1; + + @polyline = @polyline[ $from_index .. $to_index ]; + my @polyline_coords; + for my $coord (@polyline) { + push( @polyline_coords, [ $coord->[1], $coord->[0] ] ); } + push( @polylines, [@polyline_coords] ); + } - # Note that journeys are sorted from recent to older entries - if ( $journey->{rt_arr_ts} - and $next_departure - and $next_departure - $journey->{rt_arr_ts} < ( 60 * 60 ) ) - { - if ( $next_departure - $journey->{rt_arr_ts} < 0 ) { - push( @inconsistencies, - epoch_to_dt($next_departure) - ->strftime('%d.%m.%Y %H:%M') ); + for my $journey (@beeline_journeys) { + + my @route = map { $_->[0] } @{ $journey->{route} }; + + my $from_index + = first_index { $_ eq $journey->{from_name} } @route; + my $to_index = first_index { $_ eq $journey->{to_name} } @route; + + if ( $from_index == -1 ) { + my $rename = $self->app->renamed_station; + $from_index = first_index { + ( $rename->{$_} // $_ ) eq $journey->{from_name} } - else { - $interchange_real - += ( $next_departure - $journey->{rt_arr_ts} ) / 60; + @route; + } + if ( $to_index == -1 ) { + my $rename = $self->app->renamed_station; + $to_index = first_index { + ( $rename->{$_} // $_ ) eq $journey->{to_name} } + @route; + } + + if ( $from_index == -1 + or $to_index == -1 ) + { + push( @skipped_journeys, + [ $journey, 'Start/Ziel nicht in Route gefunden' ] ); + next; } - else { - $num_journeys++; + + # Manual journey entries are only included if one of the following + # conditions is satisfied: + # * their route has more than two elements (-> probably more than just + # start and stop station), or + # * $include_manual is true (-> user wants to see incomplete routes) + # This avoids messing up the map in case an A -> B connection has been + # tracked both with a regular checkin (-> detailed route shown on map) + # and entered manually (-> beeline also shown on map, typically + # significantly differs from detailed route) -- unless the user + # sets include_manual, of course. + if ( $journey->{edited} & 0x0010 + and @route <= 2 + and not $include_manual ) + { + push( @skipped_journeys, + [ $journey, 'Manueller Eintrag ohne Unterwegshalte' ] ); + next; + } + + @route = @route[ $from_index .. $to_index ]; + + my $key = join( '|', @route ); + + if ( $seen{$key} ) { + next; + } + + $seen{$key} = 1; + + # direction does not matter at the moment + $seen{ join( '|', reverse @route ) } = 1; + + my $prev_station = shift @route; + for my $station (@route) { + push( @station_pairs, [ $prev_station, $station ] ); + $prev_station = $station; } - $next_departure = $journey->{rt_dep_ts}; } - return { - km_route => $km_route, - km_beeline => $km_beeline, - num_trains => $num_trains, - num_journeys => $num_journeys, - min_travel_sched => $min_travel_sched, - min_travel_real => $min_travel_real, - min_interchange_real => $interchange_real, - delay_dep => $delay_dep, - delay_arr => $delay_arr, - inconsistencies => \@inconsistencies, + + @station_pairs = uniq_by { $_->[0] . '|' . $_->[1] } @station_pairs; + @station_pairs = grep { + exists $location->{ $_->[0] } + and exists $location->{ $_->[1] } + } @station_pairs; + @station_pairs + = map { [ $location->{ $_->[0] }, $location->{ $_->[1] } ] } + @station_pairs; + + my $ret = { + skipped_journeys => \@skipped_journeys, + station_coordinates => \@station_coordinates, + polyline_groups => [ + { + polylines => $json->encode( \@station_pairs ), + color => '#673ab7', + opacity => @polylines + ? $with_polyline + ? 0.4 + : 0.6 + : 0.8, + }, + { + polylines => $json->encode( \@polylines ), + color => '#673ab7', + opacity => 0.8, + } + ], }; + + if (@station_coordinates) { + my @lats = map { $_->[0][0] } @station_coordinates; + my @lons = map { $_->[0][1] } @station_coordinates; + my $min_lat = List::Util::min @lats; + my $max_lat = List::Util::max @lats; + my $min_lon = List::Util::min @lons; + my $max_lon = List::Util::max @lons; + $ret->{bounds} + = [ [ $min_lat, $min_lon ], [ $max_lat, $max_lon ] ]; + } + + return $ret; } ); @@ -3083,67 +2351,108 @@ sub startup { $r->get('/changelog')->to('static#changelog'); $r->get('/impressum')->to('static#imprint'); $r->get('/imprint')->to('static#imprint'); - $r->get('/offline')->to('static#offline'); - $r->get('/api/v0/:user_action/:token')->to('api#get_v0'); + $r->get('/legend')->to('static#legend'); + $r->get('/offline.html')->to('static#offline'); $r->get('/api/v1/:user_action/:token')->to('api#get_v1'); $r->get('/login')->to('account#login_form'); $r->get('/recover')->to('account#request_password_reset'); $r->get('/recover/:id/:token')->to('account#recover_password'); - $r->get('/register')->to('account#registration_form'); $r->get('/reg/:id/:token')->to('account#verify'); - $r->get('/status/:name')->to('traveling#user_status'); - $r->get('/status/:name/:ts')->to('traveling#user_status'); - $r->get('/ajax/status/:name')->to('traveling#public_status_card'); - $r->get('/ajax/status/:name/:ts')->to('traveling#public_status_card'); - $r->post('/action')->to('traveling#log_action'); + $r->get( '/status/:name' => [ format => [ 'html', 'json' ] ] ) + ->to( 'profile#user_status', format => undef ); + $r->get( '/status/:name/:ts' => [ format => [ 'html', 'json' ] ] ) + ->to( 'profile#user_status', format => undef ); + $r->get('/ajax/status/#name')->to('profile#status_card'); + $r->get('/ajax/status/:name/:ts')->to('profile#status_card'); + $r->get('/p/:name')->to('profile#profile'); + $r->get( '/p/:name/j/:id' => 'public_journey' ) + ->to('profile#journey_details'); + $r->get('/.well-known/webfinger')->to('account#webfinger'); + $r->get('/dyn/:av/autocomplete.js')->to('api#autocomplete'); + $r->post('/api/v1/import')->to('api#import_v1'); + $r->post('/api/v1/travel')->to('api#travel_v1'); + $r->post('/action')->to('traveling#travel_action'); $r->post('/geolocation')->to('traveling#geolocation'); $r->post('/list_departures')->to('traveling#redirect_to_station'); $r->post('/login')->to('account#do_login'); - $r->post('/register')->to('account#register'); $r->post('/recover')->to('account#request_password_reset'); + if ( $self->config->{traewelling}{oauth} ) { + $r->get('/oauth/traewelling')->to('traewelling#oauth'); + $r->post('/oauth/traewelling')->to('traewelling#oauth'); + } + + if ( not $self->config->{registration}{disabled} ) { + $r->get('/register')->to('account#registration_form'); + $r->post('/register')->to('account#register'); + } + my $authed_r = $r->under( sub { my ($self) = @_; if ( $self->is_user_authenticated ) { return 1; } - $self->render( 'login', redirect_to => $self->req->url ); + $self->render( + 'login', + redirect_to => $self->req->url, + from => 'auth_required' + ); return undef; } ); $authed_r->get('/account')->to('account#account'); $authed_r->get('/account/privacy')->to('account#privacy'); + $authed_r->get('/account/social')->to('account#social'); + $authed_r->get('/account/social/:kind')->to('account#social_list'); + $authed_r->get('/account/profile')->to('account#profile'); $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('/account/services')->to('account#services'); $authed_r->get('/ajax/status_card.html')->to('traveling#status_card'); $authed_r->get('/cancelled')->to('traveling#cancelled'); $authed_r->get('/fgr')->to('passengerrights#list_candidates'); $authed_r->get('/account/password')->to('account#password_form'); $authed_r->get('/account/mail')->to('account#change_mail'); + $authed_r->get('/account/name')->to('account#change_name'); $authed_r->get('/export.json')->to('account#json_export'); $authed_r->get('/history.json')->to('traveling#json_history'); + $authed_r->get('/history.csv')->to('traveling#csv_history'); $authed_r->get('/history')->to('traveling#history'); + $authed_r->get('/history/commute')->to('traveling#commute'); $authed_r->get('/history/map')->to('traveling#map_history'); $authed_r->get('/history/:year')->to('traveling#yearly_history'); + $authed_r->get('/history/:year/review')->to('traveling#year_in_review'); $authed_r->get('/history/:year/:month')->to('traveling#monthly_history'); $authed_r->get('/journey/add')->to('traveling#add_journey_form'); + $authed_r->get('/journey/comment')->to('traveling#comment_form'); + $authed_r->get('/journey/visibility')->to('traveling#visibility_form'); $authed_r->get('/journey/:id')->to('traveling#journey_details'); $authed_r->get('/s/*station')->to('traveling#station'); $authed_r->get('/confirm_mail/:token')->to('account#confirm_mail'); $authed_r->post('/account/privacy')->to('account#privacy'); + $authed_r->post('/account/social')->to('account#social'); + $authed_r->post('/account/profile')->to('account#profile'); $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('/account/services')->to('account#services'); $authed_r->post('/journey/add')->to('traveling#add_journey_form'); + $authed_r->post('/journey/comment')->to('traveling#comment_form'); + $authed_r->post('/journey/visibility')->to('traveling#visibility_form'); $authed_r->post('/journey/edit')->to('traveling#edit_journey'); $authed_r->post('/journey/passenger_rights/*filename') ->to('passengerrights#generate'); $authed_r->post('/account/password')->to('account#change_password'); $authed_r->post('/account/mail')->to('account#change_mail'); + $authed_r->post('/account/name')->to('account#change_name'); + $authed_r->post('/social-action')->to('account#social_action'); $authed_r->post('/delete')->to('account#delete'); $authed_r->post('/logout')->to('account#do_logout'); $authed_r->post('/set_token')->to('api#set_token'); + $authed_r->get('/timeline/in-transit')->to('profile#checked_in'); } diff --git a/lib/Travelynx/Command/account.pm b/lib/Travelynx/Command/account.pm new file mode 100644 index 0000000..1d17400 --- /dev/null +++ b/lib/Travelynx/Command/account.pm @@ -0,0 +1,119 @@ +package Travelynx::Command::account; + +# Copyright (C) 2021 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later +use Mojo::Base 'Mojolicious::Command'; +use UUID::Tiny qw(:std); + +has description => 'Add or remove user accounts'; + +has usage => sub { shift->extract_usage }; + +sub add_user { + my ( $self, $name, $email ) = @_; + + my $db = $self->app->pg->db; + + if ( my $error = $self->app->users->is_name_invalid( name => $name ) ) { + say "Cannot add account '$name': $error"; + die; + } + + my $token = "tmp"; + my $password = substr( create_uuid_as_string(UUID_V4), 0, 18 ); + + my $tx = $db->begin; + my $user_id = $self->app->users->add( + db => $db, + name => $name, + email => $email, + token => $token, + password => $password, + ); + my $success = $self->app->users->verify_registration_token( + db => $db, + uid => $user_id, + token => $token, + in_transaction => 1, + ); + + if ($success) { + $tx->commit; + say "Added user $name ($email) with UID $user_id"; + say "Temporary password for login: $password"; + } +} + +sub delete_user { + my ( $self, $uid ) = @_; + + my $user_data = $self->app->users->get( uid => $uid ); + + if ( not $user_data ) { + say "UID $uid does not exist."; + return; + } + + $self->app->users->flag_deletion( uid => $uid ); + + say "User $user_data->{name} (UID $uid) has been flagged for deletion."; +} + +sub really_delete_user { + my ( $self, $uid, $name ) = @_; + + my $user_data = $self->app->users->get( uid => $uid ); + + if ( $user_data->{name} ne $name ) { + say + "User name $name does not match UID $uid. Account deletion aborted."; + return; + } + + my $count = $self->app->users->delete( uid => $uid ); + + printf( "Deleted %s -- %d tokens, %d monthly stats, %d journeys\n", + $name, $count->{tokens}, $count->{stats}, $count->{journeys} ); + + return; +} + +sub run { + my ( $self, $command, @args ) = @_; + + if ( $command eq 'add' ) { + $self->add_user(@args); + } + elsif ( $command eq 'delete' ) { + $self->delete_user(@args); + } + elsif ( $command eq 'DELETE' ) { + $self->really_delete_user(@args); + } + else { + $self->help; + } +} + +1; + +__END__ + +=head1 SYNOPSIS + + Usage: index.pl account add [name] [email] + + Adds user [name] with a temporary password, which is shown on stdout. + Users can change the password once logged in. + + Usage: index.pl account delete [uid] + + Request deletion of user [uid]. This has the same effect as using the + account deletion button. The user account and all corresponding data will + be deleted by a maintenance run after three days. + + Usage: index.pl account DELETE [uid] [name] + + Immediately delete user [uid]/[name] and all associated data. Deletion is + irrevocable. Deletion is only performed if [name] matches the name of [uid]. diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm index 4b23692..d13b2a7 100644 --- a/lib/Travelynx/Command/database.pm +++ b/lib/Travelynx/Command/database.pm @@ -1,20 +1,26 @@ package Travelynx::Command::database; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Command'; use DateTime; +use File::Slurp qw(read_file); +use JSON; +use Travel::Status::DE::IRIS::Stations; has description => 'Initialize or upgrade database layout'; has usage => sub { shift->extract_usage }; sub get_schema_version { - my ($db) = @_; + my ( $db, $key ) = @_; my $version; - eval { - $version - = $db->select( 'schema_version', ['version'] )->hash->{version}; - }; + $key //= 'version'; + + eval { $version = $db->select( 'schema_version', [$key] )->hash->{$key}; }; if ($@) { # If it failed, the version table does not exist -> run setup first. @@ -809,8 +815,1327 @@ my @migrations = ( } ); }, + + # v18 -> v19 + sub { + my ($db) = @_; + say +'Transitioning from travelynx station ID to EVA IDs, this may take a while ...'; + $db->query( + qq{ + alter table in_transit drop constraint in_transit_checkin_station_id_fkey; + alter table in_transit drop constraint in_transit_checkout_station_id_fkey; + alter table journeys drop constraint journeys_checkin_station_id_fkey; + alter table journeys drop constraint journeys_checkout_station_id_fkey; + } + ); + for my $journey ( $db->select( 'in_transit_str', '*' )->hashes->each ) { + my ($s_dep) + = Travel::Status::DE::IRIS::Stations::get_station( + $journey->{dep_ds100} ); + if ( $s_dep->[1] ne $journey->{dep_name} ) { + die( +"$s_dep->[0] name mismatch: $s_dep->[1] vs. $journey->{dep_name}" + ); + } + my $rows = $db->update( + 'in_transit', + { checkin_station_id => $s_dep->[2] }, + { user_id => $journey->{user_id} } + )->rows; + if ( $rows != 1 ) { + die( +"Update error at in_transit checkin_station_id UID $journey->{user_id}\n" + ); + } + if ( $journey->{arr_ds100} ) { + my ($s_arr) + = Travel::Status::DE::IRIS::Stations::get_station( + $journey->{arr_ds100} ); + if ( $s_arr->[1] ne $journey->{arr_name} ) { + die( +"$s_arr->[0] name mismatch: $s_arr->[1] vs. $journey->{arr_name}" + ); + } + my $rows = $db->update( + 'in_transit', + { checkout_station_id => $s_arr->[2] }, + { user_id => $journey->{user_id} } + )->rows; + if ( $rows != 1 ) { + die( +"Update error at in_transit checkout_station_id UID $journey->{user_id}\n" + ); + } + } + } + for my $journey ( $db->select( 'journeys_str', '*' )->hashes->each ) { + my ($s_dep) + = Travel::Status::DE::IRIS::Stations::get_station( + $journey->{dep_ds100} ); + my ($s_arr) + = Travel::Status::DE::IRIS::Stations::get_station( + $journey->{arr_ds100} ); + if ( $s_dep->[1] ne $journey->{dep_name} ) { + die( +"$s_dep->[0] name mismatch: $s_dep->[1] vs. $journey->{dep_name}" + ); + } + my $rows = $db->update( + 'journeys', + { checkin_station_id => $s_dep->[2] }, + { id => $journey->{journey_id} } + )->rows; + if ( $rows != 1 ) { + die( +"While updating journeys#checkin_station_id for journey $journey->{id}: got $rows rows, expected 1\n" + ); + } + if ( $s_arr->[1] ne $journey->{arr_name} ) { + die( +"$s_arr->[0] name mismatch: $s_arr->[1] vs. $journey->{arr_name}" + ); + } + $rows = $db->update( + 'journeys', + { checkout_station_id => $s_arr->[2] }, + { id => $journey->{journey_id} } + )->rows; + if ( $rows != 1 ) { + die( +"While updating journeys#checkout_station_id for journey $journey->{id}: got $rows rows, expected 1\n" + ); + } + } + $db->query( + qq{ + drop view journeys_str; + drop view in_transit_str; + create view journeys_str as select + journeys.id as journey_id, user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + cancelled, edited, route, messages, user_data, + dep_platform, arr_platform + from journeys + ; + create or replace view in_transit_str as select + user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + ; + drop table stations; + update schema_version set version = 19; + } + ); + }, + + # v19 -> v20 + sub { + my ($db) = @_; + $db->query( + qq{ + create table polylines ( + id serial not null primary key, + origin_eva integer not null, + destination_eva integer not null, + polyline jsonb not null + ); + alter table journeys + add column polyline_id integer references polylines (id); + alter table in_transit + add column polyline_id integer references polylines (id); + drop view journeys_str; + drop view in_transit_str; + create view journeys_str as select + journeys.id as journey_id, user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + polylines.polyline as polyline, + cancelled, edited, route, messages, user_data, + dep_platform, arr_platform + from journeys + left join polylines on polylines.id = polyline_id + ; + create or replace view in_transit_str as select + user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + polylines.polyline as polyline, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + ; + update schema_version set version = 20; + } + ); + }, + + # v20 -> v21 + # After introducing polyline support, journey distance calculation diverged: + # the detail view (individual train) used the polyline, whereas monthly and + # yearly statistics were still based on beeline between intermediate stops. + # Release 1.16.0 fixes this -> ensure all caches are rebuilt. + sub { + my ($db) = @_; + $db->query( + qq{ + truncate journey_stats; + update schema_version set version = 21; + } + ); + }, + + # 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; + } + ); + }, + + # v22 -> v23 + # 1.18.1 fixes handling of negative cumulative arrival/departure delays + # and introduces additional statistics entries with pre-formatted duration + # strings while at it. Old cache entries lack those. + sub { + my ($db) = @_; + $db->query( + qq{ + truncate journey_stats; + update schema_version set version = 23; + } + ); + }, + + # v23 -> v24 + # travelynx 1.22 warns about upcoming account deletion due to inactivity + sub { + my ($db) = @_; + $db->query( + qq{ + alter table users add column deletion_notified timestamptz; + comment on column users.deletion_notified is 'Time at which warning about upcoming account deletion due to inactivity was sent'; + update schema_version set version = 24; + } + ); + }, + + # v24 -> v25 + # travelynx 1.23 adds optional links to external services, e.g. + # DBF or bahn.expert departure boards + sub { + my ($db) = @_; + $db->query( + qq{ + alter table users add column external_services smallint; + comment on column users.external_services is 'Which external service to use for stationboard or routing links'; + update schema_version set version = 25; + } + ); + }, + + # v25 -> v26 + # travelynx 1.24 adds local transit connections and needs to know targets + # for that to work, as local transit does not support checkins yet. + sub { + my ($db) = @_; + $db->query( + qq{ + create table localtransit ( + user_id integer not null references users (id) primary key, + data jsonb + ); + create view user_transit as select + id, + use_history, + localtransit.data as data + from users + left join localtransit on localtransit.user_id = id + ; + update schema_version set version = 26; + } + ); + }, + + # v26 -> v27 + # add list of stations that are not (or no longer) present in T-S-DE-IRIS + # (in this case, stations that were removed up to 1.74) + sub { + my ($db) = @_; + $db->query( + qq{ + alter table schema_version + add column iris varchar(12); + create table stations ( + eva int not null primary key, + ds100 varchar(16) not null, + name varchar(64) not null, + lat real not null, + lon real not null, + source smallint not null, + archived bool not null + ); + update schema_version set version = 27; + update schema_version set iris = '0'; + } + ); + }, + + # v27 -> v28 + # add ds100, name, and lat/lon from stations table to journeys_str / in_transit_str + sub { + my ($db) = @_; + $db->query( + qq{ + drop view journeys_str; + drop view in_transit_str; + create view journeys_str as select + journeys.id as journey_id, user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polylines.polyline as polyline, + cancelled, edited, route, messages, user_data, + dep_platform, arr_platform + from journeys + left join polylines on polylines.id = polyline_id + left join stations as dep_station on checkin_station_id = dep_station.eva + left join stations as arr_station on checkout_station_id = arr_station.eva + ; + create view in_transit_str as select + user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polylines.polyline as polyline, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join stations as dep_station on checkin_station_id = dep_station.eva + left join stations as arr_station on checkout_station_id = arr_station.eva + ; + update schema_version set version = 28; + } + ); + }, + + # v28 -> v29 + # add pre-migration travelynx version. This way, a failed migration can + # print a helpful "git checkout" command. + sub { + my ($db) = @_; + $db->query( + qq{ + alter table schema_version + add column travelynx varchar(64); + update schema_version set version = 29; + } + ); + }, + + # v29 -> v30 + # change layout of stops in in_transit and journeys "route" lists. + # Old layout: A mixture of [name, {data}, undef/"additional"/"cancelled"], [name, timestamp, timestamp], and [name] + # New layout: [name, eva, {data including isAdditional/isCancelled}] + # Combined with a maintenance task that adds eva IDs to past stops, this will allow for more resilience against station name changes. + # It will also help increase the performance of distance and map calculation + sub { + my ($db) = @_; + my $json = JSON->new; + + say 'Adjusting route schema, this may take a while ...'; + + my $res = $db->select( 'in_transit_str', [ 'route', 'user_id' ] ); + while ( my $row = $res->expand->hash ) { + my @new_route; + for my $stop ( @{ $row->{route} } ) { + push( @new_route, [ $stop->[0], undef, {} ] ); + } + $db->update( + 'in_transit', + { route => $json->encode( \@new_route ) }, + { user_id => $row->{user_id} } + ); + } + + my $total + = $db->select( 'journeys', 'count(*) as count' )->hash->{count}; + my $count = 0; + + $res = $db->select( 'journeys_str', [ 'route', 'journey_id' ] ); + while ( my $row = $res->expand->hash ) { + my @new_route; + + for my $stop ( @{ $row->{route} } ) { + if ( @{$stop} == 1 ) { + push( @new_route, [ $stop->[0], undef, {} ] ); + } + elsif ( + ( not defined $stop->[1] or $stop->[1] =~ m{ ^ \d+ $ }x ) + and + ( not defined $stop->[2] or $stop->[2] =~ m{ ^ \d+ $ }x ) + ) + { + push( @new_route, [ $stop->[0], undef, {} ] ); + } + else { + my $attr = $stop->[1] // {}; + if ( $stop->[2] and $stop->[2] eq 'additional' ) { + $attr->{isAdditional} = 1; + } + elsif ( $stop->[2] and $stop->[2] eq 'cancelled' ) { + $attr->{isCancelled} = 1; + } + push( @new_route, [ $stop->[0], undef, $attr ] ); + } + } + + $db->update( + 'journeys', + { route => $json->encode( \@new_route ) }, + { id => $row->{journey_id} } + ); + + if ( $count++ % 10000 == 0 ) { + printf( " %2.0f%% complete\n", $count * 100 / $total ); + } + } + say ' done'; + $db->query( + qq{ + update schema_version set version = 30; + } + ); + }, + + # v30 -> v31 + # travelynx v1.29.17 introduces links to conflicting journeys. + # These require changes to statistics data. + sub { + my ($db) = @_; + $db->query( + qq{ + truncate journey_stats; + update schema_version set version = 31; + } + ); + }, + + # v31 -> v32 + # travelynx v1.29.18 improves above-mentioned conflict links. + sub { + my ($db) = @_; + $db->query( + qq{ + truncate journey_stats; + update schema_version set version = 32; + } + ); + }, + + # v32 -> v33 + # add optional per-status visibility that overrides global visibility + sub { + my ($db) = @_; + $db->query( + qq{ + alter table journeys add column visibility smallint; + alter table in_transit add column visibility smallint; + drop view journeys_str; + drop view in_transit_str; + create view journeys_str as select + journeys.id as journey_id, user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polylines.polyline as polyline, + visibility, + cancelled, edited, route, messages, user_data, + dep_platform, arr_platform + from journeys + left join polylines on polylines.id = polyline_id + left join stations as dep_station on checkin_station_id = dep_station.eva + left join stations as arr_station on checkout_station_id = arr_station.eva + ; + create view in_transit_str as select + user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polylines.polyline as polyline, + visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join stations as dep_station on checkin_station_id = dep_station.eva + left join stations as arr_station on checkout_station_id = arr_station.eva + ; + } + ); + my $res = $db->select( 'users', [ 'id', 'public_level' ] ); + while ( my $row = $res->hash ) { + my $old_level = $row->{public_level}; + + # status default: unlisted + my $new_level = 30; + if ( $old_level & 0x01 ) { + + # status: account required + $new_level = 80; + } + if ( $old_level & 0x02 ) { + + # status: public + $new_level = 100; + } + if ( $old_level & 0x04 ) { + + # comment public + $new_level |= 0x80; + } + if ( $old_level & 0x10 ) { + + # past: account required + $new_level |= 0x100; + } + if ( $old_level & 0x20 ) { + + # past: public + $new_level |= 0x200; + } + if ( $old_level & 0x40 ) { + + # past: infinite (default is 4 weeks) + $new_level |= 0x400; + } + my $r = $db->update( + 'users', + { public_level => $new_level }, + { id => $row->{id} } + )->rows; + if ( $r != 1 ) { + die("oh no"); + } + } + $db->update( 'schema_version', { version => 33 } ); + }, + + # v33 -> v34 + # add polyline_id to in_transit_str + # (https://github.com/derf/travelynx/issues/66) + sub { + my ($db) = @_; + $db->query( + qq{ + drop view in_transit_str; + create view in_transit_str as select + user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polyline_id, + polylines.polyline as polyline, + visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join stations as dep_station on checkin_station_id = dep_station.eva + left join stations as arr_station on checkout_station_id = arr_station.eva + ; + update schema_version set version = 34; + } + ); + }, + + # v34 -> v35 + sub { + my ($db) = @_; + + # 1 : follows + # 2 : follow requested + # 3 : is blocked by + $db->query( + qq{ + create table relations ( + subject_id integer not null references users (id), + predicate smallint not null, + object_id integer not null references users (id), + primary key (subject_id, object_id) + ); + create view followers as select + relations.object_id as self_id, + users.id as id, + users.name as name + from relations + join users on relations.subject_id = users.id + where predicate = 1; + create view followees as select + relations.subject_id as self_id, + users.id as id, + users.name as name + from relations + join users on relations.object_id = users.id + where predicate = 1; + create view follow_requests as select + relations.object_id as self_id, + users.id as id, + users.name as name + from relations + join users on relations.subject_id = users.id + where predicate = 2; + create view blocked_users as select + relations.object_id as self_id, + users.id as id, + users.name as name + from relations + join users on relations.subject_id = users.id + where predicate = 3; + update schema_version set version = 35; + } + ); + }, + + # v35 -> v36 + sub { + my ($db) = @_; + $db->query( + qq{ + alter table relations + add column ts timestamptz not null; + alter table users + add column accept_follows smallint default 0; + update schema_version set version = 36; + } + ); + }, + + # v36 -> v37 + sub { + my ($db) = @_; + $db->query( + qq{ + alter table users + add column notifications smallint default 0, + add column profile jsonb; + update schema_version set version = 37; + } + ); + }, + + # v37 -> v38 + sub { + my ($db) = @_; + $db->query( + qq{ + drop view followers; + create view followers as select + relations.object_id as self_id, + users.id as id, + users.name as name, + users.accept_follows as accept_follows, + r2.predicate as inverse_predicate + from relations + join users on relations.subject_id = users.id + left join relations as r2 on relations.subject_id = r2.object_id + where relations.predicate = 1; + update schema_version set version = 38; + } + ); + }, + + # v38 -> v39 + sub { + my ($db) = @_; + $db->query( + qq{ + drop view followers; + create view followers as select + relations.object_id as self_id, + users.id as id, + users.name as name, + users.accept_follows as accept_follows, + r2.predicate as inverse_predicate + from relations + join users on relations.subject_id = users.id + left join relations as r2 + on relations.subject_id = r2.object_id + and relations.object_id = r2.subject_id + where relations.predicate = 1; + update schema_version set version = 39; + } + ); + }, + + # v39 -> v40 + # distinguish between public / travelynx / followers / private visibility + # for the history page, just like status visibility. + sub { + my ($db) = @_; + $db->query( + qq{ + alter table users alter public_level type integer; + } + ); + my $res = $db->select( 'users', [ 'id', 'public_level' ] ); + while ( my $row = $res->hash ) { + my $old_level = $row->{public_level}; + + # checkin and comment visibility remain unchanged + my $new_level = $old_level & 0x00ff; + + # past: account required + if ( $old_level & 0x100 ) { + $new_level |= 80 << 8; + } + + # past: public + elsif ( $old_level & 0x200 ) { + $new_level |= 100 << 8; + } + + # past: private + else { + $new_level |= 10 << 8; + } + + # past: infinite (default is 4 weeks) + if ( $old_level & 0x400 ) { + $new_level |= 0x10000; + } + + # show past journey on status page + if ( $old_level & 0x800 ) { + $new_level |= 0x8000; + } + + my $r = $db->update( + 'users', + { public_level => $new_level }, + { id => $row->{id} } + )->rows; + if ( $r != 1 ) { + die("oh no"); + } + } + $db->update( 'schema_version', { version => 40 } ); + }, + + # v40 -> v41 + # Compute effective visibility in in_transit_str and journeys_str. + sub { + my ($db) = @_; + $db->query( + qq{ + drop view in_transit_str; + drop view journeys_str; + create view in_transit_str as select + user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polyline_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva + left join stations as arr_station on checkout_station_id = arr_station.eva + ; + create view journeys_str as select + journeys.id as journey_id, user_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, edited, route, messages, user_data, + dep_platform, arr_platform + from journeys + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva + left join stations as arr_station on checkout_station_id = arr_station.eva + ; + update schema_version set version = 41; + } + ); + }, + + # v41 -> v42 + # adds current followee checkins + sub { + my ($db) = @_; + $db->query( + qq{ + create view follows_in_transit as select + r1.subject_id as follower_id, user_id as followee_id, + users.name as followee_name, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polyline_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva + left join stations as arr_station on checkout_station_id = arr_station.eva + ; + update schema_version set version = 42; + } + ); + }, + + # v42 -> v43 + # list sent and received follow requests + sub { + my ($db) = @_; + $db->query( + qq{ + alter view follow_requests rename to rx_follow_requests; + create view tx_follow_requests as select + relations.subject_id as self_id, + users.id as id, + users.name as name + from relations + join users on relations.object_id = users.id + where predicate = 2; + update schema_version set version = 43; + } + ); + }, + + # v43 -> v44 + # show inverse relation in followees as well + sub { + my ($db) = @_; + $db->query( + qq{ + drop view followees; + create view followees as select + relations.subject_id as self_id, + users.id as id, + users.name as name, + r2.predicate as inverse_predicate + from relations + join users on relations.object_id = users.id + left join relations as r2 + on relations.subject_id = r2.object_id + and relations.object_id = r2.subject_id + where relations.predicate = 1; + update schema_version set version = 44; + } + ); + }, + + # v44 -> v45 + # prepare for HAFAS support: many HAFAS stations do not have DS100 codes + sub { + my ($db) = @_; + $db->query( + qq{ + alter table stations alter column ds100 drop not null; + update schema_version set version = 45; + } + ); + }, + + # v45 -> v46 + # Switch to Traewelling OAuth2 authentication. + # E-Mail is no longer needed. + sub { + my ($db) = @_; + $db->query( + qq{ + drop view traewelling_str; + create view traewelling_str as select + user_id, push_sync, pull_sync, errored, token, data, + extract(epoch from latest_run) as latest_run_ts + from traewelling + ; + alter table traewelling drop column email; + update schema_version set version = 46; + } + ); + }, + + # v46 -> v47 + # sort followee checkins by checkin time + # (descending / most recent first, like a timeline) + sub { + my ($db) = @_; + $db->query( + qq{ + drop view follows_in_transit; + create view follows_in_transit as select + r1.subject_id as follower_id, user_id as followee_id, + users.name as followee_name, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polyline_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva + left join stations as arr_station on checkout_station_id = arr_station.eva + order by checkin_time desc + ; + update schema_version set version = 47; + } + ); + }, + + # v47 -> v48 + # Store Traewelling refresh tokens; store expiry as explicit column. + sub { + my ($db) = @_; + $db->query( + qq{ + alter table traewelling + add column refresh_token text, + add column expiry timestamptz; + drop view traewelling_str; + create view traewelling_str as select + user_id, push_sync, pull_sync, errored, + token, refresh_token, data, + extract(epoch from latest_run) as latest_run_ts, + extract(epoch from expiry) as expiry_ts + from traewelling + ; + update schema_version set version = 48; + } + ); + }, + + # v48 -> v49 + # create indexes for common queries + sub { + my ($db) = @_; + $db->query( + qq{ + create index uid_real_departure_idx on journeys (user_id, real_departure); + update schema_version set version = 49; + } + ); + }, + + # v49 -> v50 + # travelynx 2.0 introduced proper HAFAS support, so there is no need for + # the 'FYI, here is some hAFAS data' kludge anymore. + sub { + my ($db) = @_; + $db->query( + qq{ + drop view user_transit; + drop table localtransit; + update schema_version set version = 50; + } + ); + }, + + # v50 -> v51 + # store related HAFAS stations + sub { + my ($db) = @_; + $db->query( + qq{ + create table related_stations ( + eva integer not null, + meta integer not null, + unique (eva, meta) + ); + create index rel_eva on related_stations (eva); + update schema_version set version = 51; + } + ); + }, ); +sub sync_stations { + my ( $db, $iris_version ) = @_; + + $db->update( 'schema_version', + { iris => $Travel::Status::DE::IRIS::Stations::VERSION } ); + + say 'Updating stations table, this may take a while ...'; + my $total = scalar Travel::Status::DE::IRIS::Stations::get_stations(); + my $count = 0; + for my $s ( Travel::Status::DE::IRIS::Stations::get_stations() ) { + my ( $ds100, $name, $eva, $lon, $lat ) = @{$s}; + if ( $ENV{__TRAVELYNX_TEST_MINI_IRIS} + and ( $eva < 8000000 or $eva > 8000100 ) ) + { + next; + } + $db->insert( + 'stations', + { + eva => $eva, + ds100 => $ds100, + name => $name, + lat => $lat, + lon => $lon, + source => 0, + archived => 0 + }, + { + on_conflict => \ +'(eva) do update set archived = false, source = 0, ds100 = EXCLUDED.ds100, name=EXCLUDED.name, lat=EXCLUDED.lat, lon=EXCLUDED.lon' + } + ); + if ( $count++ % 1000 == 0 ) { + printf( " %2.0f%% complete\n", $count * 100 / $total ); + } + } + say ' done'; + + my $res1 = $db->query( + qq{ + select checkin_station_id + from journeys + left join stations on journeys.checkin_station_id = stations.eva + where stations.eva is null + limit 1; + } + )->hash; + + my $res2 = $db->query( + qq{ + select checkout_station_id + from journeys + left join stations on journeys.checkout_station_id = stations.eva + where stations.eva is null + limit 1; + } + )->hash; + + if ( $res1 or $res2 ) { + say 'Dropping stats cache for archived stations ...'; + $db->query('truncate journey_stats;'); + } + + say 'Updating archived stations ...'; + my $old_stations + = JSON->new->utf8->decode( scalar read_file('share/old_stations.json') ); + if ( $ENV{__TRAVELYNX_TEST_MINI_IRIS} ) { + $old_stations = []; + } + for my $s ( @{$old_stations} ) { + $db->insert( + 'stations', + { + eva => $s->{eva}, + ds100 => $s->{ds100}, + name => $s->{name}, + lat => $s->{latlong}[0], + lon => $s->{latlong}[1], + source => 0, + archived => 1 + }, + { on_conflict => undef } + ); + } + + if ( $iris_version == 0 ) { + say 'Applying EVA ID changes ...'; + for my $change ( + [ 721394, 301002, 'RKBP: Kronenplatz (U), Karlsruhe' ], + [ + 721356, 901012, + 'RKME: Ettlinger Tor/Staatstheater (U), Karlsruhe' + ], + ) + { + my ( $old, $new, $desc ) = @{$change}; + my $rows = $db->update( + 'journeys', + { checkout_station_id => $new }, + { checkout_station_id => $old } + )->rows; + $rows += $db->update( + 'journeys', + { checkin_station_id => $new }, + { checkin_station_id => $old } + )->rows; + if ($rows) { + say "$desc ($old -> $new) : $rows rows"; + } + } + } + + say 'Checking for unknown EVA IDs ...'; + my $found = 0; + + $res1 = $db->query( + qq{ + select checkin_station_id + from journeys + left join stations on journeys.checkin_station_id = stations.eva + where stations.eva is null; + } + ); + + $res2 = $db->query( + qq{ + select checkout_station_id + from journeys + left join stations on journeys.checkout_station_id = stations.eva + where stations.eva is null; + } + ); + + my %notified; + while ( my $row = $res1->hash ) { + my $eva = $row->{checkin_station_id}; + if ( not $found ) { + $found = 1; + say ''; + say '------------8<----------'; + say 'Travel::Status::DE::IRIS v' + . $Travel::Status::DE::IRIS::Stations::VERSION; + } + if ( not $notified{$eva} ) { + say $eva; + $notified{$eva} = 1; + } + } + + while ( my $row = $res2->hash ) { + my $eva = $row->{checkout_station_id}; + if ( not $found ) { + $found = 1; + say ''; + say '------------8<----------'; + say 'Travel::Status::DE::IRIS v' + . $Travel::Status::DE::IRIS::Stations::VERSION; + } + if ( not $notified{$eva} ) { + say $eva; + $notified{$eva} = 1; + } + } + + if ($found) { + say '------------8<----------'; + say ''; + say +'Due to a conceptual flaw in past travelynx releases, your database contains unknown EVA IDs.'; + say +'Please file a bug report titled "Missing EVA IDs after DB migration" at https://github.com/derf/travelynx/issues'; + say 'and include the list shown above in the bug report.'; + say +'If you do not have a GitHub account, please send an E-Mail to derf+travelynx@finalrewind.org instead.'; + say ''; + say 'This issue does not affect usability or long-term data integrity,'; + say 'and handling it is not time-critical.'; + say +'Past journeys referencing unknown EVA IDs may have inaccurate distance statistics,'; + say +'but this will be resolved once a future release handles those EVA IDs.'; + say 'Note that this issue was already present in previous releases.'; + } + else { + say 'None found.'; + } +} + sub setup_db { my ($db) = @_; my $tx = $db->begin; @@ -824,31 +2149,86 @@ sub setup_db { } } +sub failure_hints { + my ($old_version) = @_; + say STDERR 'This travelynx instance has reached an undefined state:'; + say STDERR +'The source code is expecting a different schema version than present in the database.'; + say STDERR +'Please file a detailed bug report at <https://github.com/derf/travelynx/issues>'; + say STDERR 'or send an e-mail to derf+travelynx@finalrewind.org.'; + if ($old_version) { + say STDERR ''; + say STDERR + "The last migration was performed with travelynx v${old_version}."; + say STDERR +'You may be able to return to a working state with the following command:'; + say STDERR "git checkout ${old_version}"; + say STDERR ''; + say STDERR 'We apologize for any inconvenience.'; + } +} + sub migrate_db { - my ($db) = @_; + my ( $self, $db ) = @_; my $tx = $db->begin; my $schema_version = get_schema_version($db); say "Found travelynx schema v${schema_version}"; + my $old_version; + + if ( $schema_version >= 29 ) { + $old_version = get_schema_version( $db, 'travelynx' ); + } + if ( $schema_version == @migrations ) { - say "Database layout is up-to-date"; + say 'Database layout is up-to-date'; + } + else { + eval { + for my $i ( $schema_version .. $#migrations ) { + printf( "Updating to v%d ...\n", $i + 1 ); + $migrations[$i]($db); + } + say 'Update complete.'; + }; + if ($@) { + say STDERR "Migration failed: $@"; + say STDERR "Rolling back to v${schema_version}"; + failure_hints($old_version); + exit(1); + } } - eval { - for my $i ( $schema_version .. $#migrations ) { - printf( "Updating to v%d ...\n", $i + 1 ); - $migrations[$i]($db); + my $iris_version = get_schema_version( $db, 'iris' ); + say "Found IRIS station database v${iris_version}"; + if ( $iris_version eq $Travel::Status::DE::IRIS::Stations::VERSION ) { + say 'Station database is up-to-date'; + } + else { + eval { + say +"Synchronizing with Travel::Status::DE::IRIS $Travel::Status::DE::IRIS::Stations::VERSION"; + sync_stations( $db, $iris_version ); + say 'Synchronization complete.'; + }; + if ($@) { + say STDERR "Synchronization failed: $@"; + if ( $schema_version != @migrations ) { + say STDERR "Rolling back to v${schema_version}"; + failure_hints($old_version); + } + exit(1); } - }; - if ($@) { - say STDERR "Migration failed: $@"; - say STDERR "Rolling back to v${schema_version}"; - exit(1); } + $db->update( 'schema_version', + { travelynx => $self->app->config->{version} } ); + if ( get_schema_version($db) == @migrations ) { $tx->commit; + say 'Changes committed to database. Have a nice day.'; } else { printf STDERR ( @@ -857,6 +2237,8 @@ sub migrate_db { get_schema_version($db) ); say STDERR "Rolling back to v${schema_version}"; + say STDERR ""; + failure_hints($old_version); exit(1); } } @@ -875,10 +2257,13 @@ sub run { if ( not defined get_schema_version($db) ) { setup_db($db); } - migrate_db($db); + $self->migrate_db($db); } elsif ( $command eq 'has-current-schema' ) { - if ( get_schema_version($db) == @migrations ) { + if ( get_schema_version($db) == @migrations + and get_schema_version( $db, 'iris' ) eq + $Travel::Status::DE::IRIS::Stations::VERSION ) + { say "yes"; } else { @@ -903,5 +2288,5 @@ __END__ Recommended workflow: > systemctl stop travelynx - > perl index.pl migrate + > perl index.pl database migrate > systemctl start travelynx diff --git a/lib/Travelynx/Command/dumpconfig.pm b/lib/Travelynx/Command/dumpconfig.pm index d2a6761..600ffb0 100644 --- a/lib/Travelynx/Command/dumpconfig.pm +++ b/lib/Travelynx/Command/dumpconfig.pm @@ -1,4 +1,7 @@ package Travelynx::Command::dumpconfig; +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Command'; use Data::Dumper; diff --git a/lib/Travelynx/Command/dumpstops.pm b/lib/Travelynx/Command/dumpstops.pm new file mode 100644 index 0000000..e6740ec --- /dev/null +++ b/lib/Travelynx/Command/dumpstops.pm @@ -0,0 +1,51 @@ +package Travelynx::Command::dumpstops; + +# Copyright (C) 2024 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use Mojo::Base 'Mojolicious::Command'; +use List::Util qw(); +use Text::CSV; + +has description => 'Export HAFAS/IRIS stops to CSV'; + +has usage => sub { shift->extract_usage }; + +sub run { + my ( $self, $command, $filename ) = @_; + my $db = $self->app->pg->db; + + if ( not $command or not $filename ) { + $self->help; + } + elsif ( $command eq 'csv' ) { + open( my $fh, '>:encoding(utf-8)', $filename ) + or die("open($filename): $!\n"); + + my $csv = Text::CSV->new( { eol => "\r\n" } ); + $csv->combine(qw(name eva lat lon source archived)); + print $fh $csv->string; + + my $iter = $self->app->stations->get_db_iterator; + while ( my $row = $iter->hash ) { + $csv->combine( @{$row}{qw{name eva lat lon source archived}} ); + print $fh $csv->string; + } + close($fh); + } + else { + $self->help; + } +} + +1; + +__END__ + +=head1 SYNOPSIS + + Usage: index.pl dumpstops <format> <filename> + + Exports known stops to <filename>. + Right now, only the "csv" format is supported. diff --git a/lib/Travelynx/Command/influxdb.pm b/lib/Travelynx/Command/influxdb.pm new file mode 100644 index 0000000..f3fc3de --- /dev/null +++ b/lib/Travelynx/Command/influxdb.pm @@ -0,0 +1,209 @@ +package Travelynx::Command::influxdb; + +# Copyright (C) 2022 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later +use Mojo::Base 'Mojolicious::Command'; + +use DateTime; + +has description => 'Generate statistics for InfluxDB'; + +has usage => sub { shift->extract_usage }; + +sub query_to_influx { + my ( $label, $value ) = @_; + + if ( defined $value ) { + return sprintf( '%s=%f', $label, $value ); + } + return; +} + +sub run { + my ($self) = @_; + + my $db = $self->app->pg->db; + + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + my $active = $now->clone->subtract( months => 1 ); + + my @stats; + my @stations; + my @traewelling; + + push( + @stats, + query_to_influx( + 'pending_user_count', + $db->select( 'users', 'count(*) as count', { status => 0 } ) + ->hash->{count} + ) + ); + push( + @stats, + query_to_influx( + 'reg_user_count', + $db->select( 'users', 'count(*) as count', { status => 1 } ) + ->hash->{count} + ) + ); + push( + @stats, + query_to_influx( + 'active_user_count', + $db->select( + 'users', + 'count(*) as count', + { + status => 1, + last_seen => { '>', $active } + } + )->hash->{count} + ) + ); + + push( + @stats, + query_to_influx( + 'checked_in_count', + $db->select( 'in_transit', 'count(*) as count' )->hash->{count} + ) + ); + push( + @stats, + query_to_influx( + 'checkin_count', + $db->select( 'journeys', 'count(*) as count' )->hash->{count} + ) + ); + push( + @stats, + query_to_influx( + 'polyline_count', + $db->select( 'polylines', 'count(*) as count' )->hash->{count} + ) + ); + + push( + @stations, + query_to_influx( + 'iris', + $db->select( + 'stations', + 'count(*) as count', + { + source => 0, + archived => 0 + } + )->hash->{count} + ) + ); + push( + @stations, + query_to_influx( + 'hafas', + $db->select( + 'stations', + 'count(*) as count', + { + source => 1, + archived => 0 + } + )->hash->{count} + ) + ); + push( + @stations, + query_to_influx( + 'archived', + $db->select( 'stations', 'count(*) as count', { archived => 1 } ) + ->hash->{count} + ) + ); + push( + @stations, + query_to_influx( + 'meta', + $db->select( 'related_stations', 'count(*) as count' ) + ->hash->{count} + ) + ); + + push( + @traewelling, + query_to_influx( + 'pull_user_count', + $db->select( + 'traewelling', + 'count(*) as count', + { pull_sync => 1 } + )->hash->{count} + ) + ); + push( + @traewelling, + query_to_influx( + 'push_user_count', + $db->select( + 'traewelling', + 'count(*) as count', + { push_sync => 1 } + )->hash->{count} + ) + ); + push( + @stats, + query_to_influx( + 'polyline_ratio', + $db->query( +'select (select count(polyline_id) from journeys)::float / (select count(*) from polylines) as ratio' + )->hash->{ratio} + ) + ); + + if ( $self->app->mode eq 'development' ) { + $self->app->log->debug( 'POST ' + . $self->app->config->{influxdb}->{url} + . ' stats ' + . join( ',', @stats ) ); + $self->app->log->debug( 'POST ' + . $self->app->config->{influxdb}->{url} + . ' stations ' + . join( ',', @stations ) ); + $self->app->log->debug( 'POST ' + . $self->app->config->{influxdb}->{url} + . ' traewelling ' + . join( ',', @traewelling ) ); + } + elsif ( $self->app->config->{influxdb}->{url} ) { + $self->app->ua->post_p( + $self->app->config->{influxdb}->{url}, + 'stats ' . join( ',', @stats ) + )->wait; + $self->app->ua->post_p( + $self->app->config->{influxdb}->{url}, + 'stations ' . join( ',', @stations ) + )->wait; + $self->app->ua->post_p( + $self->app->config->{influxdb}->{url}, + 'traewelling ' . join( ',', @traewelling ) + )->wait; + } + else { + $self->app->log->warn( + "influxdb command called, but no influxdb url has been configured"); + } + + return; +} + +1; + +__END__ + +=head1 SYNOPSIS + + Usage: index.pl influxdb + + Write statistics to InfluxDB diff --git a/lib/Travelynx/Command/integritycheck.pm b/lib/Travelynx/Command/integritycheck.pm new file mode 100644 index 0000000..4894c3d --- /dev/null +++ b/lib/Travelynx/Command/integritycheck.pm @@ -0,0 +1,112 @@ +package Travelynx::Command::integritycheck; + +# Copyright (C) 2022 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use Mojo::Base 'Mojolicious::Command'; +use List::Util qw(); +use Travel::Status::DE::IRIS::Stations; + +sub run { + my ($self) = @_; + my $found = 0; + my $db = $self->app->pg->db; + + my $res1 = $db->query( + qq{ + select checkin_station_id + from journeys + left join stations on journeys.checkin_station_id = stations.eva + where stations.eva is null; + } + ); + + my $res2 = $db->query( + qq{ + select checkout_station_id + from journeys + left join stations on journeys.checkout_station_id = stations.eva + where stations.eva is null; + } + ); + + my %notified; + while ( my $row = $res1->hash ) { + my $eva = $row->{checkin_station_id}; + if ( not $found ) { + $found = 1; + say +'Journeys in the travelynx database contain the following unknown EVA IDs.'; + say '------------8<----------'; + say 'Travel::Status::DE::IRIS v' + . $Travel::Status::DE::IRIS::Stations::VERSION; + } + if ( not $notified{$eva} ) { + say $eva; + $notified{$eva} = 1; + } + } + + while ( my $row = $res2->hash ) { + my $eva = $row->{checkout_station_id}; + if ( not $found ) { + $found = 1; + say +'Journeys in the travelynx database contain the following unknown EVA IDs.'; + say '------------8<----------'; + say 'Travel::Status::DE::IRIS v' + . $Travel::Status::DE::IRIS::Stations::VERSION; + } + if ( not $notified{$eva} ) { + say $eva; + $notified{$eva} = 1; + } + } + + if ($found) { + say '------------8<----------'; + say ''; + $found = 0; + } + + my $rename = $self->app->renamed_station; + + my $res = $db->select( 'journeys', [ 'route', 'edited' ] )->expand; + while ( my $j = $res->hash ) { + if ( $j->{edited} & 0x0010 ) { + next; + } + my @stops = @{ $j->{route} // [] }; + for my $stop (@stops) { + my $stop_name = $stop->[0]; + if ( $rename->{ $stop->[0] } ) { + $stop->[0] = $rename->{ $stop->[0] }; + } + } + my @unknown + = $self->app->stations->grep_unknown( map { $_->[0] } @stops ); + for my $stop_name (@unknown) { + if ( not $notified{$stop_name} ) { + if ( not $found ) { + say +'Journeys in the travelynx database contain the following unknown route entries.'; + say 'Note that this check ignores manual route entries.'; + say 'All reports refer to routes obtained via HAFAS/IRIS.'; + say '------------8<----------'; + say 'Travel::Status::DE::IRIS v' + . $Travel::Status::DE::IRIS::Stations::VERSION; + $found = 1; + } + say $stop_name; + $notified{$stop_name} = 1; + } + } + } + if ($found) { + say '------------8<----------'; + say ''; + } +} + +1; diff --git a/lib/Travelynx/Command/maintenance.pm b/lib/Travelynx/Command/maintenance.pm index fc64884..c9c7ed6 100644 --- a/lib/Travelynx/Command/maintenance.pm +++ b/lib/Travelynx/Command/maintenance.pm @@ -1,9 +1,13 @@ package Travelynx::Command::maintenance; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Command'; use DateTime; -has description => 'Prune unverified users etc'; +has description => 'Prune unverified users, incomplete checkins etc'; has usage => sub { shift->extract_usage }; @@ -11,9 +15,10 @@ sub run { my ( $self, $filename ) = @_; my $now = DateTime->now( time_zone => 'Europe/Berlin' ); - my $verification_deadline = $now->clone->subtract( hours => 48 ); - my $deletion_deadline = $now->clone->subtract( hours => 72 ); - my $old_deadline = $now->clone->subtract( years => 1 ); + my $verification_deadline = $now->clone->subtract( hours => 48 ); + my $deletion_deadline = $now->clone->subtract( hours => 72 ); + my $old_deadline = $now->clone->subtract( years => 1 ); + my $old_notification_deadline = $now->clone->subtract( weeks => 4 ); my $db = $self->app->pg->db; my $tx = $db->begin; @@ -78,12 +83,40 @@ sub run { printf( "Pruned %d pending mail change(s)\n", $rows ); } + my $to_notify = $db->select( + 'users', + [ 'id', 'name', 'email', 'last_seen' ], + { + last_seen => { '<', $old_deadline }, + deletion_notified => undef + } + ); + + for my $user ( $to_notify->hashes->each ) { + say "Sending account deletion notification to uid $user->{id}..."; + $self->app->sendmail->age_deletion_notification( + name => $user->{name}, + email => $user->{email}, + last_seen => $user->{last_seen}, + login_url => $self->app->base_url_for('login')->to_abs, + account_url => $self->app->base_url_for('account')->to_abs, + imprint_url => $self->app->base_url_for('impressum')->to_abs, + ); + $self->app->users->mark_deletion_notified( uid => $user->{id} ); + } + my $to_delete = $db->select( 'users', ['id'], { deletion_requested => { '<', $deletion_deadline } } ); my @uids_to_delete = $to_delete->arrays->map( sub { shift->[0] } )->each; - $to_delete - = $db->select( 'users', ['id'], { last_seen => { '<', $old_deadline } } ); + $to_delete = $db->select( + 'users', + ['id'], + { + last_seen => { '<', $old_deadline }, + deletion_notified => { '<', $old_notification_deadline } + } + ); push( @uids_to_delete, $to_delete->arrays->map( sub { shift->[0] } )->each ); @@ -93,33 +126,49 @@ sub run { "About to delete %d accounts, which is quite a lot.\n", scalar @uids_to_delete ); + for my $uid (@uids_to_delete) { + my $journeys_res = $db->select( + 'journeys', + 'count(*) as count', + { user_id => $uid } + )->hash; + printf STDERR ( + " - UID %5d (%4d journeys)\n", + $uid, $journeys_res->{count} + ); + } say STDERR 'Aborting maintenance. Please investigate.'; exit(1); } for my $uid (@uids_to_delete) { say "Deleting uid ${uid}..."; - my $tokens_res = $db->delete( 'tokens', { user_id => $uid } ); - my $stats_res = $db->delete( 'journey_stats', { user_id => $uid } ); - my $journeys_res = $db->delete( 'journeys', { user_id => $uid } ); - my $transit_res = $db->delete( 'in_transit', { user_id => $uid } ); - my $password_res - = $db->delete( 'pending_passwords', { user_id => $uid } ); - my $user_res = $db->delete( 'users', { id => $uid } ); - + my $count = $self->app->users->delete( + uid => $uid, + db => $db, + in_transaction => 1 + ); printf( " %d tokens, %d monthly stats, %d journeys\n", - $tokens_res->rows, $stats_res->rows, $journeys_res->rows ); - - if ( $user_res->rows != 1 ) { - printf STDERR ( - "Deleted %d rows from users, expected 1. Rollback and abort.\n", - $user_res->rows - ); - exit(1); - } + $count->{tokens}, $count->{stats}, $count->{journeys} ); } $tx->commit; + + # Computing 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 journey log. + say 'Generating missing stats ...'; + for + my $user ( $db->select( 'users', ['id'], { status => 1 } )->hashes->each ) + { + $tx = $db->begin; + $self->app->journeys->generate_missing_stats( uid => $user->{id} ); + $self->app->journeys->get_stats( + uid => $user->{id}, + year => $now->year + ); + $tx->commit; + } } 1; diff --git a/lib/Travelynx/Command/munin.pm b/lib/Travelynx/Command/munin.pm index 110e058..3b6e393 100644 --- a/lib/Travelynx/Command/munin.pm +++ b/lib/Travelynx/Command/munin.pm @@ -1,4 +1,8 @@ package Travelynx::Command::munin; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Command'; use DateTime; @@ -11,7 +15,7 @@ sub query_to_munin { my ( $label, $value ) = @_; if ( defined $value ) { - printf( "%s.value %d\n", $label, $value ); + printf( "%s.value %f\n", $label, $value ); } } @@ -20,12 +24,25 @@ sub run { my $db = $self->app->pg->db; - my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); my $active = $now->clone->subtract( months => 1 ); my $checkin_window_query = qq{select count(*) as count from journeys where checkin_time > to_timestamp(?);}; + # DateTime's math does not like time zones: When subtracting 7 days from + # sun 2am and the previous sunday was the switch from CET to CEST (i.e., + # the switch to daylight saving time), the resulting datetime is invalid. + # This is a fatal error. We avoid this edge case by performing date math + # on the epoch timestamp, which does not know or care about time zones and + # daylight saving time. + my $one_day = 24 * 60 * 60; + my $one_week = 7 * $one_day; + my $one_month = 30 * $one_day; + + query_to_munin( 'pending_user_count', + $db->select( 'users', 'count(*) as count', { status => 0 } ) + ->hash->{count} ); query_to_munin( 'reg_user_count', $db->select( 'users', 'count(*) as count', { status => 1 } ) ->hash->{count} ); @@ -40,19 +57,30 @@ sub run { } )->hash->{count} ); - query_to_munin( - 'checkins_24h', - $db->query( $checkin_window_query, - $now->subtract( hours => 24 )->epoch )->hash->{count} - ); + query_to_munin( 'checked_in', + $db->select( 'in_transit', 'count(*) as count' )->hash->{count} ); + query_to_munin( 'checkins_24h', + $db->query( $checkin_window_query, $now->epoch - $one_day ) + ->hash->{count} ); query_to_munin( 'checkins_7d', - $db->query( $checkin_window_query, $now->subtract( days => 7 )->epoch ) + $db->query( $checkin_window_query, $now->epoch - $one_week ) + ->hash->{count} ); + query_to_munin( 'checkins_30d', + $db->query( $checkin_window_query, $now->epoch - $one_month ) + ->hash->{count} ); + query_to_munin( 'polylines', + $db->select( 'polylines', 'count(*) as count' )->hash->{count} ); + query_to_munin( 'traewelling_pull', + $db->select( 'traewelling', 'count(*) as count', { pull_sync => 1 } ) + ->hash->{count} ); + query_to_munin( 'traewelling_push', + $db->select( 'traewelling', 'count(*) as count', { push_sync => 1 } ) ->hash->{count} ); query_to_munin( - 'checkins_30d', + 'polyline_ratio', $db->query( - $checkin_window_query, $now->subtract( days => 30 )->epoch - )->hash->{count} +'select (select count(polyline_id) from journeys)::float / (select count(*) from polylines) as ratio' + )->hash->{ratio} ); } diff --git a/lib/Travelynx/Command/traewelling.pm b/lib/Travelynx/Command/traewelling.pm new file mode 100644 index 0000000..4c47e84 --- /dev/null +++ b/lib/Travelynx/Command/traewelling.pm @@ -0,0 +1,207 @@ +package Travelynx::Command::traewelling; + +# Copyright (C) 2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later +use Mojo::Base 'Mojolicious::Command'; +use Mojo::Promise; + +use DateTime; +use JSON; +use List::Util; + +has description => 'Synchronize with Traewelling'; + +has usage => sub { shift->extract_usage }; + +sub pull_sync { + my ($self) = @_; + my %pull_result; + my $request_count = 0; + for my $account_data ( $self->app->traewelling->get_pull_accounts ) { + + my $in_transit = $self->app->in_transit->get( + uid => $account_data->{user_id}, + ); + if ($in_transit) { + $self->app->log->debug( +"Skipping Traewelling status pull for UID $account_data->{user_id}: already checked in" + ); + next; + } + + # $account_data->{user_id} is the travelynx uid + # $account_data->{user_name} is the Träwelling username + $request_count += 1; + $self->app->log->debug( +"Scheduling Traewelling status pull for UID $account_data->{user_id}" + ); + + # In 'work', the event loop is not running, + # so there's no need to multiply by $request_count at the moment + Mojo::Promise->timer(1)->then( + sub { + return $self->app->traewelling_api->get_status_p( + username => $account_data->{data}{user_name}, + token => $account_data->{token} + ); + } + )->then( + sub { + my ($traewelling) = @_; + $pull_result{ $traewelling->{http} } += 1; + return $self->app->traewelling_to_travelynx_p( + traewelling => $traewelling, + user_data => $account_data + ); + } + )->catch( + sub { + my ($err) = @_; + $pull_result{ $err->{http} // 0 } += 1; + $self->app->traewelling->log( + uid => $account_data->{user_id}, + message => "Fehler bei der Status-Abfrage: $err->{text}", + is_error => 1 + ); + $self->app->log->debug("Error $err->{text}"); + } + )->wait; + } + + return \%pull_result; +} + +sub push_sync { + my ($self) = @_; + my %push_result; + + for my $candidate ( $self->app->traewelling->get_pushable_accounts ) { + $self->app->log->debug( + "Pushing to Traewelling for UID $candidate->{uid}"); + my $trip_id = $candidate->{journey_data}{trip_id}; + if ( not $trip_id ) { + $self->app->log->debug("... trip_id is missing"); + $self->app->traewelling->log( + uid => $candidate->{uid}, + message => +"Konnte $candidate->{train_type} $candidate->{train_no} nicht übertragen: Keine trip_id vorhanden", + is_error => 1 + ); + next; + } + if ( $candidate->{data}{latest_push_ts} + and $candidate->{data}{latest_push_ts} == $candidate->{checkin_ts} ) + { + $self->app->log->debug("... already handled"); + next; + } + $self->app->traewelling_api->checkin_p( %{$candidate}, + trip_id => $trip_id )->then( + sub { + my ($status) = @_; + $push_result{ $status->{http} } += 1; + } + )->catch( + sub { + my ($status) = @_; + $push_result{ $status->{http} // 0 } += 1; + } + )->wait; + } + + return \%push_result; +} + +sub run { + my ( $self, $direction ) = @_; + + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + my $started_at = $now; + my $push_result; + my $pull_result; + + if ( not $direction or $direction eq 'push' ) { + $push_result = $self->push_sync; + } + + my $trwl_push_finished_at = DateTime->now( time_zone => 'Europe/Berlin' ); + + if ( not $direction or $direction eq 'pull' ) { + $pull_result = $self->pull_sync; + } + + my $trwl_pull_finished_at = DateTime->now( time_zone => 'Europe/Berlin' ); + + my $trwl_push_duration = $trwl_push_finished_at->epoch - $started_at->epoch; + my $trwl_pull_duration + = $trwl_pull_finished_at->epoch - $trwl_push_finished_at->epoch; + my $trwl_duration = $trwl_pull_finished_at->epoch - $started_at->epoch; + + if ( $self->app->config->{influxdb}->{url} ) { + my $report = "sync_runtime_seconds=${trwl_duration}"; + if ( not $direction or $direction eq 'push' ) { + $report .= ",push_runtime_seconds=${trwl_push_duration}"; + } + if ( not $direction or $direction eq 'pull' ) { + $report .= ",pull_runtime_seconds=${trwl_pull_duration}"; + } + if ( $self->app->mode eq 'development' ) { + $self->app->log->debug( 'POST ' + . $self->app->config->{influxdb}->{url} + . " traewelling ${report}" ); + } + else { + $self->app->ua->post_p( $self->app->config->{influxdb}->{url}, + "traewelling ${report}" )->wait; + } + + if ($push_result) { + for my $status ( keys %{$push_result} ) { + my $count = $push_result->{$status}; + if ( $self->app->mode eq 'development' ) { + $self->app->log->debug( 'POST ' + . $self->app->config->{influxdb}->{url} + . " traewelling_push,http=$status count=$count" ); + } + else { + $self->app->ua->post_p( + $self->app->config->{influxdb}->{url}, + "traewelling_push,http=$status count=$count" + )->wait; + } + } + } + + if ($pull_result) { + for my $status ( keys %{$pull_result} ) { + my $count = $pull_result->{$status}; + if ( $self->app->mode eq 'development' ) { + $self->app->log->debug( 'POST ' + . $self->app->config->{influxdb}->{url} + . " traewelling_pull,http=$status count=$count" ); + } + else { + $self->app->ua->post_p( + $self->app->config->{influxdb}->{url}, + "traewelling_pull,http=$status count=$count" + )->wait; + } + } + } + } +} + +1; + +__END__ + +=head1 SYNOPSIS + + Usage: index.pl traewelling [direction] + + Performs both push and pull synchronization by default. + If "direction" is specified, only synchronizes in the specified direction + ("push" or "pull") + + Should be called from a cronjob every three to ten minutes. diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm index b9a8520..10b1b69 100644 --- a/lib/Travelynx/Command/work.pm +++ b/lib/Travelynx/Command/work.pm @@ -1,33 +1,113 @@ package Travelynx::Command::work; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Command'; +use Mojo::Promise; use DateTime; use JSON; -use List::Util qw(first); +use List::Util; -has description => - 'Perform automatic checkout when users arrive at their destination'; +has description => 'Update real-time data of active journeys'; has usage => sub { shift->extract_usage }; sub run { my ($self) = @_; - my $now = DateTime->now( time_zone => 'Europe/Berlin' ); - my $json = JSON->new; + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + my $checkin_deadline = $now->clone->subtract( hours => 48 ); + my $json = JSON->new; + + my $num_incomplete = $self->app->in_transit->delete_incomplete_checkins( + earlier_than => $checkin_deadline ); - my $db = $self->app->pg->db; + if ($num_incomplete) { + $self->app->log->debug("Removed ${num_incomplete} incomplete checkins"); + } - for my $entry ( - $db->select( 'in_transit_str', '*', { cancelled => 0 } )->hashes->each ) - { + my $errors = 0; + + for my $entry ( $self->app->in_transit->get_all_active ) { my $uid = $entry->{user_id}; - my $dep = $entry->{dep_ds100}; - my $arr = $entry->{arr_ds100}; + my $dep = $entry->{dep_eva}; + my $arr = $entry->{arr_eva}; my $train_id = $entry->{train_id}; - $self->app->log->debug("Processing $uid"); + if ( $train_id =~ m{[|]} ) { + + $self->app->hafas->get_journey_p( trip_id => $train_id )->then( + sub { + my ($journey) = @_; + + my $found_dep; + my $found_arr; + for my $stop ( $journey->route ) { + if ( $stop->loc->eva == $dep ) { + $found_dep = $stop; + } + if ( $arr and $stop->loc->eva == $arr ) { + $found_arr = $stop; + last; + } + } + if ( not $found_dep ) { + $self->app->log->debug( + "Did not find $dep within journey $train_id"); + return; + } + + if ( $found_dep->{rt_dep} ) { + $self->app->in_transit->update_departure_hafas( + uid => $uid, + journey => $journey, + stop => $found_dep, + dep_eva => $dep, + arr_eva => $arr + ); + } + + if ( $found_arr and $found_arr->{rt_arr} ) { + $self->app->in_transit->update_arrival_hafas( + uid => $uid, + journey => $journey, + stop => $found_arr, + dep_eva => $dep, + arr_eva => $arr + ); + } + } + )->catch( + sub { + my ($err) = @_; + if ( $err =~ m{svcResL\[0\][.]err is (?:FAIL|PARAMETER)$} ) + { + # HAFAS do be weird. These are not actionable. + $self->app->log->debug("work($uid)/journey: $err"); + } + else { + $self->app->log->error("work($uid)/journey: $err"); + } + } + )->wait; + + if ( $arr + and $entry->{real_arr_ts} + and $now->epoch - $entry->{real_arr_ts} > 600 ) + { + $self->app->checkout_p( + station => $arr, + force => 2, + dep_eva => $dep, + arr_eva => $arr, + uid => $uid + )->wait; + } + next; + } # Note: IRIS data is not always updated in real-time. Both departure and # arrival delays may take several minutes to appear, especially in case @@ -37,57 +117,96 @@ sub run { eval { if ( $now->epoch - $entry->{real_dep_ts} < 900 ) { - $self->app->log->debug(" - updating departure"); - my $status = $self->app->get_departures( $dep, 30, 30 ); + my $status = $self->app->iris->get_departures( + station => $dep, + lookbehind => 30, + lookahead => 30 + ); if ( $status->{errstr} ) { die("get_departures($dep): $status->{errstr}\n"); } - my ($train) - = first { $_->train_id eq $train_id } @{ $status->{results} }; + my ($train) = List::Util::first { $_->train_id eq $train_id } + @{ $status->{results} }; if ( not $train ) { - die("could not find train $train_id at $dep\n"); + $self->app->log->debug( + "could not find train $train_id at $dep\n"); + return; } - $db->update( - 'in_transit', - { - dep_platform => $train->platform, - real_departure => $train->departure, - route => - $json->encode( [ $self->app->route_diff($train) ] ), - messages => $json->encode( - [ - map { [ $_->[0]->epoch, $_->[1] ] } - $train->messages - ] - ), - }, - { user_id => $uid } + $self->app->in_transit->update_departure( + uid => $uid, + train => $train, + dep_eva => $dep, + arr_eva => $arr, + route => [ $self->app->iris->route_diff($train) ] ); - $self->app->add_route_timestamps( $uid, $train, 1 ); + + if ( $train->departure_is_cancelled and $arr ) { + my $checked_in + = $self->app->in_transit->update_departure_cancelled( + uid => $uid, + train => $train, + dep_eva => $dep, + arr_eva => $arr, + ); + + # depending on the amount of users in transit, some time may + # have passed between fetching $entry from the database and + # now. Only check out if the user is still checked into this + # train. + if ($checked_in) { + + # check out (adds a cancelled journey and resets journey state + # to checkin + $self->app->checkout_p( + station => $arr, + force => 2, + dep_eva => $dep, + arr_eva => $arr, + uid => $uid + )->wait; + } + } + else { + $self->app->add_route_timestamps( $uid, $train, 1 ); + } } }; if ($@) { + $errors += 1; $self->app->log->error("work($uid)/departure: $@"); } eval { if ( - $entry->{arr_name} + $arr and ( not $entry->{real_arr_ts} or $now->epoch - $entry->{real_arr_ts} < 600 ) ) { - $self->app->log->debug(" - updating arrival"); - my $status = $self->app->get_departures( $arr, 20, 220 ); + my $status = $self->app->iris->get_departures( + station => $arr, + lookbehind => 20, + lookahead => 220 + ); if ( $status->{errstr} ) { die("get_departures($arr): $status->{errstr}\n"); } - my ($train) - = first { $_->train_id eq $train_id } @{ $status->{results} }; + # Note that a train may pass the same station several times. + # Notable example: S41 / S42 ("Ringbahn") both starts and + # terminates at Berlin Südkreuz + my ($train) = List::Util::first { + $_->train_id eq $train_id + and $_->sched_arrival + and $_->sched_arrival->epoch > $entry->{sched_dep_ts} + } + @{ $status->{results} }; + + $train //= List::Util::first { $_->train_id eq $train_id } + @{ $status->{results} }; if ( not $train ) { @@ -96,49 +215,80 @@ sub run { return; } - $db->update( - 'in_transit', - { - arr_platform => $train->platform, - sched_arrival => $train->sched_arrival, - real_arrival => $train->arrival, - route => - $json->encode( [ $self->app->route_diff($train) ] ), - messages => $json->encode( - [ - map { [ $_->[0]->epoch, $_->[1] ] } - $train->messages - ] - ), - }, - { user_id => $uid } + my $checked_in = $self->app->in_transit->update_arrival( + uid => $uid, + train => $train, + route => [ $self->app->iris->route_diff($train) ], + dep_eva => $dep, + arr_eva => $arr, ); - $self->app->add_route_timestamps( $uid, $train, 0 ); + + if ( $checked_in and $train->arrival_is_cancelled ) { + + # check out (adds a cancelled journey and resets journey state + # to destination selection) + $self->app->checkout_p( + station => $arr, + force => 0, + dep_eva => $dep, + arr_eva => $arr, + uid => $uid + )->wait; + } + else { + $self->app->add_route_timestamps( + $uid, $train, 0, + ( + defined $entry->{real_arr_ts} + and $now->epoch > $entry->{real_arr_ts} + ) ? 1 : 0 + ); + } } elsif ( $entry->{real_arr_ts} ) { - $self->app->log->debug(" - checking out"); - my ( undef, $error ) = $self->app->checkout( $arr, 1, $uid ); - if ($error) { - die("${error}\n"); - } + my ( undef, $error ) = $self->app->checkout_p( + station => $arr, + force => 2, + dep_eva => $dep, + arr_eva => $arr, + uid => $uid + )->catch( + sub { + my ($error) = @_; + $self->app->log->error("work($uid)/arrival: $error"); + $errors += 1; + } + )->wait; } }; if ($@) { $self->app->log->error("work($uid)/arrival: $@"); + $errors += 1; } - eval { } + eval { }; + } + + my $started_at = $now; + my $main_finished_at = DateTime->now( time_zone => 'Europe/Berlin' ); + my $worker_duration = $main_finished_at->epoch - $started_at->epoch; + + if ( $self->app->config->{influxdb}->{url} ) { + if ( $self->app->mode eq 'development' ) { + $self->app->log->debug( 'POST ' + . $self->app->config->{influxdb}->{url} + . " worker runtime_seconds=${worker_duration},errors=${errors}" + ); + } + else { + $self->app->ua->post_p( $self->app->config->{influxdb}->{url}, + "worker runtime_seconds=${worker_duration},errors=${errors}" ) + ->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. - for my $user ( $db->select( 'users', 'id', { status => 1 } )->hashes->each ) - { - $self->app->get_journey_stats( - uid => $user->{id}, - year => $now->year - ); + if ( not $self->app->config->{traewelling}->{separate_worker} ) { + $self->app->start('traewelling'); } } diff --git a/lib/Travelynx/Command/worker.pm b/lib/Travelynx/Command/worker.pm index 6b70f2e..be7431f 100644 --- a/lib/Travelynx/Command/worker.pm +++ b/lib/Travelynx/Command/worker.pm @@ -1,24 +1,31 @@ package Travelynx::Command::worker; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Command'; use Mojo::IOLoop; -has description => - 'travelynx background worker'; +has description => 'travelynx background worker'; has usage => sub { shift->extract_usage }; sub run { my ($self) = @_; - Mojo::IOLoop->recurring(180 => sub { - $self->app->start('work'); - }); + Mojo::IOLoop->recurring( + 180 => sub { + $self->app->start('work'); + } + ); - Mojo::IOLoop->recurring(3600 => sub { - $self->app->start('maintenance'); - }); + Mojo::IOLoop->recurring( + 36000 => sub { + $self->app->start('maintenance'); + } + ); - if (not Mojo::IOLoop->is_running) { + if ( not Mojo::IOLoop->is_running ) { Mojo::IOLoop->start; } } @@ -33,4 +40,4 @@ __END__ Background worker for cron-less setups, e.g. Docker. - Calls "index.pl work" every 3 minutes and "index.pl maintenance" every 1 hour. + Calls "index.pl work" every 3 minutes and "index.pl maintenance" every 10 hours. diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm index 0275b96..f1dc43e 100644 --- a/lib/Travelynx/Controller/Account.pm +++ b/lib/Travelynx/Controller/Account.pm @@ -1,21 +1,228 @@ package Travelynx::Controller::Account; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Controller'; -use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64); +use JSON; +use Mojo::Util qw(xml_escape); +use Text::Markdown; use UUID::Tiny qw(:std); -sub hash_password { - my ($password) = @_; - my @salt_bytes = map { int( rand(255) ) + 1 } ( 1 .. 16 ); - my $salt = en_base64( pack( 'C[16]', @salt_bytes ) ); +my %visibility_itoa = ( + 100 => 'public', + 80 => 'travelynx', + 60 => 'followers', + 30 => 'unlisted', + 10 => 'private', +); - return bcrypt( $password, '$2a$12$' . $salt ); -} +my %visibility_atoi = ( + public => 100, + travelynx => 80, + followers => 60, + unlisted => 30, + private => 10, +); + +# Internal Helpers sub make_token { return create_uuid_as_string(UUID_V4); } +sub send_registration_mail { + my ( $self, %opt ) = @_; + + my $email = $opt{email}; + my $token = $opt{token}; + my $user = $opt{user}; + my $user_id = $opt{user_id}; + my $ip = $opt{ip}; + my $date = DateTime->now( time_zone => 'Europe/Berlin' ) + ->strftime('%d.%m.%Y %H:%M:%S %z'); + + my $ua = $self->req->headers->user_agent; + my $reg_url = $self->url_for('reg')->to_abs->scheme('https'); + my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); + + my $body = "Hallo, ${user}!\n\n"; + $body .= "Mit deiner E-Mail-Adresse (${email}) wurde ein Account bei\n"; + $body .= "travelynx angelegt.\n\n"; + $body + .= "Falls die Registrierung von dir ausging, kannst du den Account unter\n"; + $body .= "${reg_url}/${user_id}/${token}\n"; + $body .= "freischalten.\n\n"; + $body + .= "Falls nicht, ignoriere diese Mail bitte. Nach etwa 48 Stunden wird deine\n"; + $body + .= "Mail-Adresse erneut zur Registrierung freigeschaltet. Falls auch diese fehlschlägt,\n"; + $body + .= "werden wir sie dauerhaft sperren und keine Mails mehr dorthin schicken.\n\n"; + $body .= "Daten zur Registrierung:\n"; + $body .= " * Datum: ${date}\n"; + $body .= " * Client: ${ip}\n"; + $body .= " * UserAgent: ${ua}\n\n\n"; + $body .= "Impressum: ${imprint_url}\n"; + + return $self->sendmail->custom( $email, 'Registrierung bei travelynx', + $body ); +} + +sub send_address_confirmation_mail { + my ( $self, $email, $token ) = @_; + + my $name = $self->current_user->{name}; + my $ip = $self->req->headers->header('X-Forwarded-For'); + my $ua = $self->req->headers->user_agent; + my $date = DateTime->now( time_zone => 'Europe/Berlin' ) + ->strftime('%d.%m.%Y %H:%M:%S %z'); + + # In case Mojolicious is not running behind a reverse proxy + $ip + //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port ); + my $confirm_url = $self->url_for('confirm_mail')->to_abs->scheme('https'); + my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); + + my $body = "Hallo ${name},\n\n"; + $body .= "Bitte bestätige unter <${confirm_url}/${token}>,\n"; + $body .= "dass du mit dieser Adresse E-Mail empfangen kannst.\n\n"; + $body + .= "Du erhältst diese Mail, da eine Änderung der deinem travelynx-Account\n"; + $body .= "zugeordneten Mail-Adresse beantragt wurde.\n\n"; + $body .= "Daten zur Anfrage:\n"; + $body .= " * Datum: ${date}\n"; + $body .= " * Client: ${ip}\n"; + $body .= " * UserAgent: ${ua}\n\n\n"; + $body .= "Impressum: ${imprint_url}\n"; + + return $self->sendmail->custom( $email, + 'travelynx: Mail-Adresse bestätigen', $body ); +} + +sub send_name_notification_mail { + my ( $self, $old_name, $new_name ) = @_; + + my $ip = $self->req->headers->header('X-Forwarded-For'); + my $ua = $self->req->headers->user_agent; + my $date = DateTime->now( time_zone => 'Europe/Berlin' ) + ->strftime('%d.%m.%Y %H:%M:%S %z'); + + # In case Mojolicious is not running behind a reverse proxy + $ip + //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port ); + my $confirm_url = $self->url_for('confirm_mail')->to_abs->scheme('https'); + my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); + + my $body = "Hallo ${new_name},\n\n"; + $body .= "Der Name deines Travelynx-Accounts wurde erfolgreich geändert.\n"; + $body + .= "Bitte beachte, dass du dich ab sofort nur mit dem neuen Namen anmelden kannst.\n\n"; + $body .= "Alter Name: ${old_name}\n\n"; + $body .= "Neue Name: ${new_name}\n\n"; + $body .= "Daten zur Anfrage:\n"; + $body .= " * Datum: ${date}\n"; + $body .= " * Client: ${ip}\n"; + $body .= " * UserAgent: ${ua}\n\n\n"; + $body .= "Impressum: ${imprint_url}\n"; + + return $self->sendmail->custom( $self->current_user->{email}, + 'travelynx: Name geändert', $body ); +} + +sub send_password_notification_mail { + my ($self) = @_; + my $user = $self->current_user->{name}; + my $email = $self->current_user->{email}; + my $ip = $self->req->headers->header('X-Forwarded-For'); + my $ua = $self->req->headers->user_agent; + my $date = DateTime->now( time_zone => 'Europe/Berlin' ) + ->strftime('%d.%m.%Y %H:%M:%S %z'); + + # In case Mojolicious is not running behind a reverse proxy + $ip + //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port ); + my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); + + my $body = "Hallo ${user},\n\n"; + $body + .= "Das Passwort deines travelynx-Accounts wurde soeben geändert.\n\n"; + $body .= "Daten zur Änderung:\n"; + $body .= " * Datum: ${date}\n"; + $body .= " * Client: ${ip}\n"; + $body .= " * UserAgent: ${ua}\n\n\n"; + $body .= "Impressum: ${imprint_url}\n"; + + $self->sendmail->custom( $email, 'travelynx: Passwort geändert', $body ); +} + +sub send_lostpassword_confirmation_mail { + my ( $self, %opt ) = @_; + my $email = $opt{email}; + my $name = $opt{name}; + my $uid = $opt{uid}; + my $token = $opt{token}; + + my $ip = $self->req->headers->header('X-Forwarded-For'); + my $ua = $self->req->headers->user_agent; + my $date = DateTime->now( time_zone => 'Europe/Berlin' ) + ->strftime('%d.%m.%Y %H:%M:%S %z'); + + # In case Mojolicious is not running behind a reverse proxy + $ip + //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port ); + my $recover_url = $self->url_for('recover')->to_abs->scheme('https'); + my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); + + my $body = "Hallo ${name},\n\n"; + $body .= "Unter ${recover_url}/${uid}/${token}\n"; + $body + .= "kannst du ein neues Passwort für deinen travelynx-Account vergeben.\n\n"; + $body + .= "Du erhältst diese Mail, da mit deinem Accountnamen und deiner Mail-Adresse\n"; + $body + .= "ein Passwort-Reset angefordert wurde. Falls diese Anfrage nicht von dir\n"; + $body .= "ausging, kannst du sie ignorieren.\n\n"; + $body .= "Daten zur Anfrage:\n"; + $body .= " * Datum: ${date}\n"; + $body .= " * Client: ${ip}\n"; + $body .= " * UserAgent: ${ua}\n\n\n"; + $body .= "Impressum: ${imprint_url}\n"; + + my $success + = $self->sendmail->custom( $email, 'travelynx: Neues Passwort', $body ); +} + +sub send_lostpassword_notification_mail { + my ( $self, $account ) = @_; + my $user = $account->{name}; + my $email = $account->{email}; + my $ip = $self->req->headers->header('X-Forwarded-For'); + my $ua = $self->req->headers->user_agent; + my $date = DateTime->now( time_zone => 'Europe/Berlin' ) + ->strftime('%d.%m.%Y %H:%M:%S %z'); + + # In case Mojolicious is not running behind a reverse proxy + $ip + //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port ); + my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); + + my $body = "Hallo ${user},\n\n"; + $body .= "Das Passwort deines travelynx-Accounts wurde soeben über die"; + $body .= " 'Passwort vergessen'-Funktion geändert.\n\n"; + $body .= "Daten zur Änderung:\n"; + $body .= " * Datum: ${date}\n"; + $body .= " * Client: ${ip}\n"; + $body .= " * UserAgent: ${ua}\n\n\n"; + $body .= "Impressum: ${imprint_url}\n"; + + return $self->sendmail->custom( $email, 'travelynx: Passwort geändert', + $body ); +} + +# Controllers + sub login_form { my ($self) = @_; $self->render('login'); @@ -31,22 +238,31 @@ sub do_login { if ( $self->validation->csrf_protect->has_error('csrf_token') ) { $self->render( - 'login', - invalid => 'csrf', + 'bad_request', + csrf => 1, + status => 400 ); } else { if ( $self->authenticate( $user, $password ) ) { $self->redirect_to( $self->req->param('redirect_to') // '/' ); - $self->mark_seen( $self->current_user->{id} ); + $self->users->mark_seen( uid => $self->current_user->{id} ); } else { - my $data = $self->get_user_password($user); + my $data = $self->users->get_login_data( name => $user ); if ( $data and $data->{status} == 0 ) { - $self->render( 'login', invalid => 'confirmation' ); + $self->render( + 'login', + status => 400, + invalid => 'confirmation' + ); } else { - $self->render( 'login', invalid => 'credentials' ); + $self->render( + 'login', + status => 400, + invalid => 'credentials' + ); } } } @@ -59,14 +275,12 @@ sub registration_form { sub register { my ($self) = @_; + my $dt = $self->req->param('dt'); my $user = $self->req->param('user'); my $email = $self->req->param('email'); my $password = $self->req->param('password'); my $password2 = $self->req->param('password2'); my $ip = $self->req->headers->header('X-Forwarded-For'); - my $ua = $self->req->headers->user_agent; - my $date = DateTime->now( time_zone => 'Europe/Berlin' ) - ->strftime('%d.%m.%Y %H:%M:%S %z'); # In case Mojolicious is not running behind a reverse proxy $ip @@ -74,33 +288,44 @@ sub register { if ( $self->validation->csrf_protect->has_error('csrf_token') ) { $self->render( - 'register', - invalid => 'csrf', + 'bad_request', + csrf => 1, + status => 400 ); return; } - if ( not length($user) ) { - $self->render( 'register', invalid => 'user_empty' ); - return; - } - - if ( not length($email) ) { - $self->render( 'register', invalid => 'mail_empty' ); - return; + if ( my $registration_denylist + = $self->app->config->{registration}->{denylist} ) + { + if ( open( my $fh, "<", $registration_denylist ) ) { + while ( my $line = <$fh> ) { + chomp $line; + if ( $ip eq $line ) { + close($fh); + $self->render( 'register', invalid => "denylist" ); + return; + } + } + close($fh); + } + else { + $self->log->error("Cannot open($registration_denylist): $!"); + die("Cannot verify registration: $!"); + } } - if ( $user !~ m{ ^ [0-9a-zA-Z_-]+ $ }x ) { - $self->render( 'register', invalid => 'user_format' ); + if ( my $error = $self->users->is_name_invalid( name => $user ) ) { + $self->render( 'register', invalid => $error ); return; } - if ( $self->check_if_user_name_exists($user) ) { - $self->render( 'register', invalid => 'user_collision' ); + if ( not length($email) ) { + $self->render( 'register', invalid => 'mail_empty' ); return; } - if ( $self->check_if_mail_is_blacklisted($email) ) { + if ( $self->users->mail_is_blacklisted( email => $email ) ) { $self->render( 'register', invalid => 'mail_blacklisted' ); return; } @@ -115,35 +340,37 @@ sub register { return; } - my $token = make_token(); - my $pw_hash = hash_password($password); - my $db = $self->pg->db; - my $tx = $db->begin; - my $user_id = $self->add_user( $db, $user, $email, $token, $pw_hash ); - my $reg_url = $self->url_for('reg')->to_abs->scheme('https'); - my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); + if ( not $dt + or DateTime->now( time_zone => 'Europe/Berlin' )->epoch - $dt < 6 ) + { + # a human user should take at least five seconds to fill out the form. + # Throw a CSRF error at presumed spammers. + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); + return; + } - my $body = "Hallo, ${user}!\n\n"; - $body .= "Mit deiner E-Mail-Adresse (${email}) wurde ein Account bei\n"; - $body .= "travelynx angelegt.\n\n"; - $body - .= "Falls die Registrierung von dir ausging, kannst du den Account unter\n"; - $body .= "${reg_url}/${user_id}/${token}\n"; - $body .= "freischalten.\n\n"; - $body - .= "Falls nicht, ignoriere diese Mail bitte. Nach etwa 48 Stunden wird deine\n"; - $body - .= "Mail-Adresse erneut zur Registrierung freigeschaltet. Falls auch diese fehlschlägt,\n"; - $body - .= "werden wir sie dauerhaft sperren und keine Mails mehr dorthin schicken.\n\n"; - $body .= "Daten zur Registrierung:\n"; - $body .= " * Datum: ${date}\n"; - $body .= " * Client: ${ip}\n"; - $body .= " * UserAgent: ${ua}\n\n\n"; - $body .= "Impressum: ${imprint_url}\n"; + my $token = make_token(); + my $db = $self->pg->db; + my $tx = $db->begin; + my $user_id = $self->users->add( + db => $db, + name => $user, + email => $email, + token => $token, + password => $password, + ); - my $success - = $self->sendmail->custom( $email, 'Registrierung bei travelynx', $body ); + my $success = $self->send_registration_mail( + email => $email, + token => $token, + ip => $ip, + user => $user, + user_id => $user_id + ); if ($success) { $tx->commit; $self->render( 'login', from => 'register' ); @@ -164,7 +391,13 @@ sub verify { return; } - if ( not $self->verify_registration_token( $id, $token ) ) { + if ( + not $self->users->verify_registration_token( + uid => $id, + token => $token + ) + ) + { $self->render( 'register', invalid => 'token' ); return; } @@ -174,8 +407,13 @@ sub verify { sub delete { my ($self) = @_; + my $uid = $self->current_user->{id}; if ( $self->validation->csrf_protect->has_error('csrf_token') ) { - $self->render( 'account', invalid => 'csrf' ); + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); return; } @@ -187,13 +425,14 @@ sub delete { ) ) { - $self->render( 'account', invalid => 'deletion password' ); + $self->flash( invalid => 'deletion password' ); + $self->redirect_to('account'); return; } - $self->flag_user_deletion( $self->current_user->{id} ); + $self->users->flag_deletion( uid => $uid ); } else { - $self->unflag_user_deletion( $self->current_user->{id} ); + $self->users->unflag_deletion( uid => $uid ); } $self->redirect_to('account'); } @@ -201,7 +440,11 @@ sub delete { sub do_logout { my ($self) = @_; if ( $self->validation->csrf_protect->has_error('csrf_token') ) { - $self->render( 'login', invalid => 'csrf' ); + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); return; } $self->logout; @@ -211,32 +454,353 @@ sub do_logout { sub privacy { my ($self) = @_; - my $user = $self->current_user; - my $public_level = $user->{is_public}; + my $user = $self->current_user; if ( $self->param('action') and $self->param('action') eq 'save' ) { - if ( $self->param('public_status') ) { - $public_level |= 0x02; + my %opt; + my $default_visibility + = $visibility_atoi{ $self->param('status_level') }; + if ( defined $default_visibility ) { + $opt{default_visibility} = $default_visibility; } - else { - $public_level &= ~0x02; + + my $past_visibility = $visibility_atoi{ $self->param('history_level') }; + if ( defined $past_visibility ) { + $opt{past_visibility} = $past_visibility; } - $self->set_privacy( $user->{id}, $public_level ); + + $opt{comments_visible} = $self->param('public_comment') ? 1 : 0; + + $opt{past_all} = $self->param('history_age') eq 'infinite' ? 1 : 0; + $opt{past_status} = $self->param('past_status') ? 1 : 0; + + $self->users->set_privacy( + uid => $user->{id}, + %opt + ); $self->flash( success => 'privacy' ); $self->redirect_to('account'); } else { - $self->param( public_status => $public_level & 0x02 ? 1 : 0 ); + $self->param( + status_level => $visibility_itoa{ $user->{default_visibility} } ); + $self->param( public_comment => $user->{comments_visible} ); + $self->param( + history_level => $visibility_itoa{ $user->{past_visibility} } ); + $self->param( history_age => $user->{past_all} ? 'infinite' : 'month' ); + $self->param( past_status => $user->{past_status} ); $self->render( 'privacy', name => $user->{name} ); } } +sub social { + my ($self) = @_; + + my $user = $self->current_user; + + if ( $self->param('action') and $self->param('action') eq 'save' ) { + if ( $self->validation->csrf_protect->has_error('csrf_token') ) { + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); + return; + } + + my %opt; + my $accept_follow = $self->param('accept_follow'); + + if ( $accept_follow eq 'yes' ) { + $opt{accept_follows} = 1; + } + elsif ( $accept_follow eq 'request' ) { + $opt{accept_follow_requests} = 1; + } + + $self->users->set_social( + uid => $user->{id}, + %opt + ); + + $self->flash( success => 'social' ); + $self->redirect_to('account'); + } + else { + if ( $user->{accept_follows} ) { + $self->param( accept_follow => 'yes' ); + } + elsif ( $user->{accept_follow_requests} ) { + $self->param( accept_follow => 'request' ); + } + else { + $self->param( accept_follow => 'no' ); + } + $self->render( 'social', name => $user->{name} ); + } +} + +sub social_list { + my ($self) = @_; + + my $kind = $self->stash('kind'); + my $user = $self->current_user; + + if ( $kind eq 'follow-requests-received' ) { + my @follow_reqs + = $self->users->get_follow_requests( uid => $user->{id} ); + $self->render( + 'social_list', + type => 'follow-requests-received', + entries => [@follow_reqs], + notifications => $user->{notifications}, + ); + } + elsif ( $kind eq 'follow-requests-sent' ) { + my @follow_reqs = $self->users->get_follow_requests( + uid => $user->{id}, + sent => 1 + ); + $self->render( + 'social_list', + type => 'follow-requests-sent', + entries => [@follow_reqs], + notifications => $user->{notifications}, + ); + } + elsif ( $kind eq 'followers' ) { + my @followers = $self->users->get_followers( uid => $user->{id} ); + $self->render( + 'social_list', + type => 'followers', + entries => [@followers], + notifications => $user->{notifications}, + ); + } + elsif ( $kind eq 'follows' ) { + my @following = $self->users->get_followees( uid => $user->{id} ); + $self->render( + 'social_list', + type => 'follows', + entries => [@following], + notifications => $user->{notifications}, + ); + } + elsif ( $kind eq 'blocks' ) { + my @blocked = $self->users->get_blocked_users( uid => $user->{id} ); + $self->render( + 'social_list', + type => 'blocks', + entries => [@blocked], + notifications => $user->{notifications}, + ); + } + else { + $self->render( 'not_found', status => 404 ); + } +} + +sub social_action { + my ($self) = @_; + + my $user = $self->current_user; + my $action = $self->param('action'); + my $target_ids = $self->param('target'); + my $redirect_to = $self->param('redirect_to'); + + for my $key ( + qw(follow request_follow follow_or_request unfollow remove_follower cancel_follow_request accept_follow_request reject_follow_request block unblock) + ) + { + if ( $self->param($key) ) { + $action = $key; + $target_ids = $self->param($key); + } + } + + if ( $self->validation->csrf_protect->has_error('csrf_token') ) { + $self->redirect_to('/'); + return; + } + + if ( $action and $action eq 'clear_notifications' ) { + $self->users->update_notifications( + db => $self->pg->db, + uid => $user->{id}, + has_follow_requests => 0 + ); + $self->flash( success => 'clear_notifications' ); + $self->redirect_to('account'); + return; + } + + if ( not( $action and $target_ids and $redirect_to ) ) { + $self->redirect_to('/'); + return; + } + + for my $target_id ( split( qr{,}, $target_ids ) ) { + my $target = $self->users->get_privacy_by( uid => $target_id ); + + if ( not $target ) { + next; + } + + if ( $action eq 'follow' and $target->{accept_follows} ) { + $self->users->follow( + uid => $user->{id}, + target => $target->{id} + ); + } + elsif ( $action eq 'request_follow' + and $target->{accept_follow_requests} ) + { + $self->users->request_follow( + uid => $user->{id}, + target => $target->{id} + ); + } + elsif ( $action eq 'follow_or_request' ) { + if ( $target->{accept_follows} ) { + $self->users->follow( + uid => $user->{id}, + target => $target->{id} + ); + } + elsif ( $target->{accept_follow_requests} ) { + $self->users->request_follow( + uid => $user->{id}, + target => $target->{id} + ); + } + } + elsif ( $action eq 'unfollow' ) { + $self->users->unfollow( + uid => $user->{id}, + target => $target->{id} + ); + } + elsif ( $action eq 'remove_follower' ) { + $self->users->remove_follower( + uid => $user->{id}, + follower => $target->{id} + ); + } + elsif ( $action eq 'cancel_follow_request' ) { + $self->users->cancel_follow_request( + uid => $user->{id}, + target => $target->{id} + ); + } + elsif ( $action eq 'accept_follow_request' ) { + $self->users->accept_follow_request( + uid => $user->{id}, + applicant => $target->{id} + ); + } + elsif ( $action eq 'reject_follow_request' ) { + $self->users->reject_follow_request( + uid => $user->{id}, + applicant => $target->{id} + ); + } + elsif ( $action eq 'block' ) { + $self->users->block( + uid => $user->{id}, + target => $target->{id} + ); + } + elsif ( $action eq 'unblock' ) { + $self->users->unblock( + uid => $user->{id}, + target => $target->{id} + ); + } + + if ( $redirect_to eq 'profile' ) { + + # profile links do not perform bulk actions + $self->redirect_to( '/p/' . $target->{name} ); + return; + } + } + + $self->redirect_to($redirect_to); +} + +sub profile { + my ($self) = @_; + my $user = $self->current_user; + + if ( $self->param('action') and $self->param('action') eq 'save' ) { + if ( $self->validation->csrf_protect->has_error('csrf_token') ) { + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); + return; + } + my $md = Text::Markdown->new; + my $bio = $self->param('bio'); + + if ( length($bio) > 2000 ) { + $bio = substr( $bio, 0, 2000 ) . '…'; + } + + my $profile = { + bio => { + markdown => $bio, + html => $md->markdown( xml_escape($bio) ), + }, + metadata => [], + }; + for my $i ( 0 .. 20 ) { + my $key = $self->param("key_$i"); + my $value = $self->param("value_$i"); + if ($key) { + if ( length($value) > 500 ) { + $value = substr( $value, 0, 500 ) . '…'; + } + my $html_value + = ( $value + =~ s{ \[ ([^]]+) \]\( ([^)]+) \) }{'<a href="' . xml_escape($2) . '" rel="me">' . xml_escape($1) .'</a>' }egrx + ); + $profile->{metadata}[$i] = { + key => $key, + value => { + markdown => $value, + html => $html_value, + }, + }; + } + else { + last; + } + } + $self->users->set_profile( + uid => $user->{id}, + profile => $profile + ); + $self->redirect_to( '/p/' . $user->{name} ); + } + + my $profile = $self->users->get_profile( uid => $user->{id} ); + $self->param( bio => $profile->{bio}{markdown} ); + for my $i ( 0 .. $#{ $profile->{metadata} } ) { + $self->param( "key_$i" => $profile->{metadata}[$i]{key} ); + $self->param( "value_$i" => $profile->{metadata}[$i]{value}{markdown} ); + } + + $self->render( 'edit_profile', name => $user->{name} ); +} + sub insight { my ($self) = @_; my $user = $self->current_user; - my $use_history = $self->account_use_history( $user->{id} ); + my $use_history = $self->users->use_history( uid => $user->{id} ); if ( $self->param('action') and $self->param('action') eq 'save' ) { if ( $self->param('on_departure') ) { @@ -253,7 +817,10 @@ sub insight { $use_history &= ~0x02; } - $self->account_use_history( $user->{id}, $use_history ); + $self->users->use_history( + uid => $user->{id}, + set => $use_history + ); $self->flash( success => 'use_history' ); $self->redirect_to('account'); } @@ -264,16 +831,42 @@ sub insight { } +sub services { + my ($self) = @_; + my $user = $self->current_user; + + if ( $self->param('action') and $self->param('action') eq 'save' ) { + my $sb = $self->param('stationboard'); + my $value = 0; + if ( $sb =~ m{ ^ \d+ $ }x and $sb >= 0 and $sb <= 4 ) { + $value = int($sb); + } + $self->users->use_external_services( + uid => $user->{id}, + set => $value + ); + $self->flash( success => 'external' ); + $self->redirect_to('account'); + } + + $self->param( stationboard => + $self->users->use_external_services( uid => $user->{id} ) ); + $self->render('use_external_links'); +} + sub webhook { my ($self) = @_; - my $hook = $self->get_webhook; + my $uid = $self->current_user->{id}; + + my $hook = $self->users->get_webhook( uid => $uid ); if ( $self->param('action') and $self->param('action') eq 'save' ) { $hook->{url} = $self->param('url'); $hook->{token} = $self->param('token'); $hook->{enabled} = $self->param('enabled') // 0; - $self->set_webhook( + $self->users->set_webhook( + uid => $uid, url => $hook->{url}, token => $hook->{token}, enabled => $hook->{enabled} @@ -284,7 +877,7 @@ sub webhook { sub { $self->render( 'webhooks', - hook => $self->get_webhook, + hook => $self->users->get_webhook( uid => $uid ), new_hook => 1 ); } @@ -310,8 +903,9 @@ sub change_mail { if ( $action and $action eq 'update_mail' ) { if ( $self->validation->csrf_protect->has_error('csrf_token') ) { $self->render( - 'change_mail', - invalid => 'csrf', + 'bad_request', + csrf => 1, + status => 400 ); return; } @@ -333,41 +927,17 @@ sub change_mail { } my $token = make_token(); - my $name = $self->current_user->{name}; my $db = $self->pg->db; my $tx = $db->begin; - $self->mark_for_mail_change( $db, $self->current_user->{id}, - $email, $token ); - - my $ip = $self->req->headers->header('X-Forwarded-For'); - my $ua = $self->req->headers->user_agent; - my $date = DateTime->now( time_zone => 'Europe/Berlin' ) - ->strftime('%d.%m.%Y %H:%M:%S %z'); - - # In case Mojolicious is not running behind a reverse proxy - $ip - //= sprintf( '%s:%s', $self->tx->remote_address, - $self->tx->remote_port ); - my $confirm_url - = $self->url_for('confirm_mail')->to_abs->scheme('https'); - my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); - - my $body = "Hallo ${name},\n\n"; - $body .= "Bitte bestätige unter <${confirm_url}/${token}>,\n"; - $body .= "dass du mit dieser Adresse E-Mail empfangen kannst.\n\n"; - $body - .= "Du erhältst diese Mail, da eine Änderung der deinem travelynx-Account\n"; - $body .= "zugeordneten Mail-Adresse beantragt wurde.\n\n"; - $body .= "Daten zur Anfrage:\n"; - $body .= " * Datum: ${date}\n"; - $body .= " * Client: ${ip}\n"; - $body .= " * UserAgent: ${ua}\n\n\n"; - $body .= "Impressum: ${imprint_url}\n"; - - my $success - = $self->sendmail->custom( $email, - 'travelynx: Mail-Adresse bestätigen', $body ); + $self->users->mark_for_mail_change( + db => $db, + uid => $self->current_user->{id}, + email => $email, + token => $token + ); + + my $success = $self->send_address_confirmation_mail( $email, $token ); if ($success) { $tx->commit; @@ -382,6 +952,70 @@ sub change_mail { } } +sub change_name { + my ($self) = @_; + + my $action = $self->req->param('action'); + my $password = $self->req->param('password'); + my $old_name = $self->current_user->{name}; + my $new_name = $self->req->param('name'); + + if ( $action and $action eq 'update_name' ) { + if ( $self->validation->csrf_protect->has_error('csrf_token') ) { + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); + return; + } + + if ( my $error = $self->users->is_name_invalid( name => $new_name ) ) { + $self->render( + 'change_name', + name => $old_name, + invalid => $error + ); + return; + } + + if ( not $self->authenticate( $old_name, $self->param('password') ) ) { + $self->render( + 'change_name', + name => $old_name, + invalid => 'password' + ); + return; + } + + # The users table has a unique constraint on the "name" column, so having + # two users with the same name is not possible. The race condition + # between the user_name_exists check in is_name_invalid and this + # change_name call is harmless. + my $success = $self->users->change_name( + uid => $self->current_user->{id}, + name => $new_name + ); + + if ( not $success ) { + $self->render( + 'change_name', + name => $old_name, + invalid => 'user_collision' + ); + return; + } + + $self->flash( success => 'name' ); + $self->redirect_to('account'); + + $self->send_name_notification_mail( $old_name, $new_name ); + } + else { + $self->render( 'change_name', name => $old_name ); + } +} + sub password_form { my ($self) = @_; @@ -395,7 +1029,11 @@ sub change_password { my $password2 = $self->req->param('newpw2'); if ( $self->validation->csrf_protect->has_error('csrf_token') ) { - $self->render( 'change_password', invalid => 'csrf' ); + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); return; } @@ -420,34 +1058,14 @@ sub change_password { return; } - my $pw_hash = hash_password($password); - $self->set_user_password( $self->current_user->{id}, $pw_hash ); + $self->users->set_password( + uid => $self->current_user->{id}, + password => $password + ); $self->flash( success => 'password' ); $self->redirect_to('account'); - - my $user = $self->current_user->{name}; - my $email = $self->current_user->{email}; - my $ip = $self->req->headers->header('X-Forwarded-For'); - my $ua = $self->req->headers->user_agent; - my $date = DateTime->now( time_zone => 'Europe/Berlin' ) - ->strftime('%d.%m.%Y %H:%M:%S %z'); - - # In case Mojolicious is not running behind a reverse proxy - $ip - //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port ); - my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); - - my $body = "Hallo ${user},\n\n"; - $body - .= "Das Passwort deines travelynx-Accounts wurde soeben geändert.\n\n"; - $body .= "Daten zur Änderung:\n"; - $body .= " * Datum: ${date}\n"; - $body .= " * Client: ${ip}\n"; - $body .= " * UserAgent: ${ua}\n\n\n"; - $body .= "Impressum: ${imprint_url}\n"; - - $self->sendmail->custom( $email, 'travelynx: Passwort geändert', $body ); + $self->send_password_notification_mail(); } sub request_password_reset { @@ -455,14 +1073,21 @@ sub request_password_reset { if ( $self->param('action') and $self->param('action') eq 'initiate' ) { if ( $self->validation->csrf_protect->has_error('csrf_token') ) { - $self->render( 'recover_password', invalid => 'csrf' ); + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); return; } my $name = $self->param('user'); my $email = $self->param('email'); - my $uid = $self->get_uid_by_name_and_mail( $name, $email ); + my $uid = $self->users->get_uid_by_name_and_mail( + name => $name, + email => $email + ); if ( not $uid ) { $self->render( 'recover_password', @@ -474,43 +1099,23 @@ sub request_password_reset { my $db = $self->pg->db; my $tx = $db->begin; - my $error = $self->mark_for_password_reset( $db, $uid, $token ); + my $error = $self->users->mark_for_password_reset( + db => $db, + uid => $uid, + token => $token + ); if ($error) { $self->render( 'recover_password', invalid => $error ); return; } - my $ip = $self->req->headers->header('X-Forwarded-For'); - my $ua = $self->req->headers->user_agent; - my $date = DateTime->now( time_zone => 'Europe/Berlin' ) - ->strftime('%d.%m.%Y %H:%M:%S %z'); - - # In case Mojolicious is not running behind a reverse proxy - $ip - //= sprintf( '%s:%s', $self->tx->remote_address, - $self->tx->remote_port ); - my $recover_url = $self->url_for('recover')->to_abs->scheme('https'); - my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); - - my $body = "Hallo ${name},\n\n"; - $body .= "Unter ${recover_url}/${uid}/${token}\n"; - $body - .= "kannst du ein neues Passwort für deinen travelynx-Account vergeben.\n\n"; - $body - .= "Du erhältst diese Mail, da mit deinem Accountnamen und deiner Mail-Adresse\n"; - $body - .= "ein Passwort-Reset angefordert wurde. Falls diese Anfrage nicht von dir\n"; - $body .= "ausging, kannst du sie ignorieren.\n\n"; - $body .= "Daten zur Anfrage:\n"; - $body .= " * Datum: ${date}\n"; - $body .= " * Client: ${ip}\n"; - $body .= " * UserAgent: ${ua}\n\n\n"; - $body .= "Impressum: ${imprint_url}\n"; - - my $success - = $self->sendmail->custom( $email, 'travelynx: Neues Passwort', - $body ); + my $success = $self->send_lostpassword_confirmation_mail( + email => $email, + name => $name, + uid => $uid, + token => $token + ); if ($success) { $tx->commit; @@ -529,10 +1134,20 @@ sub request_password_reset { my $password2 = $self->param('newpw2'); if ( $self->validation->csrf_protect->has_error('csrf_token') ) { - $self->render( 'set_password', invalid => 'csrf' ); + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); return; } - if ( not $self->verify_password_token( $id, $token ) ) { + if ( + not $self->users->verify_password_token( + uid => $id, + token => $token + ) + ) + { $self->render( 'recover_password', invalid => 'change token' ); return; } @@ -546,8 +1161,10 @@ sub request_password_reset { return; } - my $pw_hash = hash_password($password); - $self->set_user_password( $id, $pw_hash ); + $self->users->set_password( + uid => $id, + password => $password + ); my $account = $self->get_user_data($id); @@ -559,33 +1176,12 @@ sub request_password_reset { $self->flash( success => 'password' ); $self->redirect_to('account'); - $self->remove_password_token( $id, $token ); - - my $user = $account->{name}; - my $email = $account->{email}; - my $ip = $self->req->headers->header('X-Forwarded-For'); - my $ua = $self->req->headers->user_agent; - my $date = DateTime->now( time_zone => 'Europe/Berlin' ) - ->strftime('%d.%m.%Y %H:%M:%S %z'); - - # In case Mojolicious is not running behind a reverse proxy - $ip - //= sprintf( '%s:%s', $self->tx->remote_address, - $self->tx->remote_port ); - my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https'); - - my $body = "Hallo ${user},\n\n"; - $body - .= "Das Passwort deines travelynx-Accounts wurde soeben über die"; - $body .= " 'Passwort vergessen'-Funktion geändert.\n\n"; - $body .= "Daten zur Änderung:\n"; - $body .= " * Datum: ${date}\n"; - $body .= " * Client: ${ip}\n"; - $body .= " * UserAgent: ${ua}\n\n\n"; - $body .= "Impressum: ${imprint_url}\n"; - - $self->sendmail->custom( $email, 'travelynx: Passwort geändert', - $body ); + $self->users->remove_password_token( + uid => $id, + token => $token + ); + + $self->send_lostpassword_notification_mail($account); } else { $self->render('recover_password'); @@ -603,7 +1199,13 @@ sub recover_password { return; } - if ( $self->verify_password_token( $id, $token ) ) { + if ( + $self->users->verify_password_token( + uid => $id, + token => $token + ) + ) + { $self->render('set_password'); } else { @@ -616,7 +1218,22 @@ sub confirm_mail { my $id = $self->current_user->{id}; my $token = $self->stash('token'); - if ( $self->change_mail_with_token( $id, $token ) ) { + # Some mail clients include the trailing ">" from the confirmation mail + # when opening/copying the confirmation link. A token will never contain + # this symbol, so remove it just in case. + $token =~ s{>}{}; + + # I did not yet find a mail client that also includes the trailing ",", + # but you never now... + $token =~ s{,}{}; + + if ( + $self->users->change_mail_with_token( + uid => $id, + token => $token + ) + ) + { $self->flash( success => 'mail' ); $self->redirect_to('account'); } @@ -626,10 +1243,27 @@ sub confirm_mail { } sub account { - my ($self) = @_; + my ($self) = @_; + my $uid = $self->current_user->{id}; + my $rx_follow_requests = $self->users->has_follow_requests( uid => $uid ); + my $tx_follow_requests = $self->users->has_follow_requests( + uid => $uid, + sent => 1 + ); + my $followers = $self->users->has_followers( uid => $uid ); + my $following = $self->users->has_followees( uid => $uid ); + my $blocked = $self->users->has_blocked_users( uid => $uid ); - $self->render('account'); - $self->mark_seen( $self->current_user->{id} ); + $self->render( + 'account', + api_token => $self->users->get_api_token( uid => $uid ), + num_rx_follow_requests => $rx_follow_requests, + num_tx_follow_requests => $tx_follow_requests, + num_followers => $followers, + num_following => $following, + num_blocked => $blocked, + ); + $self->users->mark_seen( uid => $uid ); } sub json_export { @@ -653,4 +1287,53 @@ sub json_export { ); } +sub webfinger { + my ($self) = @_; + + my $resource = $self->param('resource'); + + if ( not $resource ) { + $self->render( 'not_found', status => 404 ); + return; + } + + my $root_url = $self->base_url_for('/')->to_abs->host; + + if ( not $root_url + or not $resource + =~ m{ ^ acct: [@]? (?<name> [^@]+ ) [@] $root_url $ }x ) + { + $self->render( 'not_found', status => 404 ); + return; + } + + my $name = $+{name}; + my $user = $self->users->get_privacy_by( name => $name ); + + if ( not $user ) { + $self->render( 'not_found', status => 404 ); + return; + } + + my $profile_url + = $self->base_url_for("/p/${name}")->to_abs->scheme('https')->to_string; + + $self->render( + text => JSON->new->encode( + { + subject => $resource, + aliases => [ $profile_url, ], + links => [ + { + rel => 'http://webfinger.net/rel/profile-page', + type => 'text/html', + href => $profile_url, + }, + ], + } + ), + format => 'json', + ); +} + 1; diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm index a442784..687243d 100755 --- a/lib/Travelynx/Controller/Api.pm +++ b/lib/Travelynx/Controller/Api.pm @@ -1,20 +1,54 @@ package Travelynx::Controller::Api; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Controller'; -use Travel::Status::DE::IRIS::Stations; +use DateTime; +use List::Util; +use Mojo::JSON qw(encode_json); use UUID::Tiny qw(:std); +# Internal Helpers + sub make_token { return create_uuid_as_string(UUID_V4); } +sub sanitize { + my ( $type, $value ) = @_; + if ( not defined $value ) { + return undef; + } + if ( $type eq '' ) { + return '' . $value; + } + if ( $value =~ m{ ^ [0-9.e]+ $ }x ) { + return 0 + $value; + } + return 0; +} + +# Contollers + sub documentation { my ($self) = @_; - $self->render('api_documentation'); + if ( $self->is_user_authenticated ) { + my $uid = $self->current_user->{id}; + $self->render( + 'api_documentation', + uid => $uid, + api_token => $self->users->get_api_token( uid => $uid ), + ); + } + else { + $self->render('api_documentation'); + } } -sub get_v0 { +sub get_v1 { my ($self) = @_; my $api_action = $self->stash('user_action'); @@ -37,8 +71,21 @@ sub get_v0 { } my $uid = $+{id}; $api_token = $+{token}; - my $token = $self->get_api_token($uid); - if ( $api_token ne $token->{$api_action} ) { + + if ( $uid > 2147483647 ) { + $self->render( + json => { + error => 'Malformed token', + }, + ); + return; + } + + my $token = $self->users->get_api_token( uid => $uid ); + if ( not $api_token + or not $token->{$api_action} + or $api_token ne $token->{$api_action} ) + { $self->render( json => { error => 'Invalid token', @@ -47,75 +94,347 @@ sub get_v0 { return; } if ( $api_action eq 'status' ) { - my $status = $self->get_user_status($uid); + $self->render( json => $self->get_user_status_json_v1( uid => $uid ) ); + } + else { + $self->render( + json => { + error => 'not implemented', + }, + ); + } +} - my @station_descriptions; - my $station_eva = undef; - my $station_lon = undef; - my $station_lat = undef; +sub travel_v1 { + my ($self) = @_; - if ( $status->{arr_ds100} // $status->{dep_ds100} ) { - @station_descriptions - = Travel::Status::DE::IRIS::Stations::get_station( - $status->{arr_ds100} // $status->{dep_ds100} ); - } - if ( @station_descriptions == 1 ) { - ( undef, undef, $station_eva, $station_lon, $station_lat ) - = @{ $station_descriptions[0] }; - } + my $payload = $self->req->json; + + if ( not $payload or ref($payload) ne 'HASH' ) { $self->render( json => { - deprecated => \1, - checked_in => ( - $status->{checked_in} - or $status->{cancelled} - ) ? \1 : \0, - station => { - ds100 => $status->{arr_ds100} // $status->{dep_ds100}, - name => $status->{arr_name} // $status->{dep_name}, - uic => $station_eva, - longitude => $station_lon, - latitude => $station_lat, - }, - train => { - type => $status->{train_type}, - line => $status->{train_line}, - no => $status->{train_no}, - }, - actionTime => $status->{timestamp}->epoch, - scheduledTime => $status->{sched_arrival}->epoch - || $status->{sched_departure}->epoch, - realTime => $status->{real_arrival}->epoch - || $status->{real_departure}->epoch, + success => \0, + deprecated => \0, + error => 'Malformed JSON', }, ); + return; } - else { + + my $api_token = $payload->{token} // ''; + + if ( $api_token !~ qr{ ^ (?<id> \d+ ) - (?<token> .* ) $ }x ) { $self->render( json => { - error => 'not implemented', + success => \0, + deprecated => \0, + error => 'Malformed token', + }, + ); + return; + } + my $uid = $+{id}; + $api_token = $+{token}; + + if ( $uid > 2147483647 ) { + $self->render( + json => { + success => \0, + deprecated => \0, + error => 'Malformed token', }, ); + return; + } + + my $token = $self->users->get_api_token( uid => $uid ); + if ( not $token->{'travel'} or $api_token ne $token->{'travel'} ) { + $self->render( + json => { + success => \0, + deprecated => \0, + error => 'Invalid token', + }, + ); + return; + } + + if ( not exists $payload->{action} + or $payload->{action} !~ m{^(checkin|checkout|undo)$} ) + { + $self->render( + json => { + success => \0, + deprecated => \0, + error => 'Missing or invalid action', + status => $self->get_user_status_json_v1( uid => $uid ) + }, + ); + return; + } + + if ( $payload->{action} eq 'checkin' ) { + my $from_station = sanitize( q{}, $payload->{fromStation} ); + my $to_station = sanitize( q{}, $payload->{toStation} ); + my $train_id; + my $hafas = exists $payload->{train}{journeyID} ? 1 : 0; + + if ( + not( + $from_station + and ( ( $payload->{train}{type} and $payload->{train}{no} ) + or $payload->{train}{id} + or $payload->{train}{journeyID} ) + ) + ) + { + $self->render( + json => { + success => \0, + deprecated => \0, + error => 'Missing fromStation or train data', + status => $self->get_user_status_json_v1( uid => $uid ) + }, + ); + return; + } + + if ( not $hafas and not $self->stations->search($from_station) ) { + $self->render( + json => { + success => \0, + deprecated => \0, + error => 'Unknown fromStation', + status => $self->get_user_status_json_v1( uid => $uid ) + }, + ); + return; + } + + if ( $to_station + and not $hafas + and not $self->stations->search($to_station) ) + { + $self->render( + json => { + success => \0, + deprecated => \0, + error => 'Unknown toStation', + status => $self->get_user_status_json_v1( uid => $uid ) + }, + ); + return; + } + + my $train_p; + + if ( exists $payload->{train}{journeyID} ) { + $train_p = Mojo::Promise->resolve( + sanitize( q{}, $payload->{train}{journeyID} ) ); + } + elsif ( exists $payload->{train}{id} ) { + $train_p + = Mojo::Promise->resolve( sanitize( 0, $payload->{train}{id} ) ); + } + else { + my $train_type = sanitize( q{}, $payload->{train}{type} ); + my $train_no = sanitize( q{}, $payload->{train}{no} ); + + $train_p = $self->iris->get_departures_p( + station => $from_station, + lookbehind => 140, + lookahead => 40 + )->then( + sub { + my ($status) = @_; + if ( $status->{errstr} ) { + return Mojo::Promise->reject( + 'Error requesting departures from fromStation: ' + . $status->{errstr} ); + } + my ($train) = List::Util::first { + $_->type eq $train_type and $_->train_no eq $train_no + } + @{ $status->{results} }; + if ( not defined $train ) { + return Mojo::Promise->reject( + 'Train not found at fromStation'); + } + return Mojo::Promise->resolve( $train->train_id ); + } + ); + } + + $self->render_later; + + $train_p->then( + sub { + my ($train_id) = @_; + return $self->checkin_p( + station => $from_station, + train_id => $train_id, + uid => $uid + ); + } + )->then( + sub { + my ($train) = @_; + if ( $payload->{comment} ) { + $self->in_transit->update_user_data( + uid => $uid, + user_data => + { comment => sanitize( q{}, $payload->{comment} ) } + ); + } + if ($to_station) { + + # the user may not have provided the correct to_station, so + # request related stations for checkout. + return $self->checkout_p( + station => $to_station, + force => 0, + uid => $uid, + with_related => 1, + ); + } + return Mojo::Promise->resolve; + } + )->then( + sub { + my ( undef, $error ) = @_; + if ($error) { + return Mojo::Promise->reject($error); + } + $self->render( + json => { + success => \1, + deprecated => \0, + status => $self->get_user_status_json_v1( uid => $uid ) + } + ); + } + )->catch( + sub { + my ($error) = @_; + $self->render( + json => { + success => \0, + deprecated => \0, + error => 'Checkin/Checkout error: ' . $error, + status => $self->get_user_status_json_v1( uid => $uid ) + } + ); + } + )->wait; + } + elsif ( $payload->{action} eq 'checkout' ) { + my $to_station = sanitize( q{}, $payload->{toStation} ); + + if ( not $to_station ) { + $self->render( + json => { + success => \0, + deprecated => \0, + error => 'Missing toStation', + status => $self->get_user_status_json_v1( uid => $uid ) + }, + ); + return; + } + + if ( $payload->{comment} ) { + $self->in_transit->update_user_data( + uid => $uid, + user_data => { comment => sanitize( q{}, $payload->{comment} ) } + ); + } + + $self->render_later; + + # the user may not have provided the correct to_station, so + # request related stations for checkout. + $self->checkout_p( + station => $to_station, + force => $payload->{force} ? 1 : 0, + uid => $uid, + with_related => 1, + )->then( + sub { + my ( $train, $error ) = @_; + if ($error) { + return Mojo::Promise->reject($error); + } + $self->render( + json => { + success => \1, + deprecated => \0, + status => $self->get_user_status_json_v1( uid => $uid ) + } + ); + return; + } + )->catch( + sub { + my ($err) = @_; + $self->render( + json => { + success => \0, + deprecated => \0, + error => 'Checkout error: ' . $err, + status => $self->get_user_status_json_v1( uid => $uid ) + } + ); + } + )->wait; + } + elsif ( $payload->{action} eq 'undo' ) { + my $error = $self->undo( 'in_transit', $uid ); + if ($error) { + $self->render( + json => { + success => \0, + deprecated => \0, + error => $error, + status => $self->get_user_status_json_v1( uid => $uid ) + } + ); + } + else { + $self->render( + json => { + success => \1, + deprecated => \0, + status => $self->get_user_status_json_v1( uid => $uid ) + } + ); + } } } -sub get_v1 { +sub import_v1 { my ($self) = @_; - my $api_action = $self->stash('user_action'); - my $api_token = $self->stash('token'); - if ( $api_action !~ qr{ ^ (?: status | history | action ) $ }x ) { + my $payload = $self->req->json; + + if ( not $payload or ref($payload) ne 'HASH' ) { $self->render( json => { - error => 'Invalid action', + success => \0, + deprecated => \0, + error => 'Malformed JSON', }, ); return; } + + my $api_token = $payload->{token} // ''; + if ( $api_token !~ qr{ ^ (?<id> \d+ ) - (?<token> .* ) $ }x ) { $self->render( json => { - error => 'Malformed token', + success => \0, + deprecated => \0, + error => 'Malformed token', }, ); return; @@ -126,29 +445,162 @@ sub get_v1 { if ( $uid > 2147483647 ) { $self->render( json => { - error => 'Malformed token', + success => \0, + deprecated => \0, + error => 'Malformed token', }, ); return; } - my $token = $self->get_api_token($uid); - if ( $api_token ne $token->{$api_action} ) { + my $token = $self->users->get_api_token( uid => $uid ); + if ( not $token->{'import'} or $api_token ne $token->{'import'} ) { $self->render( json => { - error => 'Invalid token', + success => \0, + deprecated => \0, + error => 'Invalid token', }, ); return; } - if ( $api_action eq 'status' ) { - $self->render( json => $self->get_user_status_json_v1($uid) ); + + if ( not exists $payload->{fromStation} + or not exists $payload->{toStation} ) + { + $self->render( + json => { + success => \0, + deprecated => \0, + error => 'missing fromStation or toStation', + }, + ); + return; + } + + my %opt; + + eval { + + if ( not $payload->{fromStation}{name} + or not $payload->{toStation}{name} ) + { + die("Missing fromStation/toStation name\n"); + } + if ( not $payload->{train}{type} or not $payload->{train}{no} ) { + die("Missing train data\n"); + } + if ( not $payload->{fromStation}{scheduledTime} + or not $payload->{toStation}{scheduledTime} ) + { + die("Missing fromStation/toStation scheduledTime\n"); + } + + %opt = ( + uid => $uid, + train_type => sanitize( q{}, $payload->{train}{type} ), + train_no => sanitize( q{}, $payload->{train}{no} ), + train_line => sanitize( q{}, $payload->{train}{line} ), + cancelled => $payload->{cancelled} ? 1 : 0, + dep_station => sanitize( q{}, $payload->{fromStation}{name} ), + arr_station => sanitize( q{}, $payload->{toStation}{name} ), + sched_departure => + sanitize( 0, $payload->{fromStation}{scheduledTime} ), + rt_departure => sanitize( + 0, + $payload->{fromStation}{realTime} + // $payload->{fromStation}{scheduledTime} + ), + sched_arrival => + sanitize( 0, $payload->{toStation}{scheduledTime} ), + rt_arrival => sanitize( + 0, + $payload->{toStation}{realTime} + // $payload->{toStation}{scheduledTime} + ), + comment => sanitize( q{}, $payload->{comment} ), + lax => $payload->{lax} ? 1 : 0, + ); + + if ( $payload->{intermediateStops} + and ref( $payload->{intermediateStops} ) eq 'ARRAY' ) + { + $opt{route} + = [ map { sanitize( q{}, $_ ) } + @{ $payload->{intermediateStops} } ]; + } + + for my $key (qw(sched_departure rt_departure sched_arrival rt_arrival)) + { + $opt{$key} = DateTime->from_epoch( + time_zone => 'Europe/Berlin', + epoch => $opt{$key} + ); + } + }; + if ($@) { + my ($first_line) = split( qr{\n}, $@ ); + $self->render( + json => { + success => \0, + deprecated => \0, + error => $first_line + } + ); + return; + } + + my $db = $self->pg->db; + my $tx = $db->begin; + + $opt{db} = $db; + my ( $journey_id, $error ) = $self->journeys->add(%opt); + my $journey; + + if ( not $error ) { + $journey = $self->journeys->get_single( + uid => $uid, + db => $db, + journey_id => $journey_id, + verbose => 1 + ); + $error + = $self->journeys->sanity_check( $journey, $payload->{lax} ? 1 : 0 ); + } + + if ($error) { + $self->render( + json => { + success => \0, + deprecated => \0, + error => $error + } + ); + } + elsif ( $payload->{dryRun} ) { + $self->render( + json => { + success => \1, + deprecated => \0, + id => $journey_id, + result => $journey + } + ); } else { + $self->journey_stats_cache->invalidate( + ts => $opt{rt_departure}, + db => $db, + uid => $uid + ); + $tx->commit; $self->render( json => { - error => 'not implemented', - }, + success => \1, + deprecated => \0, + id => $journey_id, + result => $journey + } ); } } @@ -156,11 +608,15 @@ sub get_v1 { sub set_token { my ($self) = @_; if ( $self->validation->csrf_protect->has_error('csrf_token') ) { - $self->render( 'account', invalid => 'csrf' ); + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); return; } my $token = make_token(); - my $token_id = $self->app->token_type->{ $self->param('token') }; + my $token_id = $self->users->get_token_id( $self->param('token') ); if ( not $token_id ) { $self->redirect_to('account'); @@ -193,4 +649,21 @@ sub set_token { $self->redirect_to('account'); } +sub autocomplete { + my ($self) = @_; + + $self->res->headers->cache_control('max-age=86400, immutable'); + + my $output + = "document.addEventListener('DOMContentLoaded',function(){M.Autocomplete.init(document.querySelectorAll('.autocomplete'),{\n"; + $output .= 'minLength:3,limit:50,data:'; + $output .= encode_json( $self->stations->get_for_autocomplete ); + $output .= "\n});});\n"; + + $self->render( + format => 'js', + data => $output + ); +} + 1; diff --git a/lib/Travelynx/Controller/Passengerrights.pm b/lib/Travelynx/Controller/Passengerrights.pm index ba299b1..d80f1ae 100644 --- a/lib/Travelynx/Controller/Passengerrights.pm +++ b/lib/Travelynx/Controller/Passengerrights.pm @@ -1,15 +1,22 @@ package Travelynx::Controller::Passengerrights; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Controller'; use DateTime; use CAM::PDF; +# Internal Helpers + sub mark_if_missed_connection { my ( $self, $journey, $next_journey ) = @_; my $possible_delay = ( $next_journey->{rt_departure}->epoch - - $journey->{sched_arrival}->epoch ) / 60; + - $journey->{sched_arrival}->epoch ) + / 60; my $wait_time = ( $next_journey->{rt_departure}->epoch - $journey->{rt_arrival}->epoch ) / 60; @@ -50,7 +57,8 @@ sub mark_if_missed_connection { sub mark_substitute_connection { my ( $self, $journey ) = @_; - my @substitute_candidates = reverse $self->get_user_travels( + my @substitute_candidates = reverse $self->journeys->get( + uid => $self->current_user->{id}, after => $journey->{sched_departure}->clone->subtract( hours => 1 ), before => $journey->{sched_departure}->clone->add( hours => 12 ), with_datetime => 1, @@ -81,13 +89,16 @@ sub mark_substitute_connection { } } +# Controllers + sub list_candidates { my ($self) = @_; my $now = DateTime->now( time_zone => 'Europe/Berlin' ); my $range_start = $now->clone->subtract( months => 6 ); - my @journeys = $self->get_user_travels( + my @journeys = $self->journeys->get( + uid => $self->current_user->{id}, after => $range_start, before => $now, with_datetime => 1, @@ -112,7 +123,8 @@ sub list_candidates { @journeys = grep { $_->{delay} >= 60 or $_->{connection_missed} } @journeys; - my @cancelled = $self->get_user_travels( + my @cancelled = $self->journeys->get( + uid => $self->current_user->{id}, after => $range_start, before => $now, cancelled => 1, @@ -127,9 +139,9 @@ sub list_candidates { $journey->{cancelled} = 1; $self->mark_substitute_connection($journey); - if ( not $journey->{has_substitute} - or $journey->{to_substitute}->{rt_arr_ts} - $journey->{sched_arr_ts} - >= 3600 ) + if ( $journey->{has_substitute} + and $journey->{to_substitute}->{rt_arr_ts} + - $journey->{sched_arr_ts} >= 3600 ) { push( @journeys, $journey ); } @@ -163,7 +175,7 @@ sub generate { return; } - my $journey = $self->get_journey( + my $journey = $self->journeys->get_single( uid => $uid, journey_id => $journey_id, verbose => 1, @@ -187,7 +199,7 @@ sub generate { $self->mark_substitute_connection($journey); } elsif ( $journey->{delay} < 120 ) { - my @connections = $self->get_user_travels( + my @connections = $self->journeys->get( uid => $uid, after => $journey->{rt_arrival}, before => $journey->{rt_arrival}->clone->add( hours => 2 ), diff --git a/lib/Travelynx/Controller/Profile.pm b/lib/Travelynx/Controller/Profile.pm new file mode 100755 index 0000000..fc2d38c --- /dev/null +++ b/lib/Travelynx/Controller/Profile.pm @@ -0,0 +1,603 @@ +package Travelynx::Controller::Profile; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later +use Mojo::Base 'Mojolicious::Controller'; + +use DateTime; + +# Internal Helpers + +sub status_token_ok { + my ( $self, $status, $ts2_ext ) = @_; + my $token = $self->param('token') // q{}; + + my ( $eva, $ts, $ts2 ) = split( qr{-}, $token ); + if ( not $ts ) { + return; + } + + $ts2 //= $ts2_ext; + + if ( $eva == $status->{dep_eva} + and $ts == $status->{timestamp}->epoch % 337 + and $ts2 == $status->{sched_departure}->epoch ) + { + return 1; + } + return; +} + +sub journey_token_ok { + my ( $self, $journey, $ts2_ext ) = @_; + my $token = $self->param('token') // q{}; + + my ( $eva, $ts, $ts2 ) = split( qr{-}, $token ); + if ( not $ts ) { + return; + } + + $ts2 //= $ts2_ext; + + if ( $eva == $journey->{from_eva} + and $ts == $journey->{checkin_ts} % 337 + and $ts2 == $journey->{sched_dep_ts} ) + { + return 1; + } + return; +} + +# Controllers + +sub profile { + my ($self) = @_; + + my $name = $self->stash('name'); + my $user = $self->users->get_privacy_by( name => $name ); + + if ( not $user ) { + $self->render( 'not_found', status => 404 ); + return; + } + + my $profile = $self->users->get_profile( uid => $user->{id} ); + + my $my_user; + my $relation; + my $inverse_relation; + my $is_self; + if ( $self->is_user_authenticated ) { + $my_user = $self->current_user; + if ( $my_user->{id} == $user->{id} ) { + $is_self = 1; + $my_user = undef; + } + else { + $relation = $self->users->get_relation( + subject => $my_user->{id}, + object => $user->{id} + ); + $inverse_relation = $self->users->get_relation( + subject => $user->{id}, + object => $my_user->{id} + ); + } + } + + my $status = $self->get_user_status( $user->{id} ); + if ( $status->{checked_in} or $status->{arr_name} ) { + my $visibility = $status->{effective_visibility}; + if ( + not( + $visibility == 100 + or ( $visibility >= 80 and $my_user ) + or + ( $visibility >= 60 and $relation and $relation eq 'follows' ) + or ( $visibility >= 60 and $is_self ) + or ( $visibility >= 30 and $self->status_token_ok($status) ) + ) + ) + { + $status->{checked_in} = 0; + $status->{arr_name} = undef; + } + } + if ( not $status->{checked_in} + and $status->{arr_name} + and not $user->{past_status} ) + { + $status->{arr_name} = undef; + } + + my @journeys; + + if ( + $user->{past_visibility_str} eq 'public' + or ( $user->{past_visibility_str} eq 'travelynx' + and ( $my_user or $is_self ) ) + or ( $user->{past_visibility_str} eq 'followers' + and ( ( $relation and $relation eq 'follows' ) or $is_self ) ) + ) + { + + my %opt = ( + uid => $user->{id}, + limit => 10, + with_datetime => 1 + ); + + if ( not $user->{past_all} ) { + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + $opt{before} = DateTime->now( time_zone => 'Europe/Berlin' ); + $opt{after} = $now->clone->subtract( weeks => 4 ); + } + + if ($is_self) { + $opt{min_visibility} = 'followers'; + } + elsif ($my_user) { + if ( $relation and $relation eq 'follows' ) { + $opt{min_visibility} = 'followers'; + } + else { + $opt{min_visibility} = 'travelynx'; + } + } + else { + $opt{min_visibility} = 'public'; + } + + @journeys = $self->journeys->get(%opt); + } + + $self->render( + 'profile', + title => "travelynx: $name", + name => $name, + uid => $user->{id}, + privacy => $user, + bio => $profile->{bio}{html}, + metadata => $profile->{metadata}, + is_self => $is_self, + following => ( $relation and $relation eq 'follows' ) ? 1 : 0, + follow_requested => ( $relation and $relation eq 'requests_follow' ) + ? 1 + : 0, + can_follow => ( $my_user and $user->{accept_follows} and not $relation ) + ? 1 + : 0, + can_request_follow => + ( $my_user and $user->{accept_follow_requests} and not $relation ) + ? 1 + : 0, + follows_me => ( $inverse_relation and $inverse_relation eq 'follows' ) + ? 1 + : 0, + follow_reqs_me => + ( $inverse_relation and $inverse_relation eq 'requests_follow' ) ? 1 + : 0, + journey => $status, + journeys => [@journeys], + ); +} + +sub journey_details { + my ($self) = @_; + my $name = $self->stash('name'); + my $journey_id = $self->stash('id'); + my $user = $self->users->get_privacy_by( name => $name ); + + $self->param( journey_id => $journey_id ); + + my $my_user; + my $relation; + my $inverse_relation; + my $is_self; + if ( $self->is_user_authenticated ) { + $my_user = $self->current_user; + if ( $my_user->{id} == $user->{id} ) { + $is_self = 1; + $my_user = undef; + } + else { + $relation = $self->users->get_relation( + subject => $my_user->{id}, + object => $user->{id} + ); + } + } + + if ( not( $user and $journey_id and $journey_id =~ m{ ^ \d+ $ }x ) ) { + $self->render( + 'journey', + status => 404, + error => 'notfound', + journey => {} + ); + return; + } + + my $journey = $self->journeys->get_single( + uid => $user->{id}, + journey_id => $journey_id, + verbose => 1, + with_datetime => 1, + with_polyline => 1, + with_visibility => 1, + ); + + if ( not $journey ) { + $self->render( + 'journey', + status => 404, + error => 'notfound', + journey => {} + ); + return; + } + + my $is_past; + if ( not $user->{past_all} ) { + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + if ( $journey->{sched_dep_ts} < $now->subtract( weeks => 4 )->epoch ) { + $is_past = 1; + } + } + + my $visibility = $journey->{effective_visibility}; + + if ( + not( ( $visibility == 100 and not $is_past ) + or ( $visibility >= 80 and $my_user and not $is_past ) + or ( $visibility >= 60 and $relation and $relation eq 'follows' ) + or ( $visibility >= 60 and $is_self ) + or ( $visibility >= 30 and $self->journey_token_ok($journey) ) ) + ) + { + $self->render( + 'journey', + status => 404, + error => 'notfound', + journey => {} + ); + return; + } + + my $title = sprintf( 'Fahrt von %s nach %s am %s', + $journey->{from_name}, $journey->{to_name}, + $journey->{rt_arrival}->strftime('%d.%m.%Y') ); + my $delay = 'pünktlich '; + if ( $journey->{rt_arrival} != $journey->{sched_arrival} ) { + $delay = sprintf( + 'mit %+d ', + ( + $journey->{rt_arrival}->epoch + - $journey->{sched_arrival}->epoch + ) / 60 + ); + } + my $description = sprintf( 'Ankunft mit %s %s %s', + $journey->{type}, $journey->{no}, + $journey->{rt_arrival}->strftime('um %H:%M') ); + if ( $journey->{km_route} > 0.1 ) { + $description = sprintf( '%.0f km mit %s %s – Ankunft %sum %s', + $journey->{km_route}, $journey->{type}, $journey->{no}, + $delay, $journey->{rt_arrival}->strftime('%H:%M') ); + } + my %tw_data = ( + card => 'summary', + site => '@derfnull', + image => $self->url_for('/static/icons/icon-512x512.png') + ->to_abs->scheme('https'), + title => $title, + description => $description, + ); + my %og_data = ( + type => 'article', + image => $tw_data{image}, + url => $self->url_for->to_abs, + site_name => 'travelynx', + title => $title, + description => $description, + ); + + my $map_data = $self->journeys_to_map_data( + journeys => [$journey], + include_manual => 1, + ); + if ( $journey->{user_data}{comment} + and not $user->{comments_visible} ) + { + delete $journey->{user_data}{comment}; + } + $self->render( + 'journey', + title => "travelynx: $title", + error => undef, + journey => $journey, + with_map => 1, + username => $name, + readonly => 1, + twitter => \%tw_data, + opengraph => \%og_data, + %{$map_data}, + ); +} + +sub user_status { + my ($self) = @_; + + my $name = $self->stash('name'); + my $ts = $self->stash('ts') // 0; + my $user = $self->users->get_privacy_by( name => $name ); + + if ( not $user ) { + $self->respond_to( + json => { + json => { error => 'not found' }, + status => 404, + }, + any => { + template => 'not_found', + status => 404 + } + ); + return; + } + + my $my_user; + my $relation; + my $inverse_relation; + my $is_self; + if ( $self->is_user_authenticated ) { + $my_user = $self->current_user; + if ( $my_user->{id} == $user->{id} ) { + $is_self = 1; + $my_user = undef; + } + else { + $relation = $self->users->get_relation( + subject => $my_user->{id}, + object => $user->{id} + ); + } + } + + my $status = $self->get_user_status( $user->{id} ); + + if ( + $ts + and ( not $status->{checked_in} + or $status->{sched_departure}->epoch != $ts ) + ) + { + for my $journey ( + $self->journeys->get( + uid => $user->{id}, + sched_dep_ts => $ts, + limit => 1, + with_visibility => 1, + ) + ) + { + my $visibility = $journey->{effective_visibility}; + if ( + $visibility == 100 + or ( $visibility >= 80 and $my_user ) + or + ( $visibility >= 60 and $relation and $relation eq 'follows' ) + or ( $visibility >= 60 and $is_self ) + or ( $visibility >= 30 + and $self->journey_token_ok( $journey, $ts ) ) + ) + { + my $token = $self->param('token') // q{}; + $self->redirect_to( + "/p/${name}/j/$journey->{id}?token=${token}-${ts}"); + } + else { + $self->respond_to( + json => { + json => { error => 'not found' }, + status => 404, + }, + any => { + template => 'not_found', + status => 404 + } + ); + } + return; + } + $self->respond_to( + json => { + json => { error => 'not found' }, + status => 404, + }, + any => { + template => 'not_found', + status => 404 + } + ); + return; + } + + my %tw_data = ( + card => 'summary', + site => '@derfnull', + image => $self->url_for('/static/icons/icon-512x512.png') + ->to_abs->scheme('https'), + ); + my %og_data = ( + type => 'article', + image => $tw_data{image}, + url => $self->url_for("/status/${name}")->to_abs->scheme('https'), + site_name => 'travelynx', + ); + + if ( $status->{checked_in} or $status->{arr_name} ) { + my $visibility = $status->{effective_visibility}; + if ( + not( + $visibility == 100 + or ( $visibility >= 80 and $my_user ) + or + ( $visibility >= 60 and $relation and $relation eq 'follows' ) + or ( $visibility >= 60 and $is_self ) + or + ( $visibility >= 30 and $self->status_token_ok( $status, $ts ) ) + ) + ) + { + $status = {}; + } + } + if ( not $status->{checked_in} + and $status->{arr_name} + and not $user->{past_status} ) + { + $status = {}; + } + + if ( $status->{checked_in} ) { + $og_data{url} .= '/' . $status->{sched_departure}->epoch; + $og_data{title} = $tw_data{title} = "${name} ist unterwegs"; + $og_data{description} = $tw_data{description} = sprintf( + '%s %s von %s nach %s', + $status->{train_type}, $status->{train_line} // $status->{train_no}, + $status->{dep_name}, $status->{arr_name} // 'irgendwo' + ); + if ( $status->{real_arrival}->epoch ) { + $tw_data{description} .= $status->{real_arrival} + ->strftime(' – Ankunft gegen %H:%M Uhr'); + $og_data{description} .= $status->{real_arrival} + ->strftime(' – Ankunft gegen %H:%M Uhr'); + } + } + else { + $og_data{title} = $tw_data{title} + = "${name} ist gerade nicht eingecheckt"; + $og_data{description} = $tw_data{description} = q{}; + } + + $self->respond_to( + json => { + json => { + account => { + name => $name, + }, + status => $self->get_user_status_json_v1( + status => $status, + privacy => $user, + public => 1 + ), + version => $self->app->config->{version} // 'UNKNOWN', + }, + }, + any => { + template => 'user_status', + name => $name, + title => "travelynx: $tw_data{title}", + privacy => $user, + journey => $status, + twitter => \%tw_data, + opengraph => \%og_data, + version => $self->app->config->{version} // 'UNKNOWN', + }, + ); +} + +sub status_card { + my ($self) = @_; + + my $name = $self->stash('name'); + $name =~ s{[.]html$}{}; + my $user = $self->users->get_privacy_by( name => $name ); + + delete $self->stash->{layout}; + + if ( not $user ) { + $self->render( 'not_found', status => 404 ); + return; + } + + my $my_user; + my $relation; + my $inverse_relation; + my $is_self; + if ( $self->is_user_authenticated ) { + $my_user = $self->current_user; + if ( $my_user->{id} == $user->{id} ) { + $is_self = 1; + $my_user = undef; + } + else { + $relation = $self->users->get_relation( + subject => $my_user->{id}, + object => $user->{id} + ); + } + } + + my $status = $self->get_user_status( $user->{id} ); + my $visibility; + if ( $status->{checked_in} or $status->{arr_name} ) { + my $visibility = $status->{effective_visibility}; + if ( + not( + $visibility == 100 + or ( $visibility >= 80 and $my_user ) + or + ( $visibility >= 60 and $relation and $relation eq 'follows' ) + or ( $visibility >= 60 and $is_self ) + or ( $visibility >= 30 and $self->status_token_ok($status) ) + ) + ) + { + $status->{checked_in} = 0; + $status->{arr_name} = undef; + } + } + if ( not $status->{checked_in} + and $status->{arr_name} + and not $user->{past_status} ) + { + $status->{arr_name} = undef; + } + + $self->render( + '_public_status_card', + name => $name, + privacy => $user, + journey => $status, + from_profile => $self->param('profile') ? 1 : 0, + ); +} + +sub checked_in { + my ($self) = @_; + + my $uid = $self->current_user->{id}; + my @journeys = $self->in_transit->get_timeline( + uid => $uid, + with_data => 1 + ); + + if ( $self->param('ajax') ) { + delete $self->stash->{layout}; + $self->render( + '_timeline-checked-in', + journeys => [@journeys], + ); + } + else { + $self->render( + 'timeline-checked-in', + journeys => [@journeys], + ); + } +} + +1; diff --git a/lib/Travelynx/Controller/Static.pm b/lib/Travelynx/Controller/Static.pm index 2c35f1b..04c2d0f 100644 --- a/lib/Travelynx/Controller/Static.pm +++ b/lib/Travelynx/Controller/Static.pm @@ -1,26 +1,32 @@ package Travelynx::Controller::Static; -use Mojo::Base 'Mojolicious::Controller'; -my $travelynx_version = qx{git describe --dirty} || 'experimental'; +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later +use Mojo::Base 'Mojolicious::Controller'; sub about { my ($self) = @_; - $self->render( 'about', - version => $self->app->config->{version} // 'UNKNOWN' ); + $self->render( 'about', title => 'Über travelynx' ); } sub changelog { my ($self) = @_; - $self->render( 'changelog', - version => $self->app->config->{version} // 'UNKNOWN' ); + $self->render( 'changelog', title => 'travelynx: Changelog' ); } sub imprint { my ($self) = @_; - $self->render('imprint'); + $self->render( 'imprint', title => 'travelynx: Impressum' ); +} + +sub legend { + my ($self) = @_; + + $self->render( 'legend', title => 'travelynx: Legende' ); } sub offline { diff --git a/lib/Travelynx/Controller/Traewelling.pm b/lib/Travelynx/Controller/Traewelling.pm new file mode 100644 index 0000000..3cdeff8 --- /dev/null +++ b/lib/Travelynx/Controller/Traewelling.pm @@ -0,0 +1,154 @@ +package Travelynx::Controller::Traewelling; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later +use Mojo::Base 'Mojolicious::Controller'; +use Mojo::Promise; + +sub oauth { + my ($self) = @_; + + if ( $self->param('action') + and $self->validation->csrf_protect->has_error('csrf_token') ) + { + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); + return; + } + + $self->render_later; + + my $oa = $self->config->{traewelling}{oauth}; + + return $self->oauth2->get_token_p( + traewelling => { + redirect_uri => + $self->base_url_for('/oauth/traewelling')->to_abs->scheme( + $self->app->mode eq 'development' ? 'http' : 'https' + )->to_string, + scope => 'read-statuses write-statuses' + } + )->then( + sub { + my ($provider) = @_; + if ( not defined $provider ) { + + # OAuth2 plugin performed a redirect, no need to render + return; + } + if ( not $provider or not $provider->{access_token} ) { + $self->flash( new_traewelling => 1 ); + $self->flash( login_error => 'no token received' ); + $self->redirect_to('/account/traewelling'); + return; + } + my $uid = $self->current_user->{id}; + my $token = $provider->{access_token}; + $self->traewelling->link( + uid => $self->current_user->{id}, + token => $provider->{access_token}, + refresh_token => $provider->{refresh_token}, + expires_in => $provider->{expires_in}, + ); + return $self->traewelling_api->get_user_p( $uid, $token )->then( + sub { + $self->flash( new_traewelling => 1 ); + $self->redirect_to('/account/traewelling'); + } + ); + } + )->catch( + sub { + my ($err) = @_; + say "error $err"; + $self->flash( new_traewelling => 1 ); + $self->flash( login_error => $err ); + $self->redirect_to('/account/traewelling'); + return; + } + ); +} + +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( + 'bad_request', + csrf => 1, + status => 400 + ); + return; + } + + if ( $self->param('action') and $self->param('action') eq 'logout' ) { + $self->render_later; + my $traewelling = $self->traewelling->get( uid => $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, + toot => $self->param('toot') ? 1 : 0, + tweet => $self->param('tweet') ? 1 : 0, + ); + $self->flash( success => 'traewelling' ); + $self->redirect_to('account'); + return; + } + + my $traewelling = $self->traewelling->get( uid => $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' ); + } + if ( $traewelling->{data}{toot} ) { + $self->param( toot => 1 ); + } + if ( $traewelling->{data}{tweet} ) { + $self->param( tweet => 1 ); + } + + $self->stash( title => 'travelynx × träwelling' ); + $self->render( + 'traewelling', + traewelling => $traewelling, + ); +} + +1; diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm index 56de0fb..89385e1 100755 --- a/lib/Travelynx/Controller/Traveling.pm +++ b/lib/Travelynx/Controller/Traveling.pm @@ -1,108 +1,465 @@ package Travelynx::Controller::Traveling; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Controller'; use DateTime; use DateTime::Format::Strptime; -use List::Util qw(uniq); -use List::UtilsBy qw(uniq_by); +use List::Util qw(uniq min max); +use List::UtilsBy qw(max_by uniq_by); use List::MoreUtils qw(first_index); +use Mojo::Promise; +use Text::CSV; use Travel::Status::DE::IRIS::Stations; -sub homepage { - my ($self) = @_; - if ( $self->is_user_authenticated ) { - $self->render( - 'landingpage', - version => $self->app->config->{version} // 'UNKNOWN', - with_autocomplete => 1, - with_geolocation => 1 - ); - $self->mark_seen( $self->current_user->{id} ); - } - else { - $self->render( - 'landingpage', - version => $self->app->config->{version} // 'UNKNOWN', - intro => 1 - ); +# Internal Helpers + +sub has_str_in_list { + my ( $str, @strs ) = @_; + if ( List::Util::any { $str eq $_ } @strs ) { + return 1; } + return; } -sub user_status { - my ($self) = @_; +sub get_connecting_trains_p { + my ( $self, %opt ) = @_; - my $name = $self->stash('name'); - my $ts = $self->stash('ts'); - my $user = $self->get_privacy_by_name($name); + my $uid = $opt{uid} //= $self->current_user->{id}; + my $use_history = $self->users->use_history( uid => $uid ); - if ( $user and ( $user->{public_level} & 0x02 ) ) { - my $status = $self->get_user_status( $user->{id} ); + my ( $eva, $exclude_via, $exclude_train_id, $exclude_before ); + my $now = $self->now->epoch; + my ( $stationinfo, $arr_epoch, $arr_platform, $arr_countdown ); - my %tw_data = ( - card => 'summary', - site => '@derfnull', - image => $self->url_for('/static/icons/icon-512x512.png') - ->to_abs->scheme('https'), - ); + my $promise = Mojo::Promise->new; - if ( - $ts - and ( not $status->{checked_in} - or $status->{sched_departure}->epoch != $ts ) - ) - { - $tw_data{title} = "Bahnfahrt beendet"; - $tw_data{description} = "${name} hat das Ziel erreicht"; - } - elsif ( $status->{checked_in} ) { - $tw_data{title} = "${name} ist unterwegs"; - $tw_data{description} = sprintf( - '%s %s von %s nach %s', - $status->{train_type}, - $status->{train_line} // $status->{train_no}, - $status->{dep_name}, - $status->{arr_name} // 'irgendwo' - ); - if ( $status->{real_arrival}->epoch ) { - $tw_data{description} .= $status->{real_arrival} - ->strftime(' – Ankunft gegen %H:%M Uhr'); - } + if ( $opt{eva} ) { + if ( $use_history & 0x01 ) { + $eva = $opt{eva}; } - else { - $tw_data{title} = "${name} ist gerade nicht eingecheckt"; - $tw_data{description} = "Letztes Fahrtziel: $status->{arr_name}"; + elsif ( $opt{destination_name} ) { + $eva = $opt{eva}; + } + } + else { + if ( $use_history & 0x02 ) { + my $status = $self->get_user_status; + $eva = $status->{arr_eva}; + $exclude_via = $status->{dep_name}; + $exclude_train_id = $status->{train_id}; + $arr_platform = $status->{arr_platform}; + $stationinfo = $status->{extra_data}{stationinfo_arr}; + if ( $status->{real_arrival} ) { + $exclude_before = $arr_epoch = $status->{real_arrival}->epoch; + $arr_countdown = $status->{arrival_countdown}; + } } + } - $self->render( - 'user_status', - name => $name, - journey => $status, - twitter => \%tw_data, - ); + $exclude_before //= $now - 300; + + if ( not $eva ) { + return $promise->reject; + } + + my ( $dest_ids, $destinations ) + = $self->journeys->get_connection_targets(%opt); + + my @destinations = uniq_by { $_->{name} } @{$destinations}; + + if ($exclude_via) { + @destinations = grep { $_->{name} ne $exclude_via } @destinations; + } + + if ( not @destinations ) { + return $promise->reject; + } + + my $iris_eva = $eva; + if ( $eva < 8000000 ) { + $iris_eva = ( List::Util::first { $_ >= 8000000 } @{$dest_ids} ) + // $eva; + } + + my $can_check_in = not $arr_epoch || ( $arr_countdown // 1 ) < 0; + my $lookahead + = $can_check_in ? 40 : ( ( ${arr_countdown} // 0 ) / 60 + 40 ); + + my $iris_promise = Mojo::Promise->new; + my %via_count = map { $_->{name} => 0 } @destinations; + + if ( $iris_eva >= 8000000 + and List::Util::any { $_->{eva} >= 8000000 } @destinations ) + { + $self->iris->get_departures_p( + station => $iris_eva, + lookbehind => 10, + lookahead => $lookahead, + with_related => 1 + )->then( + sub { + my ($stationboard) = @_; + if ( $stationboard->{errstr} ) { + $iris_promise->resolve( [] ); + return; + } + + @{ $stationboard->{results} } = map { $_->[0] } + sort { $a->[1] <=> $b->[1] } + map { [ $_, $_->departure ? $_->departure->epoch : 0 ] } + @{ $stationboard->{results} }; + my @results; + my @cancellations; + my $excluded_train; + for my $train ( @{ $stationboard->{results} } ) { + if ( not $train->departure ) { + next; + } + if ( $exclude_before + and $train->departure + and $train->departure->epoch < $exclude_before ) + { + next; + } + if ( $exclude_train_id + and $train->train_id eq $exclude_train_id ) + { + $excluded_train = $train; + next; + } + + # In general, this function is meant to return feasible + # connections. However, cancelled connections may also be of + # interest and are also useful for logging cancellations. + # To satisfy both demands with (hopefully) little confusion and + # UI clutter, this function returns two concatenated arrays: + # actual connections (ordered by actual departure time) followed + # by cancelled connections (ordered by scheduled departure time). + # This is easiest to achieve in two separate loops. + # + # Note that a cancelled train may still have a matching destination + # in its route_post, e.g. if it leaves out $eva due to + # unscheduled route changes but continues on schedule afterwards + # -- so it is only cancelled at $eva, not on the remainder of + # the route. Also note that this specific case is not yet handled + # properly by the cancellation logic etc. + + if ( $train->departure_is_cancelled ) { + my @via = ( + $train->sched_route_post, $train->sched_route_end + ); + for my $dest (@destinations) { + if ( has_str_in_list( $dest->{name}, @via ) ) { + push( @cancellations, [ $train, $dest ] ); + next; + } + } + } + else { + my @via = ( $train->route_post, $train->route_end ); + for my $dest (@destinations) { + if ( $via_count{ $dest->{name} } < 2 + and has_str_in_list( $dest->{name}, @via ) ) + { + push( @results, [ $train, $dest ] ); + + # Show all past and up to two future departures per destination + if ( not $train->departure + or $train->departure->epoch >= $now ) + { + $via_count{ $dest->{name} }++; + } + next; + } + } + } + } + + @results = map { $_->[0] } + sort { $a->[1] <=> $b->[1] } + map { + [ + $_, + $_->[0]->departure->epoch + // $_->[0]->sched_departure->epoch + ] + } @results; + @cancellations = map { $_->[0] } + sort { $a->[1] <=> $b->[1] } + map { [ $_, $_->[0]->sched_departure->epoch ] } + @cancellations; + + # remove trains whose route matches the excluded one's + if ($excluded_train) { + my $route_pre + = join( '|', reverse $excluded_train->route_pre ); + @results + = grep { join( '|', $_->[0]->route_post ) ne $route_pre } + @results; + my $route_post = join( '|', $excluded_train->route_post ); + @results + = grep { join( '|', $_->[0]->route_post ) ne $route_post } + @results; + } + + # add message IDs and 'transfer short' hints + for my $result (@results) { + my $train = $result->[0]; + my @message_ids + = List::Util::uniq map { $_->[1] } $train->raw_messages; + $train->{message_id} = { map { $_ => 1 } @message_ids }; + my $interchange_duration; + if ( exists $stationinfo->{i} ) { + if ( defined $arr_platform + and defined $train->platform ) + { + $interchange_duration + = $stationinfo->{i}{$arr_platform} + { $train->platform }; + } + $interchange_duration //= $stationinfo->{i}{"*"}; + } + if ( defined $interchange_duration ) { + my $interchange_time + = ( $train->departure->epoch - $arr_epoch ) / 60; + if ( $interchange_time < $interchange_duration ) { + $train->{interchange_text} = 'Anschluss knapp'; + $train->{interchange_icon} = 'directions_run'; + } + elsif ( $interchange_time == $interchange_duration ) { + $train->{interchange_text} + = 'Anschluss könnte knapp werden'; + $train->{interchange_icon} = 'directions_run'; + } + } + } + + $iris_promise->resolve( [ @results, @cancellations ] ); + return; + } + )->catch( + sub { + $iris_promise->resolve( [] ); + return; + } + )->wait; } else { - $self->render('not_found'); + $iris_promise->resolve( [] ); } -} -sub public_status_card { - my ($self) = @_; + my $hafas_promise = Mojo::Promise->new; + $self->hafas->get_departures_p( + eva => $eva, + lookbehind => 10, + lookahead => $lookahead + )->then( + sub { + my ($status) = @_; + $hafas_promise->resolve( [ $status->results ] ); + return; + } + )->catch( + sub { + # HAFAS data is optional. + # Errors are logged by get_json_p and can be silently ignored here. + $hafas_promise->resolve( [] ); + return; + } + )->wait; + + Mojo::Promise->all( $iris_promise, $hafas_promise )->then( + sub { + my ( $iris, $hafas ) = @_; + my @iris_trains = @{ $iris->[0] }; + my @all_hafas_trains = @{ $hafas->[0] }; + my @hafas_trains; + + # We've already got a list of connecting trains; this function + # only adds further information to them. We ignore errors, as + # partial data is better than no data. + eval { + for my $iris_train (@iris_trains) { + if ( $iris_train->[0]->departure_is_cancelled ) { + for my $hafas_train (@all_hafas_trains) { + if ( $hafas_train->number + and $hafas_train->number + == $iris_train->[0]->train_no ) + { + $hafas_train->{iris_seen} = 1; + next; + } + } + next; + } + for my $hafas_train (@all_hafas_trains) { + if ( $hafas_train->number + and $hafas_train->number + == $iris_train->[0]->train_no ) + { + $hafas_train->{iris_seen} = 1; + if ( $hafas_train->load + and $hafas_train->load->{SECOND} ) + { + $iris_train->[3] = $hafas_train->load; + } + for my $stop ( $hafas_train->route ) { + if ( $stop->loc->name + and $stop->loc->name eq + $iris_train->[1]->{name} + and $stop->arr ) + { + $iris_train->[2] = $stop->arr; + if ( $iris_train->[0]->departure_delay + and not $stop->arr_delay ) + { + $iris_train->[2] + ->add( minutes => $iris_train->[0] + ->departure_delay ); + } + last; + } + } + last; + } + } + } + for my $hafas_train (@all_hafas_trains) { + if ( $hafas_train->{iris_seen} ) { + next; + } + if ( $hafas_train->station_eva >= 8000000 ) { + + # better safe than sorry, for now + next; + } + for my $stop ( $hafas_train->route ) { + for my $dest (@destinations) { + if ( $stop->loc->name + and $stop->loc->name eq $dest->{name} + and $via_count{ $dest->{name} } < 2 + and $hafas_train->datetime ) + { + my $departure = $hafas_train->datetime; + my $arrival = $stop->arr; + my $delay = $hafas_train->delay; + if ( $delay + and $stop->arr == $stop->sched_arr ) + { + $arrival->add( minutes => $delay ); + } + if ( $departure->epoch >= $exclude_before ) { + $via_count{ $dest->{name} }++; + push( @hafas_trains, + [ $hafas_train, $dest, $arrival ] ); + } + } + } + } + } + }; + if ($@) { + $self->app->log->error( + "get_connecting_trains_p($uid): IRIS/HAFAS merge failed: $@" + ); + } + + $promise->resolve( \@iris_trains, \@hafas_trains ); + return; + } + )->catch( + sub { + my ($err) = @_; + $promise->reject($err); + return; + } + )->wait; - my $name = $self->stash('name'); - my $user = $self->get_privacy_by_name($name); + return $promise; +} - delete $self->stash->{layout}; +sub compute_effective_visibility { + my ( $self, $default_visibility, $journey_visibility ) = @_; + if ( $journey_visibility eq 'default' ) { + return $default_visibility; + } + return $journey_visibility; +} - if ( $user and ( $user->{public_level} & 0x02 ) ) { - my $status = $self->get_user_status( $user->{id} ); +# Controllers + +sub homepage { + my ($self) = @_; + if ( $self->is_user_authenticated ) { + my $uid = $self->current_user->{id}; + my $status = $self->get_user_status; + my @timeline = $self->in_transit->get_timeline( + uid => $uid, + short => 1 + ); + $self->stash( timeline => [@timeline] ); + my @recent_targets; + if ( $status->{checked_in} ) { + my $journey_visibility + = $self->compute_effective_visibility( + $self->current_user->{default_visibility_str}, + $status->{visibility_str} ); + if ( defined $status->{arrival_countdown} + and $status->{arrival_countdown} < ( 40 * 60 ) ) + { + $self->render_later; + $self->get_connecting_trains_p->then( + sub { + my ( $connections_iris, $connections_hafas ) = @_; + $self->render( + 'landingpage', + user_status => $status, + journey_visibility => $journey_visibility, + connections_iris => $connections_iris, + connections_hafas => $connections_hafas, + ); + $self->users->mark_seen( uid => $uid ); + } + )->catch( + sub { + $self->render( + 'landingpage', + user_status => $status, + journey_visibility => $journey_visibility, + ); + $self->users->mark_seen( uid => $uid ); + } + )->wait; + return; + } + else { + $self->render( + 'landingpage', + user_status => $status, + journey_visibility => $journey_visibility, + ); + $self->users->mark_seen( uid => $uid ); + return; + } + } + else { + @recent_targets = uniq_by { $_->{eva} } + $self->journeys->get_latest_checkout_stations( uid => $uid ); + } $self->render( - '_public_status_card', - name => $name, - journey => $status + 'landingpage', + user_status => $status, + recent_targets => \@recent_targets, + with_autocomplete => 1, + with_geolocation => 1 ); + $self->users->mark_seen( uid => $uid ); } else { - $self->render('not_found'); + $self->render( 'landingpage', intro => 1 ); } } @@ -112,10 +469,93 @@ sub status_card { delete $self->stash->{layout}; + my @timeline = $self->in_transit->get_timeline( + uid => $self->current_user->{id}, + short => 1 + ); + $self->stash( timeline => [@timeline] ); + if ( $status->{checked_in} ) { - $self->render( '_checked_in', journey => $status ); + my $journey_visibility + = $self->compute_effective_visibility( + $self->current_user->{default_visibility_str}, + $status->{visibility_str} ); + if ( defined $status->{arrival_countdown} + and $status->{arrival_countdown} < ( 40 * 60 ) ) + { + $self->render_later; + $self->get_connecting_trains_p->then( + sub { + my ( $connections_iris, $connections_hafas ) = @_; + $self->render( + '_checked_in', + journey => $status, + journey_visibility => $journey_visibility, + connections_iris => $connections_iris, + connections_hafas => $connections_hafas, + ); + } + )->catch( + sub { + $self->render( + '_checked_in', + journey => $status, + journey_visibility => $journey_visibility, + ); + } + )->wait; + return; + } + $self->render( + '_checked_in', + journey => $status, + journey_visibility => $journey_visibility, + ); + } + elsif ( $status->{cancellation} ) { + $self->render_later; + $self->get_connecting_trains_p( + eva => $status->{cancellation}{dep_eva}, + destination_name => $status->{cancellation}{arr_name} + )->then( + sub { + my ($connecting_trains) = @_; + $self->render( + '_cancelled_departure', + journey => $status->{cancellation}, + connections_iris => $connecting_trains + ); + } + )->catch( + sub { + $self->render( '_cancelled_departure', + journey => $status->{cancellation} ); + } + )->wait; + return; } else { + my @connecting_trains; + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + if ( $now->epoch - $status->{timestamp}->epoch < ( 30 * 60 ) ) { + $self->render_later; + $self->get_connecting_trains_p->then( + sub { + my ( $connections_iris, $connections_hafas ) = @_; + $self->render( + '_checked_out', + journey => $status, + connections_iris => $connections_iris, + connections_hafas => $connections_hafas, + ); + } + )->catch( + sub { + $self->render( '_checked_out', journey => $status ); + } + )->wait; + return; + } $self->render( '_checked_out', journey => $status ); } } @@ -128,38 +568,71 @@ sub geolocation { if ( not $lon or not $lat ) { $self->render( json => { error => 'Invalid lon/lat received' } ); + return; } - else { - my @candidates = map { - { - ds100 => $_->[0][0], - name => $_->[0][1], - eva => $_->[0][2], - lon => $_->[0][3], - lat => $_->[0][4], - distance => $_->[1], - } - } Travel::Status::DE::IRIS::Stations::get_station_by_location( $lon, - $lat, 10 ); - @candidates = uniq_by { $_->{name} } @candidates; - if ( @candidates > 5 ) { + $self->render_later; + + my @iris = map { + { + ds100 => $_->[0][0], + name => $_->[0][1], + eva => $_->[0][2], + lon => $_->[0][3], + lat => $_->[0][4], + distance => $_->[1], + hafas => 0, + } + } Travel::Status::DE::IRIS::Stations::get_station_by_location( $lon, + $lat, 10 ); + @iris = uniq_by { $_->{name} } @iris; + if ( @iris > 5 ) { + @iris = @iris[ 0 .. 4 ]; + } + + Travel::Status::DE::HAFAS->new_p( + promise => 'Mojo::Promise', + user_agent => $self->ua, + geoSearch => { + lat => $lat, + lon => $lon + } + )->then( + sub { + my ($hafas) = @_; + my @hafas = map { + { + name => $_->name, + eva => $_->eva, + distance => $_->distance_m / 1000, + hafas => 1 + } + } $hafas->results; + if ( @hafas > 10 ) { + @hafas = @hafas[ 0 .. 9 ]; + } + my @results = map { $_->[0] } + sort { $a->[1] <=> $b->[1] } + map { [ $_, $_->{distance} ] } ( @iris, @hafas ); $self->render( json => { - candidates => [ @candidates[ 0 .. 4 ] ], + candidates => [@results], } ); } - else { + )->catch( + sub { + my ($err) = @_; $self->render( json => { - candidates => [@candidates], + candidates => [@iris], + warning => $err, } ); } - } + )->wait; } -sub log_action { +sub travel_action { my ($self) = @_; my $params = $self->req->json; @@ -194,61 +667,124 @@ sub log_action { if ( $params->{action} eq 'checkin' ) { - my ( $train, $error ) - = $self->checkin( $params->{station}, $params->{train} ); - my $destination = $params->{dest}; + my $status = $self->get_user_status; + my $promise; - if ($error) { - $self->render( - json => { - success => 0, - error => $error, - }, - ); - } - elsif ( not $destination ) { - $self->render( - json => { - success => 1, - redirect_to => '/', - }, - ); + if ( $status->{checked_in} + and $status->{arr_eva} + and $status->{arrival_countdown} <= 0 ) + { + $promise = $self->checkout_p( station => $status->{arr_eva} ); } 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 $station_link = '/s/' . $destination; - $self->render( - json => { - success => 1, - redirect_to => $still_checked_in ? '/' : $station_link, - }, - ); + $promise = Mojo::Promise->resolve; } + + $self->render_later; + $promise->then( + sub { + return $self->checkin_p( + station => $params->{station}, + train_id => $params->{train} + ); + } + )->then( + sub { + my $destination = $params->{dest}; + if ( not $destination ) { + $self->render( + json => { + success => 1, + redirect_to => '/', + }, + ); + return; + } + + # Silently ignore errors -- if they are permanent, the user will see + # them when selecting the destination manually. + return $self->checkout_p( + station => $destination, + force => 0 + ); + } + )->then( + sub { + my ( $still_checked_in, undef ) = @_; + if ( my $destination = $params->{dest} ) { + my $station_link = '/s/' . $destination; + if ( $status->{train_id} =~ m{[|]} ) { + $station_link .= '?hafas=1'; + } + $self->render( + json => { + success => 1, + redirect_to => $still_checked_in + ? '/' + : $station_link, + }, + ); + } + return; + } + )->catch( + sub { + my ($error) = @_; + $self->render( + json => { + success => 0, + error => $error, + }, + ); + } + )->wait; } elsif ( $params->{action} eq 'checkout' ) { - my ( $still_checked_in, $error ) - = $self->checkout( $params->{station}, $params->{force} ); - my $station_link = '/s/' . $params->{station}; + $self->render_later; + my $status = $self->get_user_status; + $self->checkout_p( + station => $params->{station}, + force => $params->{force} + )->then( + sub { + my ( $still_checked_in, $error ) = @_; + my $station_link = '/s/' . $params->{station}; + if ( $status->{train_id} =~ m{[|]} ) { + $station_link .= '?hafas=1'; + } - if ($error) { - $self->render( - json => { - success => 0, - error => $error, - }, - ); - } - else { - $self->render( - json => { - success => 1, - redirect_to => $still_checked_in ? '/' : $station_link, - }, - ); - } + if ($error) { + $self->render( + json => { + success => 0, + error => $error, + }, + ); + } + else { + $self->render( + json => { + success => 1, + redirect_to => $still_checked_in + ? '/' + : $station_link, + }, + ); + } + return; + } + )->catch( + sub { + my ($error) = @_; + $self->render( + json => { + success => 0, + error => $error, + }, + ); + return; + } + )->wait; } elsif ( $params->{action} eq 'undo' ) { my $status = $self->get_user_status; @@ -264,7 +800,12 @@ sub log_action { else { my $redir = '/'; if ( $status->{checked_in} or $status->{cancelled} ) { - $redir = '/s/' . $status->{dep_ds100}; + if ( $status->{train_id} =~ m{[|]} ) { + $redir = '/s/' . $status->{dep_eva} . '?hafas=1'; + } + else { + $redir = '/s/' . $status->{dep_ds100}; + } } $self->render( json => { @@ -275,50 +816,77 @@ sub log_action { } } elsif ( $params->{action} eq 'cancelled_from' ) { - my ( undef, $error ) - = $self->checkin( $params->{station}, $params->{train} ); - - if ($error) { - $self->render( - json => { - success => 0, - error => $error, - }, - ); - } - else { - $self->render( - json => { - success => 1, - redirect_to => '/', - }, - ); - } + $self->render_later; + $self->checkin_p( + station => $params->{station}, + train_id => $params->{train} + )->then( + sub { + $self->render( + json => { + success => 1, + redirect_to => '/', + }, + ); + } + )->catch( + sub { + my ($error) = @_; + $self->render( + json => { + success => 0, + error => $error, + }, + ); + } + )->wait; } elsif ( $params->{action} eq 'cancelled_to' ) { - my ( undef, $error ) - = $self->checkout( $params->{station}, 1 ); - - if ($error) { - $self->render( - json => { - success => 0, - error => $error, - }, - ); - } - else { - $self->render( - json => { - success => 1, - redirect_to => '/', - }, - ); - } + $self->render_later; + $self->checkout_p( + station => $params->{station}, + force => 1 + )->then( + sub { + my ( undef, $error ) = @_; + if ($error) { + $self->render( + json => { + success => 0, + error => $error, + }, + ); + } + else { + $self->render( + json => { + success => 1, + redirect_to => '/', + }, + ); + } + return; + } + )->catch( + sub { + my ($error) = @_; + $self->render( + json => { + success => 0, + error => $error, + }, + ); + return; + } + )->wait; } elsif ( $params->{action} eq 'delete' ) { - my $error = $self->delete_journey( $params->{id}, $params->{checkin}, - $params->{checkout} ); + my $error = $self->journeys->delete( + uid => $self->current_user->{id}, + id => $params->{id}, + checkin => $params->{checkin}, + checkout => $params->{checkout} + ); if ($error) { $self->render( json => { @@ -347,57 +915,277 @@ sub log_action { } sub station { - my ($self) = @_; - my $station = $self->stash('station'); - my $train = $self->param('train'); + my ($self) = @_; + my $station = $self->stash('station'); + my $train = $self->param('train'); + my $trip_id = $self->param('trip_id'); + my $timestamp = $self->param('timestamp'); + my $uid = $self->current_user->{id}; + + my @timeline = $self->in_transit->get_timeline( + uid => $uid, + short => 1 + ); + my %checkin_by_train; + for my $checkin (@timeline) { + say $checkin->{train_id}; + push( @{ $checkin_by_train{ $checkin->{train_id} } }, $checkin ); + } + $self->stash( checkin_by_train => \%checkin_by_train ); - my $status = $self->get_departures( $station, 120, 30, 1 ); + $self->render_later; - if ( $status->{errstr} ) { - $self->render( - 'landingpage', - version => $self->app->config->{version} // 'UNKNOWN', - with_autocomplete => 1, - with_geolocation => 1, - error => $status->{errstr} + if ( $timestamp and $timestamp =~ m{ ^ \d+ $ }x ) { + $timestamp = DateTime->from_epoch( + epoch => $timestamp, + time_zone => 'Europe/Berlin' ); } else { - # You can't check into a train which terminates here - my @results = grep { $_->departure } @{ $status->{results} }; - - @results = map { $_->[0] } - sort { $b->[1] <=> $a->[1] } - map { [ $_, $_->departure->epoch // $_->sched_departure->epoch ] } - @results; - - if ($train) { - @results - = grep { $_->type . ' ' . $_->train_no eq $train } @results; - } + $timestamp = DateTime->now( time_zone => 'Europe/Berlin' ); + } - $self->render( - 'departures', - ds100 => $status->{station_ds100}, - results => \@results, - station => $status->{station_name}, - related_stations => $status->{related_stations}, - title => "travelynx: $status->{station_name}", + my $use_hafas = $self->param('hafas'); + my $promise; + if ($use_hafas) { + $promise = $self->hafas->get_departures_p( + eva => $station, + timestamp => $timestamp, + lookbehind => 30, + lookahead => 30, ); } - $self->mark_seen( $self->current_user->{id} ); + else { + $promise = $self->iris->get_departures_p( + station => $station, + lookbehind => 120, + lookahead => 30, + with_related => 1, + ); + } + $promise->then( + sub { + my ($status) = @_; + my $api_link; + my @results; + + my $now = $self->now->epoch; + my $now_within_range + = abs( $timestamp->epoch - $now ) < 1800 ? 1 : 0; + + if ($use_hafas) { + + my $iris_eva = List::Util::min grep { $_ >= 1000000 } + @{ $status->station->{evas} // [] }; + if ($iris_eva) { + $api_link = '/s/' . $iris_eva; + } + + @results = map { $_->[0] } + sort { $b->[1] <=> $a->[1] } + map { [ $_, $_->datetime->epoch ] } $status->results; + $self->stations->add_meta( + eva => $status->station->{eva}, + meta => $status->station->{evas} // [] + ); + $status = { + station_eva => $status->station->{eva}, + station_name => ( + List::Util::reduce { length($a) < length($b) ? $a : $b } + @{ $status->station->{names} } + ), + related_stations => [], + }; + } + else { + + $api_link = '/s/' . $status->{station_eva} . '?hafas=1'; + + # You can't check into a train which terminates here + @results = grep { $_->departure } @{ $status->{results} }; + + @results = map { $_->[0] } + sort { $b->[1] <=> $a->[1] } + map { + [ $_, $_->departure->epoch // $_->sched_departure->epoch ] + } @results; + } + + my $user_status = $self->get_user_status; + + my $can_check_out = 0; + if ( $user_status->{checked_in} ) { + for my $stop ( @{ $user_status->{route_after} } ) { + if ( + $stop->[1] eq $status->{station_eva} + or List::Util::any { $stop->[1] eq $_->{uic} } + @{ $status->{related_stations} } + ) + { + $can_check_out = 1; + last; + } + } + } + + my $connections_p; + if ( $trip_id and $use_hafas ) { + @results = grep { $_->id eq $trip_id } @results; + } + elsif ( $train and not $use_hafas ) { + @results + = grep { $_->type . ' ' . $_->train_no eq $train } @results; + } + else { + if ( $user_status->{cancellation} + and $status->{station_eva} eq + $user_status->{cancellation}{dep_eva} ) + { + $connections_p = $self->get_connecting_trains_p( + eva => $user_status->{cancellation}{dep_eva}, + destination_name => + $user_status->{cancellation}{arr_name} + ); + } + else { + $connections_p = $self->get_connecting_trains_p( + eva => $status->{station_eva} ); + } + } + + if ($connections_p) { + $connections_p->then( + sub { + my ( $connections_iris, $connections_hafas ) = @_; + $self->render( + 'departures', + eva => $status->{station_eva}, + datetime => $timestamp, + now_in_range => $now_within_range, + results => \@results, + hafas => $use_hafas, + station => $status->{station_name}, + related_stations => $status->{related_stations}, + user_status => $user_status, + can_check_out => $can_check_out, + connections_iris => $connections_iris, + connections_hafas => $connections_hafas, + api_link => $api_link, + title => "travelynx: $status->{station_name}", + ); + } + )->catch( + sub { + $self->render( + 'departures', + eva => $status->{station_eva}, + datetime => $timestamp, + now_in_range => $now_within_range, + results => \@results, + hafas => $use_hafas, + station => $status->{station_name}, + related_stations => $status->{related_stations}, + user_status => $user_status, + can_check_out => $can_check_out, + api_link => $api_link, + title => "travelynx: $status->{station_name}", + ); + } + )->wait; + } + else { + $self->render( + 'departures', + eva => $status->{station_eva}, + datetime => $timestamp, + now_in_range => $now_within_range, + results => \@results, + hafas => $use_hafas, + station => $status->{station_name}, + related_stations => $status->{related_stations}, + user_status => $user_status, + can_check_out => $can_check_out, + api_link => $api_link, + title => "travelynx: $status->{station_name}", + ); + } + } + )->catch( + sub { + my ( $err, $status ) = @_; + if ( $status and $status->{suggestions} ) { + $self->render( + 'disambiguation', + suggestions => $status->{suggestions}, + status => 300, + ); + } + elsif ( $use_hafas and $status and $status->errcode eq 'LOCATION' ) + { + $self->hafas->search_location_p( query => $station )->then( + sub { + my ($hafas2) = @_; + my @suggestions = $hafas2->results; + if ( @suggestions == 1 ) { + $self->redirect_to( + '/s/' . $suggestions[0]->eva . '?hafas=1' ); + } + else { + $self->render( + 'disambiguation', + suggestions => [ + map { { name => $_->name, eva => $_->eva } } + @suggestions + ], + status => 300, + ); + } + } + )->catch( + sub { + my ($err2) = @_; + $self->render( + 'exception', + exception => +"locationSearch threw '$err2' when handling '$err'", + status => 502 + ); + } + )->wait; + } + else { + $self->render( + 'exception', + exception => $err, + status => 502 + ); + } + } + )->wait; + $self->users->mark_seen( uid => $uid ); } sub redirect_to_station { my ($self) = @_; my $station = $self->param('station'); - $self->redirect_to("/s/${station}"); + if ( my $s = $self->app->stations->search($station) ) { + if ( $s->{source} == 1 ) { + $self->redirect_to("/s/${station}?hafas=1"); + } + else { + $self->redirect_to("/s/${station}"); + } + } + else { + $self->redirect_to("/s/${station}?hafas=1"); + } } sub cancelled { my ($self) = @_; - my @journeys = $self->get_user_travels( + my @journeys = $self->journeys->get( + uid => $self->current_user->{id}, cancelled => 1, with_datetime => 1 ); @@ -414,7 +1202,122 @@ sub cancelled { sub history { my ($self) = @_; - $self->render( template => 'history' ); + $self->render( + template => 'history', + title => 'travelynx: History' + ); +} + +sub commute { + my ($self) = @_; + + my $year = $self->param('year'); + my $filter_type = $self->param('filter_type') || 'exact'; + my $station = $self->param('station'); + + # DateTime is very slow when looking far into the future due to DST changes + # -> Limit time range to avoid accidental DoS. + if ( + not( $year + and $year =~ m{ ^ [0-9]{4} $ }x + and $year > 1990 + and $year < 2100 ) + ) + { + $year = DateTime->now( time_zone => 'Europe/Berlin' )->year - 1; + } + my $interval_start = DateTime->new( + time_zone => 'Europe/Berlin', + year => $year, + month => 1, + day => 1, + hour => 0, + minute => 0, + second => 0, + ); + my $interval_end = $interval_start->clone->add( years => 1 ); + + my @journeys = $self->journeys->get( + uid => $self->current_user->{id}, + after => $interval_start, + before => $interval_end, + with_datetime => 1, + ); + + if ( not $station ) { + my %candidate_count; + for my $journey (@journeys) { + my $dep = $journey->{rt_departure}; + my $arr = $journey->{rt_arrival}; + if ( $arr->dow <= 5 and $arr->hour <= 12 ) { + $candidate_count{ $journey->{to_name} }++; + } + elsif ( $dep->dow <= 5 and $dep->hour > 12 ) { + $candidate_count{ $journey->{from_name} }++; + } + else { + # Avoid selecting an intermediate station for multi-leg commutes. + # Assumption: The intermediate station is also used for private + # travels -> penalize stations which are used on weekends or at + # unexpected times. + $candidate_count{ $journey->{from_name} }--; + $candidate_count{ $journey->{to_name} }--; + } + } + $station = max_by { $candidate_count{$_} } keys %candidate_count; + } + + my %journeys_by_month; + my %count_by_month; + my $total = 0; + + my $prev_doy = 0; + for my $journey ( reverse @journeys ) { + my $month = $journey->{rt_departure}->month; + if ( + ( + $filter_type eq 'exact' and ( $journey->{to_name} eq $station + or $journey->{from_name} eq $station ) + ) + or ( + $filter_type eq 'substring' + and ( $journey->{to_name} =~ m{\Q$station\E} + or $journey->{from_name} =~ m{\Q$station\E} ) + ) + or ( + $filter_type eq 'regex' + and ( $journey->{to_name} =~ m{$station} + or $journey->{from_name} =~ m{$station} ) + ) + ) + { + push( @{ $journeys_by_month{$month} }, $journey ); + + my $doy = $journey->{rt_departure}->day_of_year; + if ( $doy != $prev_doy ) { + $count_by_month{$month}++; + $total++; + } + + $prev_doy = $doy; + } + } + + $self->param( year => $year ); + $self->param( filter_type => $filter_type ); + $self->param( station => $station ); + + $self->render( + template => 'commute', + with_autocomplete => 1, + journeys_by_month => \%journeys_by_month, + count_by_month => \%count_by_month, + total_journeys => $total, + title => 'travelynx: Reisen nach Station', + months => [ + qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember) + ], + ); } sub map_history { @@ -422,116 +1325,278 @@ sub map_history { my $location = $self->app->coordinates_by_station; - my @journeys = $self->get_user_travels; + if ( not $self->param('route_type') ) { + $self->param( route_type => 'polybee' ); + } + + my $route_type = $self->param('route_type'); + my $filter_from = $self->param('filter_from'); + my $filter_until = $self->param('filter_to'); + my $filter_type = $self->param('filter_type'); + my $with_polyline = $route_type eq 'beeline' ? 0 : 1; + + my $parser = DateTime::Format::Strptime->new( + pattern => '%d.%m.%Y', + locale => 'de_DE', + time_zone => 'Europe/Berlin' + ); + + if ( $filter_from + and $filter_from =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x ) + { + $filter_from = $parser->parse_datetime($filter_from); + } + else { + $filter_from = undef; + } + + if ( $filter_until + and $filter_until =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x ) + { + $filter_until = $parser->parse_datetime($filter_until)->set( + hour => 23, + minute => 59, + second => 58 + ); + } + else { + $filter_until = undef; + } + + my $year; + if ( $filter_from + and $filter_from->day == 1 + and $filter_from->month == 1 + and $filter_until + and $filter_until->day == 31 + and $filter_until->month == 12 + and $filter_from->year == $filter_until->year ) + { + $year = $filter_from->year; + } + + my @journeys = $self->journeys->get( + uid => $self->current_user->{id}, + with_polyline => $with_polyline, + after => $filter_from, + before => $filter_until, + ); + + if ($filter_type) { + my @filter = split( qr{, *}, $filter_type ); + @journeys + = grep { has_str_in_list( $_->{type}, @filter ) } @journeys; + } if ( not @journeys ) { $self->render( template => 'history_map', with_map => 1, + skipped_journeys => [], station_coordinates => [], - station_pairs => [], + polyline_groups => [], ); return; } - my $first_departure = $journeys[-1]->{rt_departure}; - my $last_departure = $journeys[0]->{rt_departure}; + my $include_manual = $self->param('include_manual') ? 1 : 0; - my @stations = uniq map { $_->{to_name} } @journeys; - push( @stations, uniq map { $_->{from_name} } @journeys ); - @stations = uniq @stations; - my @station_coordinates = map { [ $location->{$_}, $_ ] } - grep { exists $location->{$_} } @stations; + my $res = $self->journeys_to_map_data( + journeys => \@journeys, + route_type => $route_type, + include_manual => $include_manual + ); - my @station_pairs; - my %seen; + $self->render( + template => 'history_map', + year => $year, + with_map => 1, + title => 'travelynx: Karte', + %{$res} + ); +} + +sub json_history { + my ($self) = @_; + + $self->render( + json => [ $self->journeys->get( uid => $self->current_user->{id} ) ] ); +} - for my $journey (@journeys) { +sub csv_history { + my ($self) = @_; - my @route = map { $_->[0] } @{ $journey->{route} }; - my $from_index = first_index { $_ eq $journey->{from_name} } @route; - my $to_index = first_index { $_ eq $journey->{to_name} } @route; + my $csv = Text::CSV->new( { eol => "\r\n" } ); + my $buf = q{}; + + $csv->combine( + qw(Zugtyp Linie Nummer Start Ziel), + 'Start (DS100)', + 'Ziel (DS100)', + 'Abfahrt (soll)', + 'Abfahrt (ist)', + 'Ankunft (soll)', + 'Ankunft (ist)', + 'Kommentar', + 'ID' + ); + $buf .= $csv->string; - if ( $from_index == -1 - or $to_index == -1 - or $journey->{edited} == 0x3fff ) + for my $journey ( + $self->journeys->get( + uid => $self->current_user->{id}, + with_datetime => 1 + ) + ) + { + if ( + $csv->combine( + $journey->{type}, + $journey->{line}, + $journey->{no}, + $journey->{from_name}, + $journey->{to_name}, + $journey->{from_ds100}, + $journey->{to_ds100}, + $journey->{sched_departure}->strftime('%Y-%m-%d %H:%M'), + $journey->{rt_departure}->strftime('%Y-%m-%d %H:%M'), + $journey->{sched_arrival}->strftime('%Y-%m-%d %H:%M'), + $journey->{rt_arrival}->strftime('%Y-%m-%d %H:%M'), + $journey->{user_data}{comment} // q{}, + $journey->{id} + ) + ) { - next; + $buf .= $csv->string; } + } - @route = @route[ $from_index .. $to_index ]; - - my $key = join( '|', @route ); + $self->render( + text => $buf, + format => 'csv' + ); +} - if ( $seen{$key} ) { - next; - } +sub year_in_review { + my ($self) = @_; + my $year = $self->stash('year'); + my @journeys; - $seen{$key} = 1; + # DateTime is very slow when looking far into the future due to DST changes + # -> Limit time range to avoid accidental DoS. + if ( not( $year =~ m{ ^ [0-9]{4} $ }x and $year > 1990 and $year < 2100 ) ) + { + $self->render( 'not_found', status => 404 ); + return; + } - # direction does not matter at the moment - $seen{ join( '|', reverse @route ) } = 1; + my $interval_start = DateTime->new( + time_zone => 'Europe/Berlin', + year => $year, + month => 1, + day => 1, + hour => 0, + minute => 0, + second => 0, + ); + my $interval_end = $interval_start->clone->add( years => 1 ); + @journeys = $self->journeys->get( + uid => $self->current_user->{id}, + after => $interval_start, + before => $interval_end, + with_datetime => 1 + ); - my $prev_station = shift @route; - for my $station (@route) { - push( @station_pairs, [ $prev_station, $station ] ); - $prev_station = $station; - } + if ( not @journeys ) { + $self->render( + 'not_found', + message => 'Keine Zugfahrten im angefragten Jahr gefunden.', + status => 404 + ); + return; } - @station_pairs = uniq_by { $_->[0] . '|' . $_->[1] } @station_pairs; - @station_pairs - = grep { exists $location->{ $_->[0] } and exists $location->{ $_->[1] } } - @station_pairs; - @station_pairs - = map { [ $location->{ $_->[0] }, $location->{ $_->[1] } ] } - @station_pairs; + my $now = $self->now; + if ( + not( $year < $now->year or ( $now->month == 12 and $now->day == 31 ) ) ) + { + $self->render( + 'not_found', + message => +'Der aktuelle Jahresrückblick wird erst zum Jahresende (am 31.12.) freigeschaltet', + status => 404 + ); + return; + } - my @routes; + my ( $stats, $review ) = $self->journeys->get_stats( + uid => $self->current_user->{id}, + year => $year, + review => 1 + ); $self->render( - template => 'history_map', - with_map => 1, - station_coordinates => \@station_coordinates, - station_pairs => \@station_pairs, + 'year_in_review', + title => "travelynx: Jahresrückblick $year", + year => $year, + stats => $stats, + review => $review, ); -} - -sub json_history { - my ($self) = @_; - $self->render( json => [ $self->get_user_travels ] ); } sub yearly_history { my ($self) = @_; - my $year = $self->stash('year'); + my $year = $self->stash('year'); + my $filter = $self->param('filter'); my @journeys; - my $stats; # DateTime is very slow when looking far into the future due to DST changes # -> Limit time range to avoid accidental DoS. if ( not( $year =~ m{ ^ [0-9]{4} $ }x and $year > 1990 and $year < 2100 ) ) { - @journeys = $self->get_user_travels( with_datetime => 1 ); + $self->render( 'not_found', status => 404 ); + return; } - else { - my $interval_start = DateTime->new( - time_zone => 'Europe/Berlin', - year => $year, - month => 1, - day => 1, - hour => 0, - minute => 0, - second => 0, - ); - my $interval_end = $interval_start->clone->add( years => 1 ); - @journeys = $self->get_user_travels( - after => $interval_start, - before => $interval_end, - with_datetime => 1 + my $interval_start = DateTime->new( + time_zone => 'Europe/Berlin', + year => $year, + month => 1, + day => 1, + hour => 0, + minute => 0, + second => 0, + ); + my $interval_end = $interval_start->clone->add( years => 1 ); + @journeys = $self->journeys->get( + uid => $self->current_user->{id}, + after => $interval_start, + before => $interval_end, + with_datetime => 1 + ); + + if ( $filter and $filter eq 'single' ) { + @journeys = $self->journeys->grep_single(@journeys); + } + + if ( not @journeys ) { + $self->render( + 'not_found', + status => 404, + message => 'Keine Zugfahrten im angefragten Jahr gefunden.' ); - $stats = $self->get_journey_stats( year => $year ); + return; + } + + my $stats = $self->journeys->get_stats( + uid => $self->current_user->{id}, + year => $year + ); + + my $with_review; + my $now = $self->now; + if ( $year < $now->year or ( $now->month == 12 and $now->day == 31 ) ) { + $with_review = 1; } $self->respond_to( @@ -542,10 +1607,12 @@ sub yearly_history { } }, any => { - template => 'history_by_year', - journeys => [@journeys], - year => $year, - statistics => $stats + template => 'history_by_year', + title => "travelynx: $year", + journeys => [@journeys], + year => $year, + have_review => $with_review, + statistics => $stats } ); @@ -556,7 +1623,6 @@ sub monthly_history { my $year = $self->stash('year'); my $month = $self->stash('month'); my @journeys; - my $stats; my @months = ( qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember) @@ -571,30 +1637,43 @@ sub monthly_history { and $month < 13 ) ) { - @journeys = $self->get_user_travels( with_datetime => 1 ); + $self->render( 'not_found', status => 404 ); + return; } - else { - my $interval_start = DateTime->new( - time_zone => 'Europe/Berlin', - year => $year, - month => $month, - day => 1, - hour => 0, - minute => 0, - second => 0, - ); - my $interval_end = $interval_start->clone->add( months => 1 ); - @journeys = $self->get_user_travels( - after => $interval_start, - before => $interval_end, - with_datetime => 1 - ); - $stats = $self->get_journey_stats( - year => $year, - month => $month + my $interval_start = DateTime->new( + time_zone => 'Europe/Berlin', + year => $year, + month => $month, + day => 1, + hour => 0, + minute => 0, + second => 0, + ); + my $interval_end = $interval_start->clone->add( months => 1 ); + @journeys = $self->journeys->get( + uid => $self->current_user->{id}, + after => $interval_start, + before => $interval_end, + with_datetime => 1 + ); + + if ( not @journeys ) { + $self->render( + 'not_found', + message => 'Keine Zugfahrten im angefragten Monat gefunden.', + status => 404 ); + return; } + my $stats = $self->journeys->get_stats( + uid => $self->current_user->{id}, + year => $year, + month => $month + ); + + my $month_name = $months[ $month - 1 ]; + $self->respond_to( json => { json => { @@ -604,10 +1683,11 @@ sub monthly_history { }, any => { template => 'history_by_month', + title => "travelynx: $month_name $year", journeys => [@journeys], year => $year, month => $month, - month_name => $months[ $month - 1 ], + month_name => $month_name, statistics => $stats } ); @@ -618,36 +1698,89 @@ sub journey_details { my ($self) = @_; my $journey_id = $self->stash('id'); - my $uid = $self->current_user->{id}; + my $user = $self->current_user; + my $uid = $user->{id}; $self->param( journey_id => $journey_id ); - if ( not($journey_id) ) { + if ( not( $journey_id and $journey_id =~ m{ ^ \d+ $ }x ) ) { $self->render( 'journey', + status => 404, error => 'notfound', journey => {} ); return; } - my $journey = $self->get_journey( - uid => $uid, - journey_id => $journey_id, - verbose => 1, - with_datetime => 1, + my $journey = $self->journeys->get_single( + uid => $uid, + journey_id => $journey_id, + verbose => 1, + with_datetime => 1, + with_polyline => 1, + with_visibility => 1, ); if ($journey) { + my $map_data = $self->journeys_to_map_data( + journeys => [$journey], + include_manual => 1, + ); + my $with_share; + my $share_text; + + my $visibility + = $self->compute_effective_visibility( + $user->{default_visibility_str}, + $journey->{visibility_str} ); + + if ( $visibility eq 'public' + or $visibility eq 'travelynx' + or $visibility eq 'followers' + or $visibility eq 'unlisted' ) + { + my $delay = 'pünktlich '; + if ( $journey->{rt_arrival} != $journey->{sched_arrival} ) { + $delay = sprintf( + 'mit %+d ', + ( + $journey->{rt_arrival}->epoch + - $journey->{sched_arrival}->epoch + ) / 60 + ); + } + $with_share = 1; + $share_text + = $journey->{km_route} + ? sprintf( '%.0f km', $journey->{km_route} ) + : 'Fahrt'; + $share_text .= sprintf( ' mit %s %s – Ankunft %sum %s', + $journey->{type}, $journey->{no}, + $delay, $journey->{rt_arrival}->strftime('%H:%M') ); + } + $self->render( 'journey', - error => undef, - journey => $journey, + title => sprintf( + 'travelynx: Fahrt %s %s %s am %s', + $journey->{type}, $journey->{line} // '', + $journey->{no}, + $journey->{sched_departure}->strftime('%d.%m.%Y um %H:%M') + ), + error => undef, + journey => $journey, + journey_visibility => $visibility, + with_map => 1, + with_share => $with_share, + share_text => $share_text, + %{$map_data}, ); } else { $self->render( 'journey', + status => 404, error => 'notfound', journey => {} ); @@ -655,6 +1788,144 @@ sub journey_details { } +sub visibility_form { + my ($self) = @_; + my $dep_ts = $self->param('dep_ts'); + my $journey_id = $self->param('id'); + my $action = $self->param('action') // 'none'; + my $user = $self->current_user; + my $user_level = $user->{default_visibility_str}; + my $uid = $user->{id}; + my $status = $self->get_user_status; + my $visibility = $status->{visibility_str}; + my $journey; + + if ($journey_id) { + $journey = $self->journeys->get_single( + uid => $uid, + journey_id => $journey_id, + with_datetime => 1, + with_visibility => 1, + ); + $visibility = $journey->{visibility_str}; + } + + if ( $action eq 'save' ) { + if ( $self->validation->csrf_protect->has_error('csrf_token') ) { + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); + } + elsif ( $dep_ts and $dep_ts != $status->{sched_departure}->epoch ) { + $self->render( + 'edit_visibility', + error => 'old', + user_level => $user_level, + journey => {} + ); + } + else { + if ($dep_ts) { + $self->in_transit->update_visibility( + uid => $uid, + visibility => $self->param('status_level'), + ); + $self->redirect_to('/'); + $self->run_hook( $uid, 'update' ); + } + elsif ($journey_id) { + $self->journeys->update_visibility( + uid => $uid, + id => $journey_id, + visibility => $self->param('status_level'), + ); + $self->redirect_to( '/journey/' . $journey_id ); + } + } + return; + } + + $self->param( status_level => $visibility ); + + if ($journey_id) { + $self->render( + 'edit_visibility', + error => undef, + user_level => $user_level, + journey => $journey + ); + } + elsif ( $status->{checked_in} ) { + $self->param( dep_ts => $status->{sched_departure}->epoch ); + $self->render( + 'edit_visibility', + error => undef, + user_level => $user_level, + journey => $status + ); + } + else { + $self->render( + 'edit_visibility', + error => 'notfound', + user_level => $user_level, + journey => {} + ); + } +} + +sub comment_form { + my ($self) = @_; + my $dep_ts = $self->param('dep_ts'); + my $status = $self->get_user_status; + + if ( not $status->{checked_in} ) { + $self->render( + 'edit_comment', + error => 'notfound', + journey => {} + ); + } + elsif ( not $dep_ts ) { + $self->param( dep_ts => $status->{sched_departure}->epoch ); + $self->param( comment => $status->{comment} ); + $self->render( + 'edit_comment', + error => undef, + journey => $status + ); + } + elsif ( $self->validation->csrf_protect->has_error('csrf_token') ) { + $self->render( + 'edit_comment', + error => undef, + journey => $status + ); + } + elsif ( $dep_ts != $status->{sched_departure}->epoch ) { + + # TODO find and update appropriate past journey (if it exists) + $self->param( comment => $status->{comment} ); + $self->render( + 'edit_comment', + error => undef, + journey => $status + ); + } + else { + $self->app->log->debug("set comment"); + my $uid = $self->current_user->{id}; + $self->in_transit->update_user_data( + uid => $uid, + user_data => { comment => $self->param('comment') } + ); + $self->redirect_to('/'); + $self->run_hook( $uid, 'update' ); + } +} + sub edit_journey { my ($self) = @_; my $journey_id = $self->param('journey_id'); @@ -663,21 +1934,24 @@ sub edit_journey { if ( not( $journey_id =~ m{ ^ \d+ $ }x ) ) { $self->render( 'edit_journey', + status => 404, error => 'notfound', journey => {} ); return; } - my $journey = $self->get_journey( + my $journey = $self->journeys->get_single( uid => $uid, journey_id => $journey_id, + verbose => 1, with_datetime => 1, ); if ( not $journey ) { $self->render( 'edit_journey', + status => 404, error => 'notfound', journey => {} ); @@ -700,8 +1974,27 @@ sub edit_journey { { my $datetime = $parser->parse_datetime( $self->param($key) ); if ( $datetime and $datetime->epoch ne $journey->{$key}->epoch ) { - $error = $self->update_journey_part( $db, $journey->{id}, - $key, $datetime ); + $error = $self->journeys->update( + uid => $uid, + db => $db, + id => $journey->{id}, + $key => $datetime + ); + if ($error) { + last; + } + } + } + for my $key (qw(from_name to_name)) { + if ( defined $self->param($key) + and $self->param($key) ne $journey->{$key} ) + { + $error = $self->journeys->update( + uid => $uid, + db => $db, + id => $journey->{id}, + $key => $self->param($key) + ); if ($error) { last; } @@ -714,8 +2007,12 @@ sub edit_journey { or $journey->{user_data}{$key} ne $self->param($key) ) ) { - $error = $self->update_journey_part( $db, $journey->{id}, $key, - $self->param($key) ); + $error = $self->journeys->update( + uid => $uid, + db => $db, + id => $journey->{id}, + $key => $self->param($key) + ); if ($error) { last; } @@ -726,21 +2023,36 @@ sub edit_journey { my @route_new = split( qr{\r?\n\r?}, $self->param('route') ); @route_new = grep { $_ ne '' } @route_new; if ( join( '|', @route_old ) ne join( '|', @route_new ) ) { - $error - = $self->update_journey_part( $db, $journey->{id}, 'route', - [@route_new] ); + $error = $self->journeys->update( + uid => $uid, + db => $db, + id => $journey->{id}, + route => [@route_new] + ); + } + } + { + my $cancelled_old = $journey->{cancelled} // 0; + my $cancelled_new = $self->param('cancelled') // 0; + if ( $cancelled_old != $cancelled_new ) { + $error = $self->journeys->update( + uid => $uid, + db => $db, + id => $journey->{id}, + cancelled => $cancelled_new + ); } } if ( not $error ) { - $journey = $self->get_journey( + $journey = $self->journeys->get_single( uid => $uid, db => $db, journey_id => $journey_id, verbose => 1, with_datetime => 1, ); - $error = $self->journey_sanity_check($journey); + $error = $self->journeys->sanity_check($journey); } if ( not $error ) { $tx->commit; @@ -759,6 +2071,10 @@ sub edit_journey { $self->param( route => join( "\n", map { $_->[0] } @{ $journey->{route} } ) ); + $self->param( cancelled => $journey->{cancelled} ? 1 : 0 ); + $self->param( from_name => $journey->{from_name} ); + $self->param( to_name => $journey->{to_name} ); + for my $key (qw(comment)) { if ( $journey->{user_data} and $journey->{user_data}{$key} ) { $self->param( $key => $journey->{user_data}{$key} ); @@ -767,8 +2083,9 @@ sub edit_journey { $self->render( 'edit_journey', - error => $error, - journey => $journey + with_autocomplete => 1, + error => $error, + journey => $journey ); } @@ -795,7 +2112,7 @@ sub add_journey_form { $self->render( 'add_journey', with_autocomplete => 1, - error => + error => 'Zug muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.' ); return; @@ -831,18 +2148,19 @@ sub add_journey_form { my $db = $self->pg->db; my $tx = $db->begin; - $opt{db} = $db; + $opt{db} = $db; + $opt{uid} = $self->current_user->{id}; - my ( $journey_id, $error ) = $self->add_journey(%opt); + my ( $journey_id, $error ) = $self->journeys->add(%opt); if ( not $error ) { - my $journey = $self->get_journey( + my $journey = $self->journeys->get_single( uid => $self->current_user->{id}, db => $db, journey_id => $journey_id, verbose => 1 ); - $error = $self->journey_sanity_check($journey); + $error = $self->journeys->sanity_check($journey); } if ($error) { diff --git a/lib/Travelynx/Helper/DBDB.pm b/lib/Travelynx/Helper/DBDB.pm new file mode 100644 index 0000000..b98a372 --- /dev/null +++ b/lib/Travelynx/Helper/DBDB.pm @@ -0,0 +1,150 @@ +package Travelynx::Helper::DBDB; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use strict; +use warnings; +use 5.020; + +use Encode qw(decode); +use Mojo::Promise; +use JSON; + +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 has_wagonorder_p { + my ( $self, $ts, $train_no ) = @_; + my $api_ts = $ts->strftime('%Y%m%d%H%M'); + my $url + = "https://ist-wr.noncd.db.de/wagenreihung/1.0/${train_no}/${api_ts}"; + my $cache = $self->{cache}; + my $promise = Mojo::Promise->new; + + if ( my $content = $cache->get("HEAD $url") ) { + if ( $content eq 'n' ) { + return $promise->reject; + } + else { + return $promise->resolve($content); + } + } + + $self->{user_agent}->request_timeout(5)->head_p( $url => $self->{header} ) + ->then( + sub { + my ($tx) = @_; + if ( $tx->result->is_success ) { + $cache->set( "HEAD $url", 'a' ); + $promise->resolve('a'); + } + else { + $cache->set( "HEAD $url", 'n' ); + $promise->reject; + } + return; + } + )->catch( + sub { + $cache->set( "HEAD $url", 'n' ); + $promise->reject; + return; + } + )->wait; + return $promise; +} + +sub get_wagonorder_p { + my ( $self, $api, $ts, $train_no ) = @_; + my $api_ts = $ts->strftime('%Y%m%d%H%M'); + my $url + = "https://ist-wr.noncd.db.de/wagenreihung/1.0/${train_no}/${api_ts}"; + + my $cache = $self->{cache}; + my $promise = Mojo::Promise->new; + + if ( my $content = $cache->thaw($url) ) { + $promise->resolve($content); + return $promise; + } + + $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} ) + ->then( + sub { + my ($tx) = @_; + + if ( $tx->result->is_success ) { + my $body = decode( 'utf-8', $tx->res->body ); + my $json = JSON->new->decode($body); + $cache->freeze( $url, $json ); + $promise->resolve($json); + } + else { + my $code = $tx->code; + $promise->reject("HTTP ${code}"); + } + return; + } + )->catch( + sub { + my ($err) = @_; + $promise->reject($err); + return; + } + )->wait; + return $promise; +} + +sub get_stationinfo_p { + my ( $self, $eva ) = @_; + + my $url = "https://lib.finalrewind.org/dbdb/s/${eva}.json"; + + my $cache = $self->{cache}; + my $promise = Mojo::Promise->new; + + if ( my $content = $cache->thaw($url) ) { + return $promise->resolve($content); + } + + $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} ) + ->then( + sub { + my ($tx) = @_; + + if ( my $err = $tx->error ) { + $cache->freeze( $url, {} ); + $promise->reject("HTTP $err->{code} $err->{message}"); + return; + } + + my $json = $tx->result->json; + $cache->freeze( $url, $json ); + $promise->resolve($json); + return; + } + )->catch( + sub { + my ($err) = @_; + $cache->freeze( $url, {} ); + $promise->reject($err); + return; + } + )->wait; + return $promise; +} + +1; diff --git a/lib/Travelynx/Helper/HAFAS.pm b/lib/Travelynx/Helper/HAFAS.pm new file mode 100644 index 0000000..7671d78 --- /dev/null +++ b/lib/Travelynx/Helper/HAFAS.pm @@ -0,0 +1,315 @@ +package Travelynx::Helper::HAFAS; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use strict; +use warnings; +use 5.020; + +use DateTime; +use Encode qw(decode); +use JSON; +use Mojo::Promise; +use Travel::Status::DE::HAFAS; + +sub _epoch { + my ($dt) = @_; + + return $dt ? $dt->epoch : 0; +} + +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_json_p { + my ( $self, $url, %opt ) = @_; + + my $cache = $self->{main_cache}; + my $promise = Mojo::Promise->new; + + if ( $opt{realtime} ) { + $cache = $self->{realtime_cache}; + } + $opt{encoding} //= 'ISO-8859-15'; + + if ( my $content = $cache->thaw($url) ) { + return $promise->resolve($content); + } + + $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} ) + ->then( + sub { + my ($tx) = @_; + + if ( my $err = $tx->error ) { + $promise->reject( +"hafas->get_json_p($url) returned HTTP $err->{code} $err->{message}" + ); + return; + } + + my $body = decode( $opt{encoding}, $tx->res->body ); + + $body =~ s{^TSLs[.]sls = }{}; + $body =~ s{;$}{}; + $body =~ s{(}{(}g; + $body =~ s{)}{)}g; + my $json = JSON->new->decode($body); + $cache->freeze( $url, $json ); + $promise->resolve($json); + return; + } + )->catch( + sub { + my ($err) = @_; + $self->{log}->info("hafas->get_json_p($url): $err"); + $promise->reject("hafas->get_json_p($url): $err"); + return; + } + )->wait; + return $promise; +} + +sub get_departures_p { + my ( $self, %opt ) = @_; + + my $when = ( + $opt{timestamp} + ? $opt{timestamp}->clone + : DateTime->now( time_zone => 'Europe/Berlin' ) + )->subtract( minutes => $opt{lookbehind} ); + return Travel::Status::DE::HAFAS->new_p( + station => $opt{eva}, + datetime => $when, + lookahead => $opt{lookahead} + $opt{lookbehind}, + results => 300, + cache => $self->{realtime_cache}, + promise => 'Mojo::Promise', + user_agent => $self->{user_agent}->request_timeout(5), + ); +} + +sub search_location_p { + my ( $self, %opt ) = @_; + + return Travel::Status::DE::HAFAS->new_p( + locationSearch => $opt{query}, + cache => $self->{realtime_cache}, + promise => 'Mojo::Promise', + user_agent => $self->{user_agent}->request_timeout(5), + ); +} + +sub get_tripid_p { + my ( $self, %opt ) = @_; + + my $promise = Mojo::Promise->new; + + my $train = $opt{train}; + my $train_desc = $train->type . ' ' . $train->train_no; + $train_desc =~ s{^- }{}; + + Travel::Status::DE::HAFAS->new_p( + journeyMatch => $train_desc, + datetime => $train->start, + cache => $self->{realtime_cache}, + promise => 'Mojo::Promise', + user_agent => $self->{user_agent}->request_timeout(10), + )->then( + sub { + my ($hafas) = @_; + my @results = $hafas->results; + + if ( not @results ) { + $promise->reject( + "journeyMatch($train_desc) returned no results"); + return; + } + + my $result = $results[0]; + if ( @results > 1 ) { + for my $journey (@results) { + if ( ( $journey->route )[0]->loc->name eq $train->origin ) { + $result = $journey; + last; + } + } + } + + $promise->resolve( $result->id ); + return; + } + )->catch( + sub { + my ($err) = @_; + $promise->reject($err); + return; + } + )->wait; + + return $promise; +} + +sub get_journey_p { + my ( $self, %opt ) = @_; + + my $promise = Mojo::Promise->new; + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + + Travel::Status::DE::HAFAS->new_p( + journey => { + id => $opt{trip_id}, + }, + with_polyline => $opt{with_polyline}, + cache => $self->{realtime_cache}, + promise => 'Mojo::Promise', + user_agent => $self->{user_agent}->request_timeout(10), + )->then( + sub { + my ($hafas) = @_; + my $journey = $hafas->result; + + if ($journey) { + $promise->resolve($journey); + return; + } + $promise->reject('no journey'); + return; + } + )->catch( + sub { + my ($err) = @_; + $promise->reject($err); + return; + } + )->wait; + + return $promise; +} + +sub get_route_timestamps_p { + my ( $self, %opt ) = @_; + + my $promise = Mojo::Promise->new; + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + + Travel::Status::DE::HAFAS->new_p( + journey => { + id => $opt{trip_id}, + + # name => $opt{train_no}, + }, + with_polyline => $opt{with_polyline}, + cache => $self->{realtime_cache}, + promise => 'Mojo::Promise', + user_agent => $self->{user_agent}->request_timeout(10), + )->then( + sub { + my ($hafas) = @_; + my $journey = $hafas->result; + my $ret = {}; + my $polyline; + + my $station_is_past = 1; + for my $stop ( $journey->route ) { + my $name = $stop->loc->name; + $ret->{$name} = $ret->{ $stop->loc->eva } = { + name => $stop->loc->name, + eva => $stop->loc->eva, + sched_arr => _epoch( $stop->sched_arr ), + sched_dep => _epoch( $stop->sched_dep ), + rt_arr => _epoch( $stop->rt_arr ), + rt_dep => _epoch( $stop->rt_dep ), + arr_delay => $stop->arr_delay, + dep_delay => $stop->dep_delay, + load => $stop->load + }; + if ( $stop->tz_offset ) { + $ret->{$name}{tz_offset} = $stop->tz_offset; + } + if ( ( $stop->arr_cancelled or not $stop->sched_arr ) + and ( $stop->dep_cancelled or not $stop->sched_dep ) ) + { + $ret->{$name}{isCancelled} = 1; + } + if ( + $station_is_past + and not $ret->{$name}{isCancelled} + and $now->epoch < ( + $ret->{$name}{rt_arr} // $ret->{$name}{rt_dep} + // $ret->{$name}{sched_arr} + // $ret->{$name}{sched_dep} // $now->epoch + ) + ) + { + $station_is_past = 0; + } + $ret->{$name}{isPast} = $station_is_past; + } + + if ( $journey->polyline ) { + my @station_list; + my @coordinate_list; + + for my $coord ( $journey->polyline ) { + if ( $coord->{name} ) { + push( @coordinate_list, + [ $coord->{lon}, $coord->{lat}, $coord->{eva} ] ); + push( @station_list, $coord->{name} ); + } + else { + push( @coordinate_list, + [ $coord->{lon}, $coord->{lat} ] ); + } + } + my $iris_stations = join( '|', $opt{train}->route ); + + # borders (Gr" as in "Grenze") are only returned by HAFAS. + # They are not stations. + my $hafas_stations + = join( '|', grep { $_ !~ m{(\(Gr\)|\)Gr)$} } @station_list ); + + if ( $iris_stations eq $hafas_stations + or index( $hafas_stations, $iris_stations ) != -1 ) + { + $polyline = { + from_eva => ( $journey->route )[0]->loc->eva, + to_eva => ( $journey->route )[-1]->loc->eva, + coords => \@coordinate_list, + }; + } + else { + $self->{log}->debug( 'Ignoring polyline for ' + . $opt{train}->line + . ": IRIS route does not agree with HAFAS route: $iris_stations != $hafas_stations" + ); + } + } + + $promise->resolve( $ret, $journey, $polyline ); + return; + } + )->catch( + sub { + my ($err) = @_; + $promise->reject($err); + return; + } + )->wait; + + return $promise; +} + +1; diff --git a/lib/Travelynx/Helper/IRIS.pm b/lib/Travelynx/Helper/IRIS.pm new file mode 100644 index 0000000..deed79a --- /dev/null +++ b/lib/Travelynx/Helper/IRIS.pm @@ -0,0 +1,237 @@ +package Travelynx::Helper::IRIS; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use strict; +use warnings; +use 5.020; + +use utf8; + +use Mojo::Promise; +use Mojo::UserAgent; +use Travel::Status::DE::IRIS; +use Travel::Status::DE::IRIS::Stations; + +sub new { + my ( $class, %opt ) = @_; + + return bless( \%opt, $class ); +} + +sub get_departures { + my ( $self, %opt ) = @_; + my $station = $opt{station}; + my $lookbehind = $opt{lookbehind} // 180; + my $lookahead = $opt{lookahead} // 30; + my $with_related = $opt{with_related} // 0; + + # Berlin Hbf exists twice: + # - BLS / 8011160 + # - BL / 8098160 (formerly "Berlin Hbf (tief)") + # Right now, travelynx assumes that station name -> EVA / DS100 is a unique + # map. This is not the case. Work around it here until travelynx has been + # adjusted properly. + if ( $station eq 'Berlin Hbf' or $station eq '8011160' ) { + $with_related = 1; + } + + my @station_matches + = Travel::Status::DE::IRIS::Stations::get_station($station); + + if ( @station_matches == 1 ) { + $station = $station_matches[0][0]; + my $status = Travel::Status::DE::IRIS->new( + station => $station, + main_cache => $self->{main_cache}, + realtime_cache => $self->{realtime_cache}, + keep_transfers => 1, + lookbehind => 20, + datetime => DateTime->now( time_zone => 'Europe/Berlin' ) + ->subtract( minutes => $lookbehind ), + lookahead => $lookbehind + $lookahead, + lwp_options => { + timeout => 10, + agent => 'travelynx/' + . $self->{version} + . ' +https://travelynx.de', + }, + with_related => $with_related, + ); + return { + results => [ $status->results ], + errstr => $status->errstr, + station_ds100 => + ( $status->station ? $status->station->{ds100} : undef ), + station_eva => + ( $status->station ? $status->station->{uic} : undef ), + station_name => + ( $status->station ? $status->station->{name} : undef ), + related_stations => [ $status->related_stations ], + }; + } + elsif ( @station_matches > 1 ) { + return { + results => [], + errstr => + "Mehrdeutiger Stationsname: '$station'. Mögliche Eingaben: " + . join( q{, }, map { $_->[1] } @station_matches ), + }; + } + else { + return { + results => [], + errstr => 'Unbekannte Station', + }; + } +} + +sub get_departures_p { + my ( $self, %opt ) = @_; + my $station = $opt{station}; + my $lookbehind = $opt{lookbehind} // 180; + my $lookahead = $opt{lookahead} // 30; + my $with_related = $opt{with_related} // 0; + + # Berlin Hbf exists twice: + # - BLS / 8011160 + # - BL / 8098160 (formerly "Berlin Hbf (tief)") + # Right now, travelynx assumes that station name -> EVA / DS100 is a unique + # map. This is not the case. Work around it here until travelynx has been + # adjusted properly. + if ( $station eq 'Berlin Hbf' or $station eq '8011160' ) { + $with_related = 1; + } + + my @station_matches + = Travel::Status::DE::IRIS::Stations::get_station($station); + + if ( @station_matches == 1 ) { + $station = $station_matches[0][0]; + my $promise = Mojo::Promise->new; + Travel::Status::DE::IRIS->new_p( + station => $station, + main_cache => $self->{main_cache}, + realtime_cache => $self->{realtime_cache}, + keep_transfers => 1, + lookbehind => 20, + datetime => DateTime->now( time_zone => 'Europe/Berlin' ) + ->subtract( minutes => $lookbehind ), + lookahead => $lookbehind + $lookahead, + lwp_options => { + timeout => 10, + agent => 'travelynx/' + . $self->{version} + . ' +https://travelynx.de', + }, + with_related => $with_related, + promise => 'Mojo::Promise', + user_agent => Mojo::UserAgent->new, + get_station => \&Travel::Status::DE::IRIS::Stations::get_station, + meta => Travel::Status::DE::IRIS::Stations::get_meta(), + )->then( + sub { + my ($status) = @_; + $promise->resolve( + { + results => [ $status->results ], + errstr => $status->errstr, + station_ds100 => ( + $status->station + ? $status->station->{ds100} + : undef + ), + station_eva => ( + $status->station ? $status->station->{uic} : undef + ), + station_name => ( + $status->station ? $status->station->{name} : undef + ), + related_stations => [ $status->related_stations ], + } + ); + return; + } + )->catch( + sub { + my ($err) = @_; + $promise->reject( + $err, + { + results => [], + errstr => "Error in promise: $err", + } + ); + return; + } + )->wait; + return $promise; + } + elsif ( @station_matches > 1 ) { + return Mojo::Promise->reject( + 'ambiguous station name', + { + results => [], + errstr => "Mehrdeutiger Stationsname: '$station'", + suggestions => [ + map { { name => $_->[1], eva => $_->[2] } } + @station_matches + ], + } + ); + } + else { + return Mojo::Promise->reject( + 'unknown station', + { + results => [], + errstr => 'Unbekannte Station', + } + ); + } +} + +sub route_diff { + my ( $self, $train ) = @_; + my @json_route; + my @route = $train->route; + my @sched_route = $train->sched_route; + + my $route_idx = 0; + my $sched_idx = 0; + + while ( $route_idx <= $#route and $sched_idx <= $#sched_route ) { + if ( $route[$route_idx] eq $sched_route[$sched_idx] ) { + push( @json_route, [ $route[$route_idx], undef, {} ] ); + $route_idx++; + $sched_idx++; + } + + # this branch is inefficient, but won't be taken frequently + elsif ( not( grep { $_ eq $route[$route_idx] } @sched_route ) ) { + push( @json_route, + [ $route[$route_idx], undef, { isAdditional => 1 } ], ); + $route_idx++; + } + else { + push( @json_route, + [ $sched_route[$sched_idx], undef, { isCancelled => 1 } ], ); + $sched_idx++; + } + } + while ( $route_idx <= $#route ) { + push( @json_route, + [ $route[$route_idx], undef, { isAdditional => 1 } ], ); + $route_idx++; + } + while ( $sched_idx <= $#sched_route ) { + push( @json_route, + [ $sched_route[$sched_idx], undef, { isCancelled => 1 } ], ); + $sched_idx++; + } + return @json_route; +} + +1; diff --git a/lib/Travelynx/Helper/Sendmail.pm b/lib/Travelynx/Helper/Sendmail.pm index fa3c4fd..baa1156 100644 --- a/lib/Travelynx/Helper/Sendmail.pm +++ b/lib/Travelynx/Helper/Sendmail.pm @@ -1,5 +1,9 @@ package Travelynx::Helper::Sendmail; +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + use strict; use warnings; @@ -7,7 +11,7 @@ use 5.020; use Encode qw(encode); use Email::Sender::Simple qw(try_to_sendmail); -use Email::Simple; +use MIME::Entity; sub new { my ( $class, %opt ) = @_; @@ -18,14 +22,14 @@ sub new { sub custom { my ( $self, $to, $subject, $body ) = @_; - my $reg_mail = Email::Simple->create( - header => [ - To => $to, - From => 'Travelynx <travelynx@finalrewind.org>', - Subject => $subject, - 'Content-Type' => 'text/plain; charset=UTF-8', - ], - body => encode( 'utf-8', $body ), + my $reg_mail = MIME::Entity->build( + To => $to, + From => $self->{config}{from}, + Subject => encode( 'MIME-Header', $subject ), + Type => 'text/plain', + Charset => 'UTF-8', + Encoding => 'quoted-printable', + Data => encode( 'utf-8', $body ), ); if ( $self->{config}->{disabled} ) { @@ -38,4 +42,34 @@ sub custom { return try_to_sendmail($reg_mail); } +sub age_deletion_notification { + my ( $self, %opt ) = @_; + my $name = $opt{name}; + my $email = $opt{email}; + my $last_seen = $opt{last_seen}; + my $login_url = $opt{login_url}; + my $account_url = $opt{account_url}; + my $imprint_url = $opt{imprint_url}; + + my $body = "Hallo ${name},\n\n"; + $body + .= "Dein travelynx-Account wurde seit dem ${last_seen} nicht verwendet.\n"; + $body + .= "Im Sinne der Datensparsamkeit wird er daher in vier Wochen gelöscht.\n"; + $body + .= "Falls du den Account weiterverwenden möchtest, kannst du dich unter\n"; + $body .= "<$login_url> anmelden.\n"; + $body + .= "Durch die Anmeldung wird die Löschung automatisch abgebrochen.\n\n"; + $body + .= "Falls du den Account löschen, aber zuvor deine Daten exportieren möchtest,\n"; + $body .= "kannst du dich unter obiger URL anmelden, unter <$account_url>\n"; + $body + .= "deine Daten exportieren und anschließend den Account löschen lassen.\n\n\n"; + $body .= "Impressum: ${imprint_url}\n"; + + return $self->custom( $email, + 'travelynx: Löschung deines Accounts', $body ); +} + 1; diff --git a/lib/Travelynx/Helper/Traewelling.pm b/lib/Travelynx/Helper/Traewelling.pm new file mode 100644 index 0000000..d688004 --- /dev/null +++ b/lib/Travelynx/Helper/Traewelling.pm @@ -0,0 +1,387 @@ +package Travelynx::Helper::Traewelling; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# Copyright (C) 2023 networkException <git@nwex.de> +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use strict; +use warnings; +use 5.020; +use utf8; + +use DateTime; +use DateTime::Format::Strptime; +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", + 'Accept' => 'application/json', + }; + $opt{strp1} = DateTime::Format::Strptime->new( + pattern => '%Y-%m-%dT%H:%M:%S.000000Z', + time_zone => 'UTC', + ); + $opt{strp2} = DateTime::Format::Strptime->new( + pattern => '%Y-%m-%d %H:%M:%S', + time_zone => 'Europe/Berlin', + ); + $opt{strp3} = DateTime::Format::Strptime->new( + pattern => '%Y-%m-%dT%H:%M:%S%z', + time_zone => 'Europe/Berlin', + ); + + return bless( \%opt, $class ); +} + +sub epoch_to_dt_or_undef { + my ($epoch) = @_; + + if ( not $epoch ) { + return undef; + } + + return DateTime->from_epoch( + epoch => $epoch, + time_zone => 'Europe/Berlin', + locale => 'de-DE', + ); +} + +sub parse_datetime { + my ( $self, $dt ) = @_; + + return $self->{strp1}->parse_datetime($dt) + // $self->{strp2}->parse_datetime($dt) + // $self->{strp3}->parse_datetime($dt); +} + +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'}, + 'Accept' => 'application/json', + 'Authorization' => "Bearer $token", + }; + + $self->{user_agent}->request_timeout(20) + ->get_p( + "https://traewelling.de/api/v1/user/${username}/statuses?limit=1" => + $header )->then( + sub { + my ($tx) = @_; + if ( my $err = $tx->error ) { + my $err_msg + = "v1/user/${username}/statuses: HTTP $err->{code} $err->{message}"; + $promise->reject( { http => $err->{code}, text => $err_msg } ); + return; + } + else { + if ( my $status = $tx->result->json->{data}[0] ) { + my $status_id = $status->{id}; + my $message = $status->{body}; + my $checkin_at + = $self->parse_datetime( $status->{createdAt} ); + + my $dep_dt = $self->parse_datetime( + $status->{train}{origin}{departurePlanned} ); + my $arr_dt = $self->parse_datetime( + $status->{train}{destination}{arrivalPlanned} ); + + my $dep_eva + = $status->{train}{origin}{evaIdentifier}; + my $arr_eva + = $status->{train}{destination}{evaIdentifier}; + + my $dep_ds100 + = $status->{train}{origin}{rilIdentifier}; + my $arr_ds100 + = $status->{train}{destination}{rilIdentifier}; + + my $dep_name + = $status->{train}{origin}{name}; + my $arr_name + = $status->{train}{destination}{name}; + + my $category = $status->{train}{category}; + my $linename = $status->{train}{lineName}; + my $trip_id = $status->{train}{hafasId}; + my ( $train_type, $train_line ) = split( qr{ }, $linename ); + $promise->resolve( + { + http => $tx->res->code, + status_id => $status_id, + message => $message, + checkin => $checkin_at, + dep_dt => $dep_dt, + dep_eva => $dep_eva, + dep_ds100 => $dep_ds100, + dep_name => $dep_name, + arr_dt => $arr_dt, + arr_eva => $arr_eva, + arr_ds100 => $arr_ds100, + arr_name => $arr_name, + trip_id => $trip_id, + train_type => $train_type, + line => $linename, + line_no => $train_line, + category => $category, + } + ); + return; + } + else { + $promise->reject( + { text => "v1/${username}/statuses: unknown error" } ); + return; + } + } + } + )->catch( + sub { + my ($err) = @_; + $promise->reject( { text => "v1/${username}/statuses: $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'}, + 'Accept' => 'application/json', + 'Authorization' => "Bearer $token", + }; + my $promise = Mojo::Promise->new; + + $ua->get_p( "https://traewelling.de/api/v1/auth/user" => $header )->then( + sub { + my ($tx) = @_; + if ( my $err = $tx->error ) { + my $err_msg = "v1/auth/user: HTTP $err->{code} $err->{message}"; + $promise->reject($err_msg); + return; + } + else { + my $user_data = $tx->result->json->{data}; + $self->{model}->set_user( + uid => $uid, + trwl_id => $user_data->{id}, + screen_name => $user_data->{displayName}, + user_name => $user_data->{username}, + ); + $promise->resolve; + return; + } + } + )->catch( + sub { + my ($err) = @_; + $promise->reject("v1/auth/user: $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'}, + 'Accept' => 'application/json', + 'Authorization' => "Bearer $token", + }; + my $request = {}; + + $self->{model}->unlink( uid => $uid ); + + my $promise = Mojo::Promise->new; + + $ua->post_p( + "https://traewelling.de/api/v1/auth/logout" => $header => json => + $request )->then( + sub { + my ($tx) = @_; + if ( my $err = $tx->error ) { + my $err_msg + = "v1/auth/logout: HTTP $err->{code} $err->{message}"; + $promise->reject($err_msg); + return; + } + else { + $promise->resolve; + return; + } + } + )->catch( + sub { + my ($err) = @_; + $promise->reject("v1/auth/logout: $err"); + return; + } + )->wait; + + return $promise; +} + +sub convert_travelynx_to_traewelling_visibility { + my ($travelynx_visibility) = @_; + + my %visibilities = ( + + # public => StatusVisibility::PUBLIC + 100 => 0, + + # travelynx => StatusVisibility::AUTHENTICATED + # (only visible for logged in users) + 80 => 4, + + # followers => StatusVisibility::FOLLOWERS + 60 => 2, + + # unlisted => StatusVisibility::PRIVATE + # (there is no träwelling equivalent to unlisted, their + # StatusVisibility::UNLISTED shows the journey on the profile) + 30 => 3, + + # private => StatusVisibility::PRIVATE + 10 => 3, + ); + + return $visibilities{$travelynx_visibility}; +} + +sub checkin_p { + my ( $self, %opt ) = @_; + + my $header = { + 'User-Agent' => $self->{header}{'User-Agent'}, + 'Accept' => 'application/json', + 'Authorization' => "Bearer $opt{token}", + }; + + my $departure_ts = epoch_to_dt_or_undef( $opt{dep_ts} ); + my $arrival_ts = epoch_to_dt_or_undef( $opt{arr_ts} ); + + if ($departure_ts) { + $departure_ts = $departure_ts->rfc3339; + } + if ($arrival_ts) { + $arrival_ts = $arrival_ts->rfc3339; + } + + my $request = { + tripId => $opt{trip_id}, + lineName => $opt{train_type} . ' ' + . ( $opt{train_line} // $opt{train_no} ), + ibnr => \1, + start => q{} . $opt{dep_eva}, + destination => q{} . $opt{arr_eva}, + departure => $departure_ts, + arrival => $arrival_ts, + toot => $opt{data}{toot} ? \1 : \0, + tweet => $opt{data}{tweet} ? \1 : \0, + visibility => + convert_travelynx_to_traewelling_visibility( $opt{visibility} ) + }; + + if ( $opt{user_data}{comment} ) { + $request->{body} = $opt{user_data}{comment}; + } + + my $debug_prefix + = "v1/trains/checkin('$request->{lineName}' $request->{tripId} $request->{start} -> $request->{destination})"; + + my $promise = Mojo::Promise->new; + + $self->{user_agent}->request_timeout(20) + ->post_p( + "https://traewelling.de/api/v1/trains/checkin" => $header => json => + $request )->then( + sub { + my ($tx) = @_; + if ( my $err = $tx->error ) { + my $err_msg = "HTTP $err->{code} $err->{message}"; + if ( $tx->res->body ) { + if ( $err->{code} == 409 ) { + my $j = $tx->res->json; + $err_msg .= sprintf( +': Bereits in %s eingecheckt: https://traewelling.de/status/%d', + $j->{message}{lineName}, + $j->{message}{status_id} + ); + } + else { + $err_msg .= ' ' . $tx->res->body; + } + } + $self->{log} + ->debug("Traewelling $debug_prefix error: $err_msg"); + $self->{model}->log( + uid => $opt{uid}, + message => +"Konnte $opt{train_type} $opt{train_no} nicht übertragen: $debug_prefix returned $err_msg", + is_error => 1 + ); + $promise->reject( { http => $err->{code} } ); + return; + } + $self->{log}->debug( "... success! " . $tx->res->body ); + + $self->{model}->log( + uid => $opt{uid}, + message => "Eingecheckt in $opt{train_type} $opt{train_no}", + status_id => $tx->res->json->{statusId} + ); + $self->{model}->set_latest_push_ts( + uid => $opt{uid}, + ts => $opt{checkin_ts} + ); + $promise->resolve( { http => $tx->res->code } ); + + # TODO store status_id in in_transit object so that it can be shown + # on the user status page + return; + } + )->catch( + sub { + my ($err) = @_; + $self->{log}->debug("... $debug_prefix error: $err"); + $self->{model}->log( + uid => $opt{uid}, + message => +"Konnte $opt{train_type} $opt{train_no} nicht übertragen: $debug_prefix returned $err", + is_error => 1 + ); + $promise->reject( { connection => $err } ); + return; + } + )->wait; + + return $promise; +} + +1; diff --git a/lib/Travelynx/Model/InTransit.pm b/lib/Travelynx/Model/InTransit.pm new file mode 100644 index 0000000..69026ac --- /dev/null +++ b/lib/Travelynx/Model/InTransit.pm @@ -0,0 +1,900 @@ +package Travelynx::Model::InTransit; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use strict; +use warnings; +use 5.020; + +use DateTime; +use JSON; + +my %visibility_itoa = ( + 100 => 'public', + 80 => 'travelynx', + 60 => 'followers', + 30 => 'unlisted', + 10 => 'private', +); + +my %visibility_atoi = ( + public => 100, + travelynx => 80, + followers => 60, + unlisted => 30, + private => 10, +); + +sub _epoch { + my ($dt) = @_; + + return $dt ? $dt->epoch : 0; +} + +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 ); +} + +# merge [name, eva, data] from old_route into [name, undef, undef] from new_route. +# If new_route already has eva/data, it is kept as-is. +# changes new_route. +sub _merge_old_route { + my ( $self, %opt ) = @_; + my $db = $opt{db}; + my $uid = $opt{uid}; + my $new_route = $opt{route}; + + my $res_h = $db->select( 'in_transit', ['route'], { user_id => $uid } ) + ->expand->hash; + my $old_route = $res_h ? $res_h->{route} : []; + + for my $i ( 0 .. $#{$new_route} ) { + if ( $old_route->[$i] and $old_route->[$i][0] eq $new_route->[$i][0] ) { + $new_route->[$i][1] //= $old_route->[$i][1]; + if ( not keys %{ $new_route->[$i][2] // {} } ) { + $new_route->[$i][2] = $old_route->[$i][2]; + } + } + } + + return $new_route; +} + +sub add { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $train = $opt{train}; + my $journey = $opt{journey}; + my $stop = $opt{stop}; + my $checkin_station_id = $opt{departure_eva}; + my $route = $opt{route}; + + my $json = JSON->new; + + if ($train) { + $db->insert( + 'in_transit', + { + user_id => $uid, + cancelled => $train->departure_is_cancelled ? 1 + : 0, + checkin_station_id => $checkin_station_id, + checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ), + dep_platform => $train->platform, + train_type => $train->type, + train_line => $train->line_no, + train_no => $train->train_no, + train_id => $train->train_id, + sched_departure => $train->sched_departure, + real_departure => $train->departure, + route => $json->encode($route), + messages => $json->encode( + [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ] + ), + data => JSON->new->encode( + { + rt => $train->departure_has_realtime ? 1 + : 0 + } + ), + } + ); + } + elsif ( $journey and $stop ) { + my @route; + my $product = $journey->product_at( $stop->loc->eva ) + // $journey->product; + for my $j_stop ( $journey->route ) { + push( + @route, + [ + $j_stop->loc->name, + $j_stop->loc->eva, + { + sched_arr => _epoch( $j_stop->sched_arr ), + sched_dep => _epoch( $j_stop->sched_dep ), + rt_arr => _epoch( $j_stop->rt_arr ), + rt_dep => _epoch( $j_stop->rt_dep ), + arr_delay => $j_stop->arr_delay, + dep_delay => $j_stop->dep_delay, + load => $j_stop->load + } + ] + ); + if ( defined $j_stop->tz_offset ) { + $route[-1][2]{tz_offset} = $j_stop->tz_offset; + } + } + $db->insert( + 'in_transit', + { + user_id => $uid, + cancelled => $stop->{dep_cancelled} + ? 1 + : 0, + checkin_station_id => $stop->loc->eva, + checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ), + dep_platform => $stop->{platform}, + train_type => $product->type // q{}, + train_line => $product->line_no, + train_no => $product->number // q{}, + train_id => $journey->id, + sched_departure => $stop->{sched_dep}, + real_departure => $stop->{rt_dep} // $stop->{sched_dep}, + route => $json->encode( \@route ), + data => JSON->new->encode( { rt => $stop->{rt_dep} ? 1 : 0 } ), + } + ); + } + else { + die('neither train nor journey specified'); + } +} + +sub add_from_journey { + my ( $self, %opt ) = @_; + + my $journey = $opt{journey}; + my $db = $opt{db} // $self->{pg}->db; + + $db->insert( 'in_transit', $journey ); +} + +sub delete { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + $db->delete( 'in_transit', { user_id => $uid } ); +} + +sub delete_incomplete_checkins { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + + return $db->delete( 'in_transit', + { checkin_time => { '<', $opt{earlier_than} } } )->rows; +} + +sub postprocess { + my ( $self, $ret ) = @_; + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + my $epoch = $now->epoch; + my @route = @{ $ret->{route} // [] }; + my @route_after; + my $dep_info; + my $is_after = 0; + + for my $station (@route) { + if ($is_after) { + push( @route_after, $station ); + } + if ( $ret->{dep_name} + and $station->[0] eq $ret->{dep_name} ) + { + $is_after = 1; + if ( @{$station} > 1 and not $dep_info ) { + $dep_info = $station->[2]; + } + } + } + + my $ts = $ret->{checkout_ts} // $ret->{checkin_ts}; + my $action_time = epoch_to_dt($ts); + + $ret->{checked_in} = !$ret->{cancelled}; + $ret->{timestamp} = $action_time; + $ret->{timestamp_delta} = $now->epoch - $action_time->epoch; + $ret->{boarding_countdown} = -1; + $ret->{sched_departure} = epoch_to_dt( $ret->{sched_dep_ts} ); + $ret->{real_departure} = epoch_to_dt( $ret->{real_dep_ts} ); + $ret->{sched_arrival} = epoch_to_dt( $ret->{sched_arr_ts} ); + $ret->{real_arrival} = epoch_to_dt( $ret->{real_arr_ts} ); + $ret->{route_after} = \@route_after; + $ret->{extra_data} = $ret->{data}; + $ret->{comment} = $ret->{user_data}{comment}; + + $ret->{visibility_str} + = $ret->{visibility} + ? $visibility_itoa{ $ret->{visibility} } + : 'default'; + $ret->{effective_visibility_str} + = $visibility_itoa{ $ret->{effective_visibility} }; + + my @parsed_messages; + for my $message ( @{ $ret->{messages} // [] } ) { + my ( $ts, $msg ) = @{$message}; + push( @parsed_messages, [ epoch_to_dt($ts), $msg ] ); + } + $ret->{messages} = [ reverse @parsed_messages ]; + + @parsed_messages = (); + for my $message ( @{ $ret->{extra_data}{qos_msg} // [] } ) { + my ( $ts, $msg ) = @{$message}; + push( @parsed_messages, [ epoch_to_dt($ts), $msg ] ); + } + $ret->{extra_data}{qos_msg} = [@parsed_messages]; + + if ( $dep_info and $dep_info->{sched_arr} ) { + $dep_info->{sched_arr} + = epoch_to_dt( $dep_info->{sched_arr} ); + $dep_info->{rt_arr} = epoch_to_dt( $dep_info->{rt_arr} ); + $dep_info->{rt_arr_countdown} = $ret->{boarding_countdown} + = $dep_info->{rt_arr}->epoch - $epoch; + } + + for my $station (@route_after) { + if ( @{$station} > 1 ) { + + # Note: $station->[2]{sched_arr} may already have been + # converted to a DateTime object. This can happen when a + # station is present several times in a train's route, e.g. + # for Frankfurt Flughafen in some nightly connections. + my $times = $station->[2] // {}; + if ( $times->{sched_arr} + and ref( $times->{sched_arr} ) ne 'DateTime' ) + { + $times->{sched_arr} + = epoch_to_dt( $times->{sched_arr} ); + if ( $times->{rt_arr} ) { + $times->{rt_arr} + = epoch_to_dt( $times->{rt_arr} ); + $times->{arr_delay} + = $times->{rt_arr}->epoch - $times->{sched_arr}->epoch; + } + $times->{arr} = $times->{rt_arr} || $times->{sched_arr}; + $times->{arr_countdown} = $times->{arr}->epoch - $epoch; + } + if ( $times->{sched_dep} + and ref( $times->{sched_dep} ) ne 'DateTime' ) + { + $times->{sched_dep} + = epoch_to_dt( $times->{sched_dep} ); + if ( $times->{rt_dep} ) { + $times->{rt_dep} + = epoch_to_dt( $times->{rt_dep} ); + $times->{dep_delay} + = $times->{rt_dep}->epoch - $times->{sched_dep}->epoch; + } + $times->{dep} = $times->{rt_dep} || $times->{sched_dep}; + $times->{dep_countdown} = $times->{dep}->epoch - $epoch; + } + } + } + + $ret->{departure_countdown} = $ret->{real_departure}->epoch - $now->epoch; + + if ( $ret->{real_arr_ts} ) { + $ret->{arrival_countdown} = $ret->{real_arrival}->epoch - $now->epoch; + $ret->{journey_duration} + = $ret->{real_arrival}->epoch - $ret->{real_departure}->epoch; + $ret->{journey_completion} + = $ret->{journey_duration} + ? 1 - ( $ret->{arrival_countdown} / $ret->{journey_duration} ) + : 1; + if ( $ret->{journey_completion} > 1 ) { + $ret->{journey_completion} = 1; + } + elsif ( $ret->{journey_completion} < 0 ) { + $ret->{journey_completion} = 0; + } + + } + else { + $ret->{arrival_countdown} = undef; + $ret->{journey_duration} = undef; + $ret->{journey_completion} = undef; + } + + return $ret; +} + +sub get { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $table = 'in_transit'; + + if ( $opt{with_timestamps} ) { + $table = 'in_transit_str'; + } + + my $res = $db->select( $table, '*', { user_id => $uid } ); + my $ret; + + if ( $opt{with_data} ) { + $ret = $res->expand->hash; + } + else { + $ret = $res->hash; + } + + if ( $opt{with_visibility} and $ret ) { + $ret->{visibility_str} + = $ret->{visibility} + ? $visibility_itoa{ $ret->{visibility} } + : 'default'; + $ret->{effective_visibility_str} + = $visibility_itoa{ $ret->{effective_visibility} }; + } + + if ( $opt{postprocess} and $ret ) { + return $self->postprocess($ret); + } + + return $ret; +} + +sub get_timeline { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $where = { + follower_id => $uid, + effective_visibility => { '>=', 60 } + }; + + if ( $opt{short} ) { + return $db->select( + 'follows_in_transit', + [ + qw(followee_name train_type train_line train_no train_id dep_eva dep_name arr_eva arr_name) + ], + $where + )->hashes->each; + } + + my $res = $db->select( 'follows_in_transit', '*', $where ); + my $ret; + + if ( $opt{with_data} ) { + return map { $self->postprocess($_) } $res->expand->hashes->each; + } + else { + return $res->hashes->each; + } +} + +sub get_all_active { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + return $db->select( 'in_transit_str', '*', { cancelled => 0 } ) + ->hashes->each; +} + +sub get_checkout_station_id { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $status = $db->select( 'in_transit', ['checkout_station_id'], + { user_id => $uid } )->hash; + + if ($status) { + return $status->{checkout_station_id}; + } + return; +} + +sub set_cancelled_destination { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $cancelled_destination = $opt{cancelled_destination}; + + my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } ) + ->expand->hash; + + my $data = $res_h ? $res_h->{data} : {}; + + $data->{cancelled_destination} = $cancelled_destination; + + $db->update( + 'in_transit', + { + checkout_station_id => undef, + checkout_time => undef, + arr_platform => undef, + sched_arrival => undef, + real_arrival => undef, + data => JSON->new->encode($data), + }, + { user_id => $uid } + ); +} + +sub set_arrival { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $train = $opt{train}; + my $route = $opt{route}; + + $route = $self->_merge_old_route( + db => $db, + uid => $uid, + route => $route + ); + + my $json = JSON->new; + + $db->update( + 'in_transit', + { + checkout_time => DateTime->now( time_zone => 'Europe/Berlin' ), + arr_platform => $train->platform, + sched_arrival => $train->sched_arrival, + real_arrival => $train->arrival, + route => $json->encode($route), + messages => $json->encode( + [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ] + ) + }, + { user_id => $uid } + ); +} + +sub set_arrival_eva { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $checkout_station_id = $opt{arrival_eva}; + + $db->update( + 'in_transit', + { + checkout_station_id => $checkout_station_id, + }, + { user_id => $uid } + ); +} + +sub set_arrival_times { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $sched_arr = $opt{sched_arrival}; + my $rt_arr = $opt{rt_arrival}; + + $db->update( + 'in_transit', + { + sched_arrival => $sched_arr, + real_arrival => $rt_arr + }, + { user_id => $uid } + ); +} + +sub set_polyline { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $polyline = $opt{polyline}; + my $old_id = $opt{old_id}; + + my $coords = $polyline->{coords}; + my $from_eva = $polyline->{from_eva}; + my $to_eva = $polyline->{to_eva}; + + my $polyline_str = JSON->new->encode($coords); + + my $pl_res = $db->select( + 'polylines', + ['id'], + { + origin_eva => $from_eva, + destination_eva => $to_eva, + polyline => $polyline_str, + }, + { limit => 1 } + ); + + my $polyline_id; + if ( my $h = $pl_res->hash ) { + $polyline_id = $h->{id}; + } + else { + eval { + $polyline_id = $db->insert( + 'polylines', + { + origin_eva => $from_eva, + destination_eva => $to_eva, + polyline => $polyline_str + }, + { returning => 'id' } + )->hash->{id}; + }; + if ($@) { + $self->{log}->warn("add_route_timestamps: insert polyline: $@"); + } + } + if ( $polyline_id and ( not defined $old_id or $polyline_id != $old_id ) ) { + $self->set_polyline_id( + uid => $uid, + db => $db, + polyline_id => $polyline_id + ); + } + +} + +sub set_polyline_id { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $polyline_id = $opt{polyline_id}; + + $db->update( + 'in_transit', + { polyline_id => $polyline_id }, + { user_id => $uid } + ); +} + +sub set_route_data { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $route = $opt{route}; + my $delay_msg = $opt{delay_messages}; + my $qos_msg = $opt{qos_messages}; + my $him_msg = $opt{him_messages}; + + my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } ) + ->expand->hash; + + my $data = $res_h ? $res_h->{data} : {}; + + $data->{delay_msg} = $opt{delay_messages}; + $data->{qos_msg} = $opt{qos_messages}; + $data->{him_msg} = $opt{him_messages}; + + # no need to merge $route, it already contains HAFAS data + $db->update( + 'in_transit', + { + route => JSON->new->encode($route), + data => JSON->new->encode($data) + }, + { user_id => $uid } + ); +} + +sub unset_arrival_data { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + $db->update( + 'in_transit', + { + checkout_time => undef, + arr_platform => undef, + sched_arrival => undef, + real_arrival => undef, + }, + { user_id => $uid } + ); +} + +sub update_departure { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $dep_eva = $opt{dep_eva}; + my $arr_eva = $opt{arr_eva}; + my $train = $opt{train}; + my $route = $opt{route}; + my $json = JSON->new; + + $route = $self->_merge_old_route( + db => $db, + uid => $uid, + route => $route + ); + + # selecting on user_id and train_no avoids a race condition if a user checks + # into a new train while we are fetching data for their previous journey. In + # this case, the new train would receive data from the previous journey. + $db->update( + 'in_transit', + { + dep_platform => $train->platform, + real_departure => $train->departure, + route => $json->encode($route), + messages => $json->encode( + [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ] + ), + }, + { + user_id => $uid, + train_no => $train->train_no, + checkin_station_id => $dep_eva, + checkout_station_id => $arr_eva, + } + ); +} + +sub update_departure_cancelled { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $dep_eva = $opt{dep_eva}; + my $arr_eva = $opt{arr_eva}; + my $train = $opt{train}; + + # depending on the amount of users in transit, some time may + # have passed between fetching $entry from the database and + # now. Ensure that the user is still checked into this train + # by selecting on uid, train no, and checkin/checkout station ID. + my $rows = $db->update( + 'in_transit', + { + cancelled => 1, + }, + { + user_id => $uid, + train_no => $train->train_no, + checkin_station_id => $dep_eva, + checkout_station_id => $arr_eva, + } + )->rows; + + return $rows; +} + +sub update_departure_hafas { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $dep_eva = $opt{dep_eva}; + my $arr_eva = $opt{arr_eva}; + my $journey = $opt{journey}; + my $stop = $opt{stop}; + my $json = JSON->new; + + # selecting on user_id and train_no avoids a race condition if a user checks + # into a new train while we are fetching data for their previous journey. In + # this case, the new train would receive data from the previous journey. + $db->update( + 'in_transit', + { + real_departure => $stop->{rt_dep}, + }, + { + user_id => $uid, + train_id => $journey->id, + checkin_station_id => $dep_eva, + checkout_station_id => $arr_eva, + } + ); +} + +sub update_arrival { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $dep_eva = $opt{dep_eva}; + my $arr_eva = $opt{arr_eva}; + my $train = $opt{train}; + my $route = $opt{route}; + my $json = JSON->new; + + $route = $self->_merge_old_route( + db => $db, + uid => $uid, + route => $route + ); + + # selecting on user_id, train_no and checkout_station_id avoids a + # race condition when a user checks into a new train or changes + # their destination station while we are fetching times based on no + # longer valid database entries. + my $rows = $db->update( + 'in_transit', + { + arr_platform => $train->platform, + sched_arrival => $train->sched_arrival, + real_arrival => $train->arrival, + route => $json->encode($route), + messages => $json->encode( + [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ] + ), + }, + { + user_id => $uid, + train_no => $train->train_no, + checkin_station_id => $dep_eva, + checkout_station_id => $arr_eva, + } + )->rows; + + return $rows; +} + +sub update_arrival_hafas { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $dep_eva = $opt{dep_eva}; + my $arr_eva = $opt{arr_eva}; + my $journey = $opt{journey}; + my $stop = $opt{stop}; + my $json = JSON->new; + + # TODO use old rt data if available + my @route; + for my $j_stop ( $journey->route ) { + push( + @route, + [ + $j_stop->loc->name, + $j_stop->loc->eva, + { + sched_arr => _epoch( $j_stop->sched_arr ), + sched_dep => _epoch( $j_stop->sched_dep ), + rt_arr => _epoch( $j_stop->rt_arr ), + rt_dep => _epoch( $j_stop->rt_dep ), + arr_delay => $j_stop->arr_delay, + dep_delay => $j_stop->dep_delay, + load => $j_stop->load + } + ] + ); + if ( defined $j_stop->tz_offset ) { + $route[-1][2]{tz_offset} = $j_stop->tz_offset; + } + } + + my $res_h = $db->select( 'in_transit', ['route'], { user_id => $uid } ) + ->expand->hash; + my $old_route = $res_h ? $res_h->{route} : []; + + for my $i ( 0 .. $#route ) { + if ( $old_route->[$i] and $old_route->[$i][1] == $route[$i][1] ) { + for my $k (qw(rt_arr rt_dep arr_delay dep_delay)) { + $route[$i][2]{$k} //= $old_route->[$i][2]{$k}; + } + } + } + + # selecting on user_id and train_no avoids a race condition if a user checks + # into a new train while we are fetching data for their previous journey. In + # this case, the new train would receive data from the previous journey. + $db->update( + 'in_transit', + { + real_arrival => $stop->{rt_arr}, + route => $json->encode( [@route] ), + }, + { + user_id => $uid, + train_id => $journey->id, + checkin_station_id => $dep_eva, + checkout_station_id => $arr_eva, + } + ); +} + +sub update_data { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $new_data = $opt{data} // {}; + + my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } ) + ->expand->hash; + + my $data = $res_h ? $res_h->{data} : {}; + + while ( my ( $k, $v ) = each %{$new_data} ) { + $data->{$k} = $v; + } + + $db->update( + 'in_transit', + { data => JSON->new->encode($data) }, + { user_id => $uid } + ); +} + +sub update_user_data { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $new_data = $opt{user_data} // {}; + + my $res_h = $db->select( 'in_transit', ['user_data'], { user_id => $uid } ) + ->expand->hash; + + my $data = $res_h ? $res_h->{user_data} : {}; + + while ( my ( $k, $v ) = each %{$new_data} ) { + $data->{$k} = $v; + } + + $db->update( + 'in_transit', + { user_data => JSON->new->encode($data) }, + { user_id => $uid } + ); +} + +sub update_visibility { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $visibility; + + if ( $opt{visibility} and $visibility_atoi{ $opt{visibility} } ) { + $visibility = $visibility_atoi{ $opt{visibility} }; + } + + $db->update( + 'in_transit', + { visibility => $visibility }, + { user_id => $uid } + ); +} + +1; diff --git a/lib/Travelynx/Model/JourneyStatsCache.pm b/lib/Travelynx/Model/JourneyStatsCache.pm new file mode 100755 index 0000000..d23eb04 --- /dev/null +++ b/lib/Travelynx/Model/JourneyStatsCache.pm @@ -0,0 +1,122 @@ +package Travelynx::Model::JourneyStatsCache; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use strict; +use warnings; +use 5.020; +use utf8; + +import JSON; + +sub new { + my ( $class, %opt ) = @_; + + return bless( \%opt, $class ); +} + +sub add { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + + eval { + $db->insert( + 'journey_stats', + { + user_id => $opt{uid}, + year => $opt{year}, + month => $opt{month}, + data => JSON->new->encode( $opt{stats} ), + } + ); + }; + if ( my $err = $@ ) { + if ( $err =~ m{duplicate key value violates unique constraint} ) { + + # If a user opens the same history page several times in + # short succession, there is a race condition where several + # Mojolicious workers execute this helper, notice that there is + # no up-to-date history, compute it, and insert it using the + # statement above. This will lead to a uniqueness violation + # in each successive insert. However, this is harmless, and + # thus ignored. + } + else { + # Otherwise we probably have a problem. + die($@); + } + } +} + +sub get { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + + my $stats = $db->select( + 'journey_stats', + ['data'], + { + user_id => $opt{uid}, + year => $opt{year}, + month => $opt{month} + } + )->expand->hash; + + return $stats->{data}; +} + +# Statistics are partitioned by real_departure, which must be provided +# when calling this function e.g. after journey deletion or editing. +# If a joureny's real_departure has been edited, this function must be +# called twice: once with the old and once with the new value. +sub invalidate { + my ( $self, %opt ) = @_; + + my $ts = $opt{ts}; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + $db->delete( + 'journey_stats', + { + user_id => $uid, + year => $ts->year, + month => $ts->month, + } + ); + $db->delete( + 'journey_stats', + { + user_id => $uid, + year => $ts->year, + month => 0, + } + ); +} + +sub get_yyyymm_having_stats { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $res = $db->select( + 'journey_stats', + [ 'year', 'month' ], + { user_id => $uid }, + { order_by => { -asc => [ 'year', 'month' ] } } + ); + + my @ret; + for my $row ( $res->hashes->each ) { + if ( $row->{month} != 0 ) { + push( @ret, [ $row->{year}, $row->{month} ] ); + } + } + + return @ret; +} + +1; diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm new file mode 100755 index 0000000..97c4681 --- /dev/null +++ b/lib/Travelynx/Model/Journeys.pm @@ -0,0 +1,1785 @@ +package Travelynx::Model::Journeys; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use GIS::Distance; +use List::MoreUtils qw(after_incl before_incl); + +use strict; +use warnings; +use 5.020; +use utf8; + +use DateTime; +use JSON; + +my %visibility_itoa = ( + 100 => 'public', + 80 => 'travelynx', + 60 => 'followers', + 30 => 'unlisted', + 10 => 'private', +); + +my %visibility_atoi = ( + public => 100, + travelynx => 80, + followers => 60, + unlisted => 30, + private => 10, +); + +my @month_name + = ( + qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember) + ); + +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 min_to_human { + my ( $self, $minutes ) = @_; + + my @ret; + + if ( $minutes >= 14 * 24 * 60 ) { + push( @ret, int( $minutes / ( 7 * 24 * 60 ) ) . ' Wochen' ); + } + elsif ( $minutes >= 7 * 24 * 60 ) { + push( @ret, '1 Woche' ); + } + $minutes %= 7 * 24 * 60; + + if ( $minutes >= 2 * 24 * 60 ) { + push( @ret, int( $minutes / ( 24 * 60 ) ) . ' Tage' ); + } + elsif ( $minutes >= 24 * 60 ) { + push( @ret, '1 Tag' ); + } + $minutes %= 24 * 60; + + if ( $minutes >= 2 * 60 ) { + push( @ret, int( $minutes / 60 ) . ' Stunden' ); + } + elsif ( $minutes >= 60 ) { + push( @ret, '1 Stunde' ); + } + $minutes %= 60; + + if ( $minutes >= 2 ) { + push( @ret, "$minutes Minuten" ); + } + elsif ($minutes) { + push( @ret, '1 Minute' ); + } + + if ( @ret == 0 ) { + return '0 Minuten'; + } + + if ( @ret == 1 ) { + return $ret[0]; + } + + my $last = pop(@ret); + return join( ', ', @ret ) . " und $last"; +} + +sub new { + my ( $class, %opt ) = @_; + + return bless( \%opt, $class ); +} + +sub stats_cache { + my ($self) = @_; + return $self->{stats_cache}; +} + +# Returns (journey id, error) +# Must be called during a transaction. +# Must perform a rollback on error. +sub add { + my ( $self, %opt ) = @_; + + my $db = $opt{db}; + my $uid = $opt{uid}; + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + my $dep_station = $self->{stations}->search( $opt{dep_station} ); + my $arr_station = $self->{stations}->search( $opt{arr_station} ); + + if ( not $dep_station ) { + return ( undef, 'Unbekannter Startbahnhof' ); + } + if ( not $arr_station ) { + return ( undef, 'Unbekannter Zielbahnhof' ); + } + + my $daily_journey_count = $db->select( + 'journeys_str', + 'count(*) as count', + { + user_id => $uid, + real_dep_ts => { + -between => [ + $opt{rt_departure}->clone->subtract( days => 1 )->epoch, + $opt{rt_departure}->epoch + ], + }, + } + )->hash->{count}; + + if ( $daily_journey_count >= 100 ) { + return ( undef, +"In den 24 Stunden vor der angegebenen Abfahrtszeit wurden ${daily_journey_count} weitere Fahrten angetreten. Das kann nicht stimmen." + ); + } + + my $route_has_start = 0; + my $route_has_stop = 0; + + for my $station ( @{ $opt{route} || [] } ) { + if ( $station eq $dep_station->{name} + or $station eq $dep_station->{ds100} ) + { + $route_has_start = 1; + } + if ( $station eq $arr_station->{name} + or $station eq $arr_station->{ds100} ) + { + $route_has_stop = 1; + } + } + + my @route; + + if ( not $route_has_start ) { + push( @route, [ $dep_station->{name}, $dep_station->{eva}, {} ] ); + } + + if ( $opt{route} ) { + my @unknown_stations; + for my $station ( @{ $opt{route} } ) { + my $station_info = $self->{stations}->search($station); + if ($station_info) { + push( @route, + [ $station_info->{name}, $station_info->{eva}, {} ] ); + } + else { + push( @route, [ $station, undef, {} ] ); + push( @unknown_stations, $station ); + } + } + + if ( not $opt{lax} ) { + if ( @unknown_stations == 1 ) { + return ( undef, + "Unbekannter Unterwegshalt: $unknown_stations[0]" ); + } + elsif (@unknown_stations) { + return ( undef, + 'Unbekannte Unterwegshalte: ' + . join( ', ', @unknown_stations ) ); + } + } + } + + if ( not $route_has_stop ) { + push( @route, [ $arr_station->{name}, $arr_station->{eva}, {} ] ); + } + + my $entry = { + user_id => $uid, + train_type => $opt{train_type}, + train_line => $opt{train_line}, + train_no => $opt{train_no}, + train_id => 'manual', + checkin_station_id => $dep_station->{eva}, + checkin_time => $now, + sched_departure => $opt{sched_departure}, + real_departure => $opt{rt_departure}, + checkout_station_id => $arr_station->{eva}, + sched_arrival => $opt{sched_arrival}, + real_arrival => $opt{rt_arrival}, + checkout_time => $now, + edited => 0x3fff, + cancelled => $opt{cancelled} ? 1 : 0, + route => JSON->new->encode( \@route ), + }; + + if ( $opt{comment} ) { + $entry->{user_data} + = JSON->new->encode( { comment => $opt{comment} } ); + } + + my $journey_id = undef; + eval { + $journey_id + = $db->insert( 'journeys', $entry, { returning => 'id' } ) + ->hash->{id}; + $self->stats_cache->invalidate( + ts => $opt{rt_departure}, + db => $db, + uid => $uid + ); + }; + + if ($@) { + $self->{log}->error("add_journey($uid): $@"); + return ( undef, 'add_journey failed: ' . $@ ); + } + + return ( $journey_id, undef ); +} + +sub add_from_in_transit { + my ( $self, %opt ) = @_; + my $db = $opt{db}; + my $journey = $opt{journey}; + + delete $journey->{data}; + $journey->{edited} = 0; + $journey->{checkout_time} = DateTime->now( time_zone => 'Europe/Berlin' ); + + return $db->insert( 'journeys', $journey, { returning => 'id' } ) + ->hash->{id}; +} + +sub update { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $journey_id = $opt{id}; + + my $rows; + + my $journey = $self->get_single( + uid => $uid, + db => $db, + journey_id => $journey_id, + with_datetime => 1, + ); + + eval { + if ( exists $opt{from_name} ) { + my $from_station = $self->{stations}->search( $opt{from_name} ); + if ( not $from_station ) { + die("Unbekannter Startbahnhof\n"); + } + $rows = $db->update( + 'journeys', + { + checkin_station_id => $from_station->{eva}, + edited => $journey->{edited} | 0x0004, + }, + { + id => $journey_id, + } + )->rows; + } + if ( exists $opt{to_name} ) { + my $to_station = $self->{stations}->search( $opt{to_name} ); + if ( not $to_station ) { + die("Unbekannter Zielbahnhof\n"); + } + $rows = $db->update( + 'journeys', + { + checkout_station_id => $to_station->{eva}, + edited => $journey->{edited} | 0x0400, + }, + { + id => $journey_id, + } + )->rows; + } + if ( exists $opt{sched_departure} ) { + $rows = $db->update( + 'journeys', + { + sched_departure => $opt{sched_departure}, + edited => $journey->{edited} | 0x0001, + }, + { + id => $journey_id, + } + )->rows; + } + if ( exists $opt{rt_departure} ) { + $rows = $db->update( + 'journeys', + { + real_departure => $opt{rt_departure}, + edited => $journey->{edited} | 0x0002, + }, + { + id => $journey_id, + } + )->rows; + + # stats are partitioned by rt_departure -> both the cache for + # the old value (see bottom of this function) and the new value + # (here) must be invalidated. + $self->stats_cache->invalidate( + ts => $opt{rt_departure}, + db => $db, + uid => $uid, + ); + } + if ( exists $opt{sched_arrival} ) { + $rows = $db->update( + 'journeys', + { + sched_arrival => $opt{sched_arrival}, + edited => $journey->{edited} | 0x0100, + }, + { + id => $journey_id, + } + )->rows; + } + if ( exists $opt{rt_arrival} ) { + $rows = $db->update( + 'journeys', + { + real_arrival => $opt{rt_arrival}, + edited => $journey->{edited} | 0x0200, + }, + { + id => $journey_id, + } + )->rows; + } + if ( exists $opt{route} ) { + my @new_route = map { [ $_, undef, {} ] } @{ $opt{route} }; + $rows = $db->update( + 'journeys', + { + route => JSON->new->encode( \@new_route ), + edited => $journey->{edited} | 0x0010, + }, + { + id => $journey_id, + } + )->rows; + } + if ( exists $opt{cancelled} ) { + $rows = $db->update( + 'journeys', + { + cancelled => $opt{cancelled}, + edited => $journey->{edited} | 0x0020, + }, + { + id => $journey_id, + } + )->rows; + } + if ( exists $opt{comment} ) { + $journey->{user_data}{comment} = $opt{comment}; + $rows = $db->update( + 'journeys', + { + user_data => JSON->new->encode( $journey->{user_data} ), + }, + { + id => $journey_id, + } + )->rows; + } + if ( not defined $rows ) { + die("Invalid update key\n"); + } + }; + + if ($@) { + $self->{log}->error("update($journey_id): $@"); + return "update($journey_id): $@"; + } + if ( $rows == 1 ) { + $self->stats_cache->invalidate( + ts => $journey->{rt_departure}, + db => $db, + uid => $uid, + ); + return undef; + } + return "update($journey_id): did not match any journey part"; +} + +sub delete { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $journey_id = $opt{id}; + my $checkin_epoch = $opt{checkin}; + my $checkout_epoch = $opt{checkout}; + + my @journeys = $self->get( + uid => $uid, + journey_id => $journey_id + ); + if ( @journeys == 0 ) { + return 'Journey not found'; + } + my $journey = $journeys[0]; + + # Double-check (comparing both ID and action epoch) to make sure we + # are really deleting the right journey and the user isn't just + # playing around with POST requests. + if ( $journey->{id} != $journey_id + or $journey->{checkin_ts} != $checkin_epoch + or $journey->{checkout_ts} != $checkout_epoch ) + { + return 'Invalid journey data'; + } + + my $rows; + eval { + $rows = $db->delete( + 'journeys', + { + user_id => $uid, + id => $journey_id, + } + )->rows; + }; + + if ($@) { + $self->{log}->error("Delete($uid, $journey_id): $@"); + return 'DELETE failed: ' . $@; + } + + if ( $rows == 1 ) { + $self->stats_cache->invalidate( + ts => epoch_to_dt( $journey->{rt_dep_ts} ), + uid => $uid + ); + return undef; + } + return sprintf( 'Deleted %d rows, expected 1', $rows ); +} + +# Used for undo (move journey entry to in_transit) +sub pop { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db}; + my $journey_id = $opt{journey_id}; + + my $journey = $db->select( + 'journeys', + '*', + { + user_id => $uid, + id => $journey_id + } + )->hash; + + $db->delete( + 'journeys', + { + user_id => $uid, + id => $journey_id + } + ); + + return $journey; +} + +sub get { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + + # If get is called from inside a transaction, db + # specifies the database handle performing the transaction. + # Otherwise, we grab a fresh one. + my $db = $opt{db} // $self->{pg}->db; + + my @select + = ( + qw(journey_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva dep_ds100 dep_name dep_lat dep_lon checkout_ts sched_arr_ts real_arr_ts arr_eva arr_ds100 arr_name arr_lat arr_lon cancelled edited route messages user_data visibility effective_visibility) + ); + my %where = ( + user_id => $uid, + cancelled => 0 + ); + my %order = ( + order_by => { + -desc => 'real_dep_ts', + } + ); + + if ( $opt{cancelled} ) { + $where{cancelled} = 1; + } + + if ( $opt{limit} ) { + $order{limit} = $opt{limit}; + } + + if ( $opt{sched_dep_ts} ) { + $where{sched_dep_ts} = $opt{sched_dep_ts}; + } + + if ( $opt{journey_id} ) { + $where{journey_id} = $opt{journey_id}; + delete $where{cancelled}; + } + elsif ( $opt{after} and $opt{before} ) { + $where{real_dep_ts} + = { -between => [ $opt{after}->epoch, $opt{before}->epoch, ] }; + } + elsif ( $opt{after} ) { + $where{real_dep_ts} = { '>=', $opt{after}->epoch }; + } + elsif ( $opt{before} ) { + $where{real_dep_ts} = { '<=', $opt{before}->epoch }; + } + + if ( $opt{with_polyline} ) { + push( @select, 'polyline' ); + } + + if ( $opt{min_visibility} ) { + if ( $visibility_atoi{ $opt{min_visibility} } ) { + $opt{min_visibility} = $visibility_atoi{ $opt{min_visibility} }; + } + $where{effective_visibility} = { '>=', $opt{min_visibility} }; + } + + my @travels; + + my $res = $db->select( 'journeys_str', \@select, \%where, \%order ); + + for my $entry ( $res->expand->hashes->each ) { + + my $ref = { + id => $entry->{journey_id}, + type => $entry->{train_type}, + line => $entry->{train_line}, + no => $entry->{train_no}, + from_eva => $entry->{dep_eva}, + from_ds100 => $entry->{dep_ds100}, + from_name => $entry->{dep_name}, + from_latlon => [ $entry->{dep_lat}, $entry->{dep_lon} ], + checkin_ts => $entry->{checkin_ts}, + sched_dep_ts => $entry->{sched_dep_ts}, + rt_dep_ts => $entry->{real_dep_ts}, + to_eva => $entry->{arr_eva}, + to_ds100 => $entry->{arr_ds100}, + to_name => $entry->{arr_name}, + to_latlon => [ $entry->{arr_lat}, $entry->{arr_lon} ], + checkout_ts => $entry->{checkout_ts}, + sched_arr_ts => $entry->{sched_arr_ts}, + rt_arr_ts => $entry->{real_arr_ts}, + messages => $entry->{messages}, + route => $entry->{route}, + edited => $entry->{edited}, + user_data => $entry->{user_data}, + visibility => $entry->{visibility}, + effective_visibility => $entry->{effective_visibility}, + }; + + if ( $opt{with_visibility} ) { + $ref->{visibility_str} + = $ref->{visibility} + ? $visibility_itoa{ $ref->{visibility} } + : 'default'; + $ref->{effective_visibility_str} + = $visibility_itoa{ $ref->{effective_visibility} }; + } + + if ( $opt{with_polyline} ) { + $ref->{polyline} = $entry->{polyline}; + } + + if ( $opt{with_datetime} ) { + $ref->{checkin} = epoch_to_dt( $ref->{checkin_ts} ); + $ref->{sched_departure} + = epoch_to_dt( $ref->{sched_dep_ts} ); + $ref->{rt_departure} = epoch_to_dt( $ref->{rt_dep_ts} ); + $ref->{checkout} = epoch_to_dt( $ref->{checkout_ts} ); + $ref->{sched_arrival} = epoch_to_dt( $ref->{sched_arr_ts} ); + $ref->{rt_arrival} = epoch_to_dt( $ref->{rt_arr_ts} ); + for my $stop ( @{ $ref->{route} } ) { + for my $k (qw(rt_arr rt_dep sched_arr sched_dep)) { + if ( $stop->[2]{$k} ) { + $stop->[2]{$k} = epoch_to_dt( $stop->[2]{$k} ); + } + } + } + } + + if ( $opt{verbose} ) { + my $rename = $self->{renamed_station}; + for my $stop ( @{ $ref->{route} } ) { + if ( $stop->[0] =~ m{^Betriebsstelle nicht bekannt (\d+)$} ) { + if ( my $s = $self->{stations}->get_by_eva($1) ) { + $stop->[0] = $s->{name}; + } + } + if ( $rename->{ $stop->[0] } ) { + $stop->[0] = $rename->{ $stop->[0] }; + } + } + $ref->{cancelled} = $entry->{cancelled}; + my @parsed_messages; + for my $message ( @{ $ref->{messages} // [] } ) { + my ( $ts, $msg ) = @{$message}; + push( @parsed_messages, [ epoch_to_dt($ts), $msg ] ); + } + $ref->{messages} = [ reverse @parsed_messages ]; + $ref->{sched_duration} + = defined $ref->{sched_arr_ts} + ? $ref->{sched_arr_ts} - $ref->{sched_dep_ts} + : undef; + $ref->{rt_duration} + = defined $ref->{rt_arr_ts} + ? $ref->{rt_arr_ts} - $ref->{rt_dep_ts} + : undef; + my ( $km_polyline, $km_route, $km_beeline, $skip ) + = $self->get_travel_distance($ref); + $ref->{km_route} = $km_polyline || $km_route; + $ref->{skip_route} = $km_polyline ? 0 : $skip; + $ref->{km_beeline} = $km_beeline; + $ref->{skip_beeline} = $skip; + my $kmh_divisor + = ( $ref->{rt_duration} // $ref->{sched_duration} // 999999 ) + / 3600; + $ref->{kmh_route} + = $kmh_divisor ? $ref->{km_route} / $kmh_divisor : -1; + $ref->{kmh_beeline} + = $kmh_divisor + ? $ref->{km_beeline} / $kmh_divisor + : -1; + } + + push( @travels, $ref ); + } + + return @travels; +} + +sub get_single { + my ( $self, %opt ) = @_; + + $opt{cancelled} = 'any'; + my @journeys = $self->get(%opt); + if ( @journeys == 0 ) { + return undef; + } + + return $journeys[0]; +} + +sub get_latest { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $latest_successful = $db->select( + 'journeys_str', + '*', + { + user_id => $uid, + cancelled => 0 + }, + { + order_by => { -desc => 'real_dep_ts' }, + limit => 1 + } + )->expand->hash; + + if ($latest_successful) { + $latest_successful->{visibility_str} + = $latest_successful->{visibility} + ? $visibility_itoa{ $latest_successful->{visibility} } + : 'default'; + $latest_successful->{effective_visibility_str} + = $visibility_itoa{ $latest_successful->{effective_visibility} }; + } + + my $latest = $db->select( + 'journeys_str', + '*', + { + user_id => $uid, + }, + { + order_by => { -desc => 'journey_id' }, + limit => 1 + } + )->expand->hash; + + if ($latest) { + $latest->{visibility_str} + = $latest->{visibility} + ? $visibility_itoa{ $latest->{visibility} } + : 'default'; + $latest->{effective_visibility_str} + = $visibility_itoa{ $latest->{effective_visibility} }; + } + + return ( $latest_successful, $latest ); +} + +sub get_oldest_ts { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $res_h = $db->select( + 'journeys_str', + ['sched_dep_ts'], + { + user_id => $uid, + }, + { + limit => 1, + order_by => { + -asc => 'real_dep_ts', + }, + } + )->hash; + + if ($res_h) { + return epoch_to_dt( $res_h->{sched_dep_ts} ); + } + return undef; +} + +sub get_latest_checkout_station_id { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $res_h = $db->select( + 'journeys', + ['checkout_station_id'], + { + user_id => $uid, + cancelled => 0 + }, + { + limit => 1, + order_by => { -desc => 'real_departure' } + } + )->hash; + + if ( not $res_h ) { + return; + } + + return $res_h->{checkout_station_id}; +} + +sub get_latest_checkout_stations { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $limit = $opt{limit} // 5; + + my $res = $db->select( + 'journeys_str', + [ 'arr_name', 'arr_eva', 'train_id' ], + { + user_id => $uid, + cancelled => 0 + }, + { + limit => $limit, + order_by => { -desc => 'journey_id' } + } + ); + + if ( not $res ) { + return; + } + + my @ret; + + while ( my $row = $res->hash ) { + push( + @ret, + { + name => $row->{arr_name}, + eva => $row->{arr_eva}, + hafas => ( $row->{train_id} =~ m{[|]} ? 1 : 0 ), + } + ); + } + + return @ret; +} + +sub get_nav_years { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $res = $db->select( + 'journeys', + 'distinct extract(year from real_departure) as year', + { user_id => $uid }, + { order_by => { -asc => 'year' } } + ); + + my @ret; + for my $row ( $res->hashes->each ) { + push( @ret, [ $row->{year}, $row->{year} ] ); + } + return @ret; +} + +sub get_years { + my ( $self, %opt ) = @_; + + my @years = $self->get_nav_years(%opt); + + for my $year (@years) { + my $stats = $self->stats_cache->get( + uid => $opt{uid}, + year => $year, + month => 0, + ); + $year->[2] = $stats // {}; + } + return @years; +} + +sub get_months_for_year { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $year = $opt{year}; + + my $res = $db->select( + 'journeys', +'distinct extract(year from real_departure) as year, extract(month from real_departure) as month', + { user_id => $uid }, + { order_by => { -asc => 'year' } } + ); + + my @ret; + + for my $month ( 1 .. 12 ) { + push( @ret, + [ sprintf( '%d/%02d', $year, $month ), $month_name[ $month - 1 ] ] + ); + } + + for my $row ( $res->hashes->each ) { + if ( $row->{year} == $year ) { + + my $stats = $self->stats_cache->get( + db => $db, + uid => $uid, + year => $year, + month => $row->{month} + ); + + # undef -> no journeys for this month; empty hash -> no cached stats + $ret[ $row->{month} - 1 ][2] = $stats // {}; + } + } + return @ret; +} + +sub get_yyyymm_having_journeys { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $res = $db->select( + 'journeys', + "distinct to_char(real_departure, 'YYYY.MM') as yearmonth", + { user_id => $uid }, + { order_by => { -asc => 'yearmonth' } } + ); + + my @ret; + for my $row ( $res->hashes->each ) { + push( @ret, [ split( qr{[.]}, $row->{yearmonth} ) ] ); + } + + return @ret; +} + +sub generate_missing_stats { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my @journey_months = $self->get_yyyymm_having_journeys( + uid => $uid, + db => $db + ); + my @stats_months = $self->stats_cache->get_yyyymm_having_stats( + uid => $uid, + $db => $db + ); + + my $stats_index = 0; + + for my $journey_index ( 0 .. $#journey_months ) { + if ( $stats_index < @stats_months + and $journey_months[$journey_index][0] + == $stats_months[$stats_index][0] + and $journey_months[$journey_index][1] + == $stats_months[$stats_index][1] ) + { + $stats_index++; + } + else { + my ( $year, $month ) = @{ $journey_months[$journey_index] }; + $self->get_stats( + uid => $uid, + db => $db, + year => $year, + month => $month, + write_only => 1 + ); + } + } +} + +sub get_nav_months { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $filter_year = $opt{year}; + my $filter_month = $opt{month}; + + my $selected_index = undef; + + my $res = $db->select( + 'journeys', + "distinct to_char(real_departure, 'YYYY.MM') as yearmonth", + { user_id => $uid }, + { order_by => { -asc => 'yearmonth' } } + ); + + my @months; + for my $row ( $res->hashes->each ) { + my ( $year, $month ) = split( qr{[.]}, $row->{yearmonth} ); + push( @months, [ $year, $month ] ); + if ( $year eq $filter_year and $month eq $filter_month ) { + $selected_index = $#months; + } + } + + # returns (previous entry, current month, next entry). if there is no + # previous or next entry, the corresponding field is undef. Previous/next + # entry is usually previous/next month, but may also have a distance of + # more than one month if there are months without travels + my @ret = ( undef, undef, undef ); + + $ret[1] = [ + "${filter_year}/${filter_month}", + $month_name[ $filter_month - 1 ] // $filter_month + ]; + + if ( not defined $selected_index ) { + return @ret; + } + + if ( $selected_index > 0 and $months[ $selected_index - 1 ] ) { + my ( $year, $month ) = @{ $months[ $selected_index - 1 ] }; + $ret[0] = [ "${year}/${month}", "${month}.${year}" ]; + } + if ( $selected_index < $#months ) { + my ( $year, $month ) = @{ $months[ $selected_index + 1 ] }; + $ret[2] = [ "${year}/${month}", "${month}.${year}" ]; + } + + return @ret; +} + +sub sanity_check { + my ( $self, $journey, $lax ) = @_; + + if ( defined $journey->{sched_duration} + and $journey->{sched_duration} <= 0 ) + { + return +'Die geplante Dauer dieser Fahrt ist ≤ 0. Teleportation und Zeitreisen werden aktuell nicht unterstützt.'; + } + if ( defined $journey->{rt_duration} + and $journey->{rt_duration} <= 0 ) + { + return +'Die Dauer dieser Fahrt ist ≤ 0. Teleportation und Zeitreisen werden aktuell nicht unterstützt.'; + } + if ( $journey->{sched_duration} + and $journey->{sched_duration} > 60 * 60 * 24 ) + { + return 'Die Fahrt ist länger als 24 Stunden.'; + } + if ( $journey->{rt_duration} + and $journey->{rt_duration} > 60 * 60 * 24 ) + { + return 'Die Fahrt ist länger als 24 Stunden.'; + } + if ( $journey->{kmh_route} > 500 or $journey->{kmh_beeline} > 500 ) { + return 'Fahrten mit über 500 km/h? Schön wär\'s.'; + } + if ( $journey->{route} and @{ $journey->{route} } > 199 ) { + my $stop_count = @{ $journey->{route} }; + return +"Die Fahrt hat $stop_count Unterwegshalte. Also ich weiß ja nicht so recht."; + } + if ( $journey->{edited} & 0x0010 and not $lax ) { + my @unknown_stations + = $self->{stations} + ->grep_unknown( map { $_->[0] } @{ $journey->{route} } ); + if (@unknown_stations) { + return 'Unbekannte Station(en): ' . join( ', ', @unknown_stations ); + } + } + + return undef; +} + +sub get_travel_distance { + my ( $self, $journey ) = @_; + + my $from = $journey->{from_name}; + my $from_eva = $journey->{from_eva}; + my $from_latlon = $journey->{from_latlon}; + my $to = $journey->{to_name}; + my $to_eva = $journey->{to_eva}; + my $to_latlon = $journey->{to_latlon}; + my $route_ref = $journey->{route}; + my $polyline_ref = $journey->{polyline}; + + if ( not $to ) { + $self->{log} + ->warn("Journey $journey->{id} has no to_name for EVA $to_eva"); + } + + if ( not $from ) { + $self->{log} + ->warn("Journey $journey->{id} has no from_name for EVA $from_eva"); + } + + my $distance_polyline = 0; + my $distance_intermediate = 0; + my $distance_beeline = 0; + my $skipped = 0; + my $geo = GIS::Distance->new(); + my @stations = map { $_->[0] } @{$route_ref}; + my @route = after_incl { $_ eq $from } @stations; + @route = before_incl { $_ eq $to } @route; + + if ( @route < 2 ) { + + # I AM ERROR + return ( 0, 0, 0 ); + } + + my @polyline = after_incl { $_->[2] and $_->[2] == $from_eva } + @{ $polyline_ref // [] }; + @polyline + = before_incl { $_->[2] and $_->[2] == $to_eva } @polyline; + + my $prev_station = shift @polyline; + for my $station (@polyline) { + $distance_polyline += $geo->distance_metal( + $prev_station->[1], $prev_station->[0], + $station->[1], $station->[0] + ); + $prev_station = $station; + } + + $prev_station = $self->{latlon_by_station}->{ shift @route }; + if ( not $prev_station ) { + return ( $distance_polyline, 0, 0 ); + } + + for my $station_name (@route) { + if ( my $station = $self->{latlon_by_station}->{$station_name} ) { + $distance_intermediate += $geo->distance_metal( + $prev_station->[0], $prev_station->[1], + $station->[0], $station->[1] + ); + $prev_station = $station; + } + } + + $distance_beeline = $geo->distance_metal( @{$from_latlon}, @{$to_latlon} ); + + return ( $distance_polyline, $distance_intermediate, + $distance_beeline, $skipped ); +} + +sub grep_single { + my ( $self, @journeys ) = @_; + + my %num_by_trip; + for my $journey (@journeys) { + if ( $journey->{from_name} and $journey->{to_name} ) { + $num_by_trip{ $journey->{from_name} . '|' . $journey->{to_name} } + += 1; + } + } + + return + grep { $num_by_trip{ $_->{from_name} . '|' . $_->{to_name} } == 1 } + @journeys; +} + +sub compute_review { + my ( $self, $stats, @journeys ) = @_; + my $longest_km; + my $longest_t; + my $shortest_km; + my $shortest_t; + my $most_delayed; + my $most_delay; + my $most_undelay; + my $num_cancelled = 0; + my $num_fgr = 0; + my $num_punctual = 0; + my $message_count = 0; + my %num_by_message; + my %num_by_wrtype; + my %num_by_linetype; + my %num_by_stop; + my %num_by_trip; + + if ( not $stats or not @journeys or $stats->{num_trains} == 0 ) { + return; + } + + my %review; + + for my $journey (@journeys) { + if ( $journey->{cancelled} ) { + $num_cancelled += 1; + next; + } + + my %seen; + + if ( $journey->{rt_duration} and $journey->{rt_duration} > 0 ) { + if ( not $longest_t + or $journey->{rt_duration} > $longest_t->{rt_duration} ) + { + $longest_t = $journey; + } + if ( not $shortest_t + or $journey->{rt_duration} < $shortest_t->{rt_duration} ) + { + $shortest_t = $journey; + } + } + + if ( $journey->{km_route} ) { + if ( not $longest_km + or $journey->{km_route} > $longest_km->{km_route} ) + { + $longest_km = $journey; + } + if ( not $shortest_km + or $journey->{km_route} < $shortest_km->{km_route} ) + { + $shortest_km = $journey; + } + } + + if ( $journey->{messages} and @{ $journey->{messages} } ) { + $message_count += 1; + for my $message ( @{ $journey->{messages} } ) { + if ( not $seen{ $message->[1] } ) { + $num_by_message{ $message->[1] } += 1; + $seen{ $message->[1] } = 1; + } + } + } + + if ( $journey->{type} ) { + $num_by_linetype{ $journey->{type} } += 1; + } + + if ( $journey->{from_name} ) { + $num_by_stop{ $journey->{from_name} } += 1; + } + if ( $journey->{to_name} ) { + $num_by_stop{ $journey->{to_name} } += 1; + } + if ( $journey->{from_name} and $journey->{to_name} ) { + $num_by_trip{ $journey->{from_name} . '|' . $journey->{to_name} } + += 1; + } + + if ( $journey->{sched_dep_ts} and $journey->{rt_dep_ts} ) { + $journey->{delay_dep} + = ( $journey->{rt_dep_ts} - $journey->{sched_dep_ts} ) / 60; + } + if ( $journey->{sched_arr_ts} and $journey->{rt_arr_ts} ) { + $journey->{delay_arr} + = ( $journey->{rt_arr_ts} - $journey->{sched_arr_ts} ) / 60; + } + + if ( $journey->{delay_arr} and $journey->{delay_arr} >= 60 ) { + $num_fgr += 1; + } + if ( not $journey->{delay_arr} and not $journey->{delay_dep} ) { + $num_punctual += 1; + } + + if ( $journey->{delay_arr} and $journey->{delay_arr} > 0 ) { + if ( not $most_delayed + or $journey->{delay_arr} > $most_delayed->{delay_arr} ) + { + $most_delayed = $journey; + } + } + + if ( $journey->{rt_duration} + and $journey->{sched_duration} + and $journey->{rt_duration} > 0 + and $journey->{sched_duration} > 0 ) + { + my $slowdown = $journey->{rt_duration} - $journey->{sched_duration}; + my $speedup = -$slowdown; + if ( + not $most_delay + or $slowdown > ( + $most_delay->{rt_duration} - $most_delay->{sched_duration} + ) + ) + { + $most_delay = $journey; + } + if ( + not $most_undelay + or $speedup > ( + $most_undelay->{sched_duration} + - $most_undelay->{rt_duration} + ) + ) + { + $most_undelay = $journey; + } + } + } + + my @linetypes = sort { $b->[1] <=> $a->[1] } + map { [ $_, $num_by_linetype{$_} ] } keys %num_by_linetype; + my @stops = sort { $b->[1] <=> $a->[1] } + map { [ $_, $num_by_stop{$_} ] } keys %num_by_stop; + my @trips = sort { $b->[1] <=> $a->[1] } + map { [ $_, $num_by_trip{$_} ] } keys %num_by_trip; + + my @reasons = sort { $b->[1] <=> $a->[1] } + map { [ $_, $num_by_message{$_} ] } keys %num_by_message; + + $review{num_stops} = scalar @stops; + $review{km_circle} = $stats->{km_route} / 40030; + $review{km_diag} = $stats->{km_route} / 12742; + + $review{trains_per_day} = sprintf( '%.1f', $stats->{num_trains} / 365 ); + $review{km_route} = sprintf( '%.0f', $stats->{km_route} ); + $review{km_beeline} = sprintf( '%.0f', $stats->{km_beeline} ); + $review{km_circle_h} = sprintf( '%.1f', $review{km_circle} ); + $review{km_diag_h} = sprintf( '%.1f', $review{km_diag} ); + + $review{trains_per_day} =~ tr{.}{,}; + $review{km_circle_h} =~ tr{.}{,}; + $review{km_diag_h} =~ tr{.}{,}; + + my $min_total = $stats->{min_travel_real} + $stats->{min_interchange_real}; + $review{traveling_min_total} = $min_total; + $review{traveling_percentage_year} + = sprintf( "%.1f%%", $min_total * 100 / 525948.77 ); + $review{traveling_percentage_year} =~ tr{.}{,}; + $review{traveling_time_year} = $self->min_to_human($min_total); + + if (@linetypes) { + $review{typical_type_1} = $linetypes[0][0]; + } + if ( @linetypes > 1 ) { + $review{typical_type_2} = $linetypes[1][0]; + } + if ( @stops >= 3 ) { + my $desc = q{}; + $review{typical_stops_3} = [ $stops[0][0], $stops[1][0], $stops[2][0] ]; + } + elsif ( @stops == 2 ) { + $review{typical_stops_2} = [ $stops[0][0], $stops[1][0] ]; + } + $review{typical_time} + = $self->min_to_human( $stats->{min_travel_real} / $stats->{num_trains} ); + $review{typical_km} + = sprintf( '%.0f', $stats->{km_route} / $stats->{num_trains} ); + $review{typical_kmh} = sprintf( '%.0f', + $stats->{km_route} / ( $stats->{min_travel_real} / 60 ) ); + $review{typical_delay_dep} + = sprintf( '%.0f', $stats->{delay_dep} / $stats->{num_trains} ); + $review{typical_delay_dep_h} + = $self->min_to_human( $review{typical_delay_dep} ); + $review{typical_delay_arr} + = sprintf( '%.0f', $stats->{delay_arr} / $stats->{num_trains} ); + $review{typical_delay_arr_h} + = $self->min_to_human( $review{typical_delay_arr} ); + + if ($longest_t) { + $review{longest_t_time} + = $self->min_to_human( $longest_t->{rt_duration} / 60 ); + $review{longest_t_type} = $longest_t->{type}; + $review{longest_t_lineno} = $longest_t->{line} // $longest_t->{no}; + $review{longest_t_from} = $longest_t->{from_name}; + $review{longest_t_to} = $longest_t->{to_name}; + $review{longest_t_id} = $longest_t->{id}; + } + + if ($longest_km) { + $review{longest_km_km} = sprintf( '%.0f', $longest_km->{km_route} ); + $review{longest_km_type} = $longest_km->{type}; + $review{longest_km_lineno} = $longest_km->{line} // $longest_km->{no}; + $review{longest_km_from} = $longest_km->{from_name}; + $review{longest_km_to} = $longest_km->{to_name}; + $review{longest_km_id} = $longest_km->{id}; + } + + if ($shortest_t) { + $review{shortest_t_time} + = $self->min_to_human( $shortest_t->{rt_duration} / 60 ); + $review{shortest_t_type} = $shortest_t->{type}; + $review{shortest_t_lineno} = $shortest_t->{line} // $shortest_t->{no}; + $review{shortest_t_from} = $shortest_t->{from_name}; + $review{shortest_t_to} = $shortest_t->{to_name}; + $review{shortest_t_id} = $shortest_t->{id}; + } + + if ($shortest_km) { + $review{shortest_km_m} + = sprintf( '%.0f', $shortest_km->{km_route} * 1000 ); + $review{shortest_km_type} = $shortest_km->{type}; + $review{shortest_km_lineno} = $shortest_km->{line} + // $shortest_km->{no}; + $review{shortest_km_from} = $shortest_km->{from_name}; + $review{shortest_km_to} = $shortest_km->{to_name}; + $review{shortest_km_id} = $shortest_km->{id}; + } + + if ($most_delayed) { + $review{most_delayed_type} = $most_delayed->{type}; + $review{most_delayed_delay_dep} + = $self->min_to_human( $most_delayed->{delay_dep} ); + $review{most_delayed_delay_arr} + = $self->min_to_human( $most_delayed->{delay_arr} ); + $review{most_delayed_lineno} = $most_delayed->{line} + // $most_delayed->{no}; + $review{most_delayed_from} = $most_delayed->{from_name}; + $review{most_delayed_to} = $most_delayed->{to_name}; + $review{most_delayed_id} = $most_delayed->{id}; + } + + if ($most_delay) { + $review{most_delay_type} = $most_delay->{type}; + $review{most_delay_delay_dep} = $most_delay->{delay_dep}; + $review{most_delay_delay_arr} = $most_delay->{delay_arr}; + $review{most_delay_sched_time} + = $self->min_to_human( $most_delay->{sched_duration} / 60 ); + $review{most_delay_real_time} + = $self->min_to_human( $most_delay->{rt_duration} / 60 ); + $review{most_delay_delta} + = $self->min_to_human( + ( $most_delay->{rt_duration} - $most_delay->{sched_duration} ) + / 60 ); + $review{most_delay_lineno} = $most_delay->{line} // $most_delay->{no}; + $review{most_delay_from} = $most_delay->{from_name}; + $review{most_delay_to} = $most_delay->{to_name}; + $review{most_delay_id} = $most_delay->{id}; + } + + if ($most_undelay) { + $review{most_undelay_type} = $most_undelay->{type}; + $review{most_undelay_delay_dep} = $most_undelay->{delay_dep}; + $review{most_undelay_delay_arr} = $most_undelay->{delay_arr}; + $review{most_undelay_sched_time} + = $self->min_to_human( $most_undelay->{sched_duration} / 60 ); + $review{most_undelay_real_time} + = $self->min_to_human( $most_undelay->{rt_duration} / 60 ); + $review{most_undelay_delta} + = $self->min_to_human( + ( $most_undelay->{sched_duration} - $most_undelay->{rt_duration} ) + / 60 ); + $review{most_undelay_lineno} = $most_undelay->{line} + // $most_undelay->{no}; + $review{most_undelay_from} = $most_undelay->{from_name}; + $review{most_undelay_to} = $most_undelay->{to_name}; + $review{most_undelay_id} = $most_undelay->{id}; + } + + $review{issue_percent} + = sprintf( '%.0f%%', $message_count * 100 / $stats->{num_trains} ); + for my $i ( 0 .. 2 ) { + if ( $reasons[$i] ) { + my $p = 'issue' . ( $i + 1 ); + $review{"${p}_count"} = $reasons[$i][1]; + $review{"${p}_text"} = $reasons[$i][0]; + } + } + + $review{cancel_count} = $num_cancelled; + $review{fgr_percent} = $num_fgr * 100 / $stats->{num_trains}; + $review{fgr_percent_h} = sprintf( '%.1f%%', $review{fgr_percent} ); + $review{fgr_percent_h} =~ tr{.}{,}; + $review{punctual_percent} = $num_punctual * 100 / $stats->{num_trains}; + $review{punctual_percent_h} + = sprintf( '%.1f%%', $review{punctual_percent} ); + $review{punctual_percent_h} =~ tr{.}{,}; + + my $top_trip_count = 0; + my $single_trip_count = 0; + for my $i ( 0 .. 3 ) { + if ( $trips[$i] ) { + my ( $from, $to ) = split( qr{[|]}, $trips[$i][0] ); + my $found = 0; + for my $j ( 0 .. $#{ $review{top_trips} } ) { + if ( $review{top_trips}[$j][0] eq $to + and $review{top_trips}[$j][2] eq $from ) + { + $review{top_trips}[$j][1] = '↔'; + $found = 1; + last; + } + } + if ( not $found ) { + push( @{ $review{top_trips} }, [ $from, '→', $to ] ); + } + $top_trip_count += $trips[$i][1]; + } + } + + for my $trip (@trips) { + if ( $trip->[1] == 1 ) { + $single_trip_count += 1; + if ( @{ $review{single_trips} // [] } < 3 ) { + push( + @{ $review{single_trips} }, + [ split( qr{[|]}, $trip->[0] ) ] + ); + } + } + } + + $review{top_trip_count} = $top_trip_count; + $review{top_trip_percent_h} + = sprintf( '%.1f%%', $top_trip_count * 100 / $stats->{num_trains} ); + $review{top_trip_percent_h} =~ tr{.}{,}; + + $review{single_trip_count} = $single_trip_count; + $review{single_trip_percent_h} + = sprintf( '%.1f%%', $single_trip_count * 100 / $stats->{num_trains} ); + $review{single_trip_percent_h} =~ tr{.}{,}; + + return \%review; +} + +sub compute_stats { + my ( $self, @journeys ) = @_; + my $km_route = 0; + my $km_beeline = 0; + my $min_travel_sched = 0; + my $min_travel_real = 0; + my $delay_dep = 0; + my $delay_arr = 0; + my $interchange_real = 0; + my $num_trains = 0; + my $num_journeys = 0; + my @inconsistencies; + + my $next_departure = 0; + my $next_id; + my $next_train; + + for my $journey (@journeys) { + $num_trains++; + $km_route += $journey->{km_route}; + $km_beeline += $journey->{km_beeline}; + if ( $journey->{sched_duration} + and $journey->{sched_duration} > 0 ) + { + $min_travel_sched += $journey->{sched_duration} / 60; + } + if ( $journey->{rt_duration} and $journey->{rt_duration} > 0 ) { + $min_travel_real += $journey->{rt_duration} / 60; + } + if ( $journey->{sched_dep_ts} and $journey->{rt_dep_ts} ) { + $delay_dep + += ( $journey->{rt_dep_ts} - $journey->{sched_dep_ts} ) / 60; + } + if ( $journey->{sched_arr_ts} and $journey->{rt_arr_ts} ) { + $delay_arr + += ( $journey->{rt_arr_ts} - $journey->{sched_arr_ts} ) / 60; + } + + # Note that journeys are sorted from recent to older entries + if ( $journey->{rt_arr_ts} + and $next_departure + and $next_departure - $journey->{rt_arr_ts} < ( 60 * 60 ) ) + { + if ( $next_departure - $journey->{rt_arr_ts} < 0 ) { + push( + @inconsistencies, + { + conflict => { + train => $journey->{type} . ' ' + . ( $journey->{line} // $journey->{no} ), + arr => epoch_to_dt( $journey->{rt_arr_ts} ) + ->strftime('%d.%m.%Y %H:%M'), + id => $journey->{id}, + }, + ignored => { + train => $next_train, + dep => epoch_to_dt($next_departure) + ->strftime('%d.%m.%Y %H:%M'), + id => $next_id, + }, + } + ); + } + else { + $interchange_real + += ( $next_departure - $journey->{rt_arr_ts} ) / 60; + } + } + else { + $num_journeys++; + } + $next_departure = $journey->{rt_dep_ts}; + $next_id = $journey->{id}; + $next_train + = $journey->{type} . ' ' . ( $journey->{line} // $journey->{no} ),; + } + my $ret = { + km_route => $km_route, + km_beeline => $km_beeline, + num_trains => $num_trains, + num_journeys => $num_journeys, + min_travel_sched => $min_travel_sched, + min_travel_real => $min_travel_real, + min_interchange_real => $interchange_real, + delay_dep => $delay_dep, + delay_arr => $delay_arr, + inconsistencies => \@inconsistencies, + }; + for my $key ( + qw(min_travel_sched min_travel_real min_interchange_real delay_dep delay_arr) + ) + { + my $strf_key = $key . '_strf'; + my $value = $ret->{$key}; + $ret->{$strf_key} = q{}; + if ( $ret->{$key} < 0 ) { + $ret->{$strf_key} .= '-'; + $value *= -1; + } + $ret->{$strf_key} .= sprintf( '%02d:%02d', $value / 60, $value % 60 ); + } + return $ret; +} + +sub get_stats { + my ( $self, %opt ) = @_; + + if ( $opt{cancelled} ) { + $self->{log} + ->warn('get_journey_stats called with illegal option cancelled => 1'); + return {}; + } + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $year = $opt{year} // 0; + my $month = $opt{month} // 0; + + # Assumption: If the stats cache contains an entry it is up-to-date. + # -> Cache entries must be explicitly invalidated whenever the user + # checks out of a train or manually edits/adds a journey. + + if ( + not $opt{write_only} + and not $opt{review} + and my $stats = $self->stats_cache->get( + uid => $uid, + db => $db, + year => $year, + month => $month + ) + ) + { + return $stats; + } + + my $interval_start = DateTime->new( + time_zone => 'Europe/Berlin', + year => 2000, + month => 1, + day => 1, + hour => 0, + minute => 0, + second => 0, + ); + + # I wonder if people will still be traveling by train in the year 3000 + my $interval_end = $interval_start->clone->add( years => 1000 ); + + if ( $opt{year} and $opt{month} ) { + $interval_start->set( + year => $opt{year}, + month => $opt{month} + ); + $interval_end = $interval_start->clone->add( months => 1 ); + } + elsif ( $opt{year} ) { + $interval_start->set( year => $opt{year} ); + $interval_end = $interval_start->clone->add( years => 1 ); + } + + my @journeys = $self->get( + uid => $uid, + cancelled => 0, + verbose => 1, + with_polyline => 1, + after => $interval_start, + before => $interval_end + ); + my $stats = $self->compute_stats(@journeys); + + $self->stats_cache->add( + uid => $uid, + db => $db, + year => $year, + month => $month, + stats => $stats + ); + + if ( $opt{review} ) { + my @cancelled_journeys = $self->get( + uid => $uid, + cancelled => 1, + verbose => 1, + after => $interval_start, + before => $interval_end + ); + return ( $stats, + $self->compute_review( $stats, @journeys, @cancelled_journeys ) ); + } + + return $stats; +} + +sub get_latest_dest_id { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + if ( + my $id = $self->{in_transit}->get_checkout_station_id( + uid => $uid, + db => $db + ) + ) + { + return $id; + } + + return $self->get_latest_checkout_station_id( + uid => $uid, + db => $db + ); +} + +sub get_connection_targets { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $threshold = $opt{threshold} + // DateTime->now( time_zone => 'Europe/Berlin' )->subtract( months => 4 ); + my $db = $opt{db} //= $self->{pg}->db; + my $min_count = $opt{min_count} // 3; + + if ( $opt{destination_name} ) { + return ( + [], + [ { eva => $opt{eva}, name => $opt{destination_name} } ] + ); + } + + my $dest_id = $opt{eva} // $self->get_latest_dest_id(%opt); + + if ( not $dest_id ) { + return ( [], [] ); + } + + my $dest_ids = [ $dest_id, $self->{stations}->get_meta( eva => $dest_id ) ]; + + my $res = $db->select( + 'journeys', + 'count(checkout_station_id) as count, checkout_station_id as dest', + { + user_id => $uid, + checkin_station_id => $dest_ids, + real_departure => { '>', $threshold } + }, + { + group_by => ['checkout_station_id'], + order_by => { -desc => 'count' } + } + ); + my @destinations + = $res->hashes->grep( sub { shift->{count} >= $min_count } ) + ->map( sub { shift->{dest} } )->each; + @destinations = $self->{stations}->get_by_evas(@destinations); + return ( $dest_ids, \@destinations ); +} + +sub update_visibility { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $visibility; + + if ( $opt{visibility} and $visibility_atoi{ $opt{visibility} } ) { + $visibility = $visibility_atoi{ $opt{visibility} }; + } + + $db->update( + 'journeys', + { visibility => $visibility }, + { + user_id => $uid, + id => $opt{id} + } + ); +} + +1; diff --git a/lib/Travelynx/Model/Stations.pm b/lib/Travelynx/Model/Stations.pm new file mode 100644 index 0000000..ac4019c --- /dev/null +++ b/lib/Travelynx/Model/Stations.pm @@ -0,0 +1,199 @@ +package Travelynx::Model::Stations; + +# Copyright (C) 2022 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use strict; +use warnings; +use 5.020; + +sub new { + my ( $class, %opt ) = @_; + + return bless( \%opt, $class ); +} + +sub add_or_update { + my ( $self, %opt ) = @_; + my $stop = $opt{stop}; + my $loc = $stop->loc; + my $source = 1; + my $db = $opt{db} // $self->{pg}->db; + + if ( my $s = $self->get_by_eva( $loc->eva, db => $db ) ) { + if ( $source == 1 and $s->{source} == 0 and not $s->{archived} ) { + return; + } + $db->update( + 'stations', + { + name => $loc->name, + lat => $loc->lat, + lon => $loc->lon, + source => $source, + archived => 0 + }, + { eva => $loc->eva } + ); + return; + } + $db->insert( + 'stations', + { + eva => $loc->eva, + name => $loc->name, + lat => $loc->lat, + lon => $loc->lon, + source => $source, + archived => 0 + } + ); +} + +sub add_meta { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $eva = $opt{eva}; + my @meta = @{ $opt{meta} }; + + for my $meta (@meta) { + if ( $meta != $eva ) { + $db->insert( + 'related_stations', + { + eva => $eva, + meta => $meta + }, + { on_conflict => undef } + ); + } + } +} + +sub get_db_iterator { + my ($self) = @_; + + return $self->{pg}->db->select( 'stations', '*' ); +} + +sub get_meta { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $eva = $opt{eva}; + + my $res = $db->select( 'related_stations', ['meta'], { eva => $eva } ); + my @ret; + + while ( my $row = $res->hash ) { + push( @ret, $row->{meta} ); + } + + return @ret; +} + +sub get_for_autocomplete { + my ($self) = @_; + + my $res = $self->{pg}->db->select( 'stations', ['name'] ); + my %ret; + + while ( my $row = $res->hash ) { + $ret{ $row->{name} } = undef; + } + + return \%ret; +} + +# Fast +sub get_by_eva { + my ( $self, $eva, %opt ) = @_; + + if ( not $eva ) { + return; + } + + my $db = $opt{db} // $self->{pg}->db; + + return $db->select( 'stations', '*', { eva => $eva } )->hash; +} + +# Fast +sub get_by_evas { + my ( $self, @evas ) = @_; + + my @ret + = $self->{pg}->db->select( 'stations', '*', { eva => { '=', \@evas } } ) + ->hashes->each; + return @ret; +} + +# Slow +sub get_latlon_by_name { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + + my %location; + my $res = $db->select( 'stations', [ 'name', 'lat', 'lon' ] ); + while ( my $row = $res->hash ) { + $location{ $row->{name} } = [ $row->{lat}, $row->{lon} ]; + } + return \%location; +} + +# Slow +sub get_by_name { + my ( $self, $name, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + + return $db->select( 'stations', '*', { name => $name }, { limit => 1 } ) + ->hash; +} + +# Slow +sub get_by_names { + my ( $self, @names ) = @_; + + my @ret + = $self->{pg}->db->select( 'stations', '*', { name => { '=', \@names } } ) + ->hashes->each; + return @ret; +} + +# Slow +sub get_by_ds100 { + my ( $self, $ds100, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + + return $db->select( 'stations', '*', { ds100 => $ds100 }, { limit => 1 } ) + ->hash; +} + +# Can be slow +sub search { + my ( $self, $identifier, %opt ) = @_; + + if ( $identifier =~ m{ ^ \d+ $ }x ) { + return $self->get_by_eva( $identifier, %opt ) + // $self->get_by_ds100( $identifier, %opt ) + // $self->get_by_name( $identifier, %opt ); + } + + return $self->get_by_ds100( $identifier, %opt ) + // $self->get_by_name( $identifier, %opt ); +} + +# Slow +sub grep_unknown { + my ( $self, @stations ) = @_; + + my %station = map { $_->{name} => 1 } $self->get_by_names(@stations); + my @unknown_stations = grep { not $station{$_} } @stations; + + return @unknown_stations; +} + +1; diff --git a/lib/Travelynx/Model/Traewelling.pm b/lib/Travelynx/Model/Traewelling.pm new file mode 100644 index 0000000..25648cc --- /dev/null +++ b/lib/Travelynx/Model/Traewelling.pm @@ -0,0 +1,243 @@ +package Travelynx::Model::Traewelling; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +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 mittels OAuth2 verbunden" ] ]; + + my $data = { log => $log }; + + my $user_entry = { + user_id => $opt{uid}, + push_sync => 0, + pull_sync => 0, + token => $opt{token}, + refresh_token => $opt{refresh_token}, + expiry => epoch_to_dt( $self->now->epoch + $opt{expires_in} ), + data => JSON->new->encode($data), + }; + + $self->{pg}->db->insert( + 'traewelling', + $user_entry, + { + on_conflict => \ +'(user_id) do update set token = EXCLUDED.token, refresh_token = EXCLUDED.refresh_token, expiry = EXCLUDED.expiry, push_sync = false, pull_sync = false, data = null, errored = false, latest_run = null' + } + ); + + return $user_entry; +} + +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, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $res_h + = $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] ); + } + $res_h->{expires_on} + = epoch_to_dt( $res_h->{expiry_ts} // $res_h->{data}{expires} ); + + my $expires_in = ( $res_h->{expiry_ts} // $res_h->{data}{expires} // 0 ) + - $self->now->epoch; + + if ( $expires_in < 0 ) { + $res_h->{expired} = 1; + } + elsif ( $expires_in < 14 * 24 * 3600 ) { + $res_h->{expiring} = 1; + } + + 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_ts { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $ts = $opt{ts}; + my $db = $opt{db} // $self->{pg}->db; + + my $res_h + = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash; + + $res_h->{data}{latest_push_ts} = $ts; + + $db->update( + 'traewelling', + { data => JSON->new->encode( $res_h->{data} ) }, + { user_id => $uid } + ); +} + +sub set_sync { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + my $res_h + = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash; + + $res_h->{data}{toot} = $opt{toot}; + $res_h->{data}{tweet} = $opt{tweet}; + + $db->update( + 'traewelling', + { + push_sync => $opt{push_sync}, + pull_sync => $opt{pull_sync}, + data => JSON->new->encode( $res_h->{data} ), + }, + { user_id => $uid } + ); +} + +sub get_pushable_accounts { + my ($self) = @_; + my $res = $self->{pg}->db->query( + qq{select t.user_id as uid, t.token as token, t.data as data, + i.user_data as user_data, + i.dep_eva as dep_eva, i.arr_eva as arr_eva, + i.data as journey_data, i.train_type as train_type, + i.train_line as train_line, i.train_no as train_no, + i.checkin_ts as checkin_ts, + i.sched_dep_ts as dep_ts, + i.sched_arr_ts as arr_ts, + i.effective_visibility as visibility + from traewelling as t + join in_transit_str as i on t.user_id = i.user_id + where t.push_sync = True + and i.arr_eva is not null + and i.cancelled = False + } + ); + 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/lib/Travelynx/Model/Users.pm b/lib/Travelynx/Model/Users.pm new file mode 100644 index 0000000..4602fa2 --- /dev/null +++ b/lib/Travelynx/Model/Users.pm @@ -0,0 +1,1160 @@ +package Travelynx::Model::Users; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use strict; +use warnings; +use 5.020; + +use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64); +use DateTime; +use JSON; + +my %visibility_itoa = ( + 100 => 'public', + 80 => 'travelynx', + 60 => 'followers', + 30 => 'unlisted', + 10 => 'private', +); + +my %visibility_atoi = ( + public => 100, + travelynx => 80, + followers => 60, + unlisted => 30, + private => 10, +); + +my %predicate_itoa = ( + 1 => 'follows', + 2 => 'requests_follow', + 3 => 'is_blocked_by', +); + +my %predicate_atoi = ( + follows => 1, + requests_follow => 2, + is_blocked_by => 3, +); + +my @sb_templates = ( + undef, + [ 'DBF', 'https://dbf.finalrewind.org/{name}?rt=1#{tt}{tn}' ], + [ 'bahn.expert', 'https://bahn.expert/{name}#{id}' ], + [ 'DBF HAFAS', 'https://dbf.finalrewind.org/{name}?rt=1&hafas=1#{tt}{tn}' ], + [ 'bahn.expert/regional', 'https://bahn.expert/regional/{name}#{id}' ], +); + +my %token_id = ( + status => 1, + history => 2, + travel => 3, + import => 4, +); +my @token_types = (qw(status history travel import)); + +sub new { + my ( $class, %opt ) = @_; + + return bless( \%opt, $class ); +} + +sub hash_password { + my ( $self, $password ) = @_; + my @salt_bytes = map { int( rand(255) ) + 1 } ( 1 .. 16 ); + my $salt = en_base64( pack( 'C[16]', @salt_bytes ) ); + + return bcrypt( substr( $password, 0, 10000 ), '$2a$12$' . $salt ); +} + +sub get_token_id { + my ( $self, $type ) = @_; + + return $token_id{$type}; +} + +sub mark_seen { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + $db->update( + 'users', + { + last_seen => DateTime->now( time_zone => 'Europe/Berlin' ), + deletion_notified => undef + }, + { id => $uid } + ); +} + +sub mark_deletion_notified { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + $db->update( + 'users', + { + deletion_notified => DateTime->now( time_zone => 'Europe/Berlin' ), + }, + { id => $uid } + ); +} + +sub verify_registration_token { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $token = $opt{token}; + my $db = $opt{db} // $self->{pg}->db; + + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + my $res = $db->select( + 'pending_registrations', + 'count(*) as count', + { + user_id => $uid, + token => $token + } + ); + + if ( $res->hash->{count} ) { + $db->update( 'users', { status => 1 }, { id => $uid } ); + $db->delete( 'pending_registrations', { user_id => $uid } ); + if ($tx) { + $tx->commit; + } + return 1; + } + return; +} + +sub get_api_token { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $token = {}; + my $res = $db->select( 'tokens', [ 'type', 'token' ], { user_id => $uid } ); + + for my $entry ( $res->hashes->each ) { + $token->{ $token_types[ $entry->{type} - 1 ] } + = $entry->{token}; + } + + return $token; +} + +sub get_uid_by_name_and_mail { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $name = $opt{name}; + my $email = $opt{email}; + + my $res = $db->select( + 'users', + ['id'], + { + name => $name, + email => $email, + status => 1 + } + ); + + if ( my $user = $res->hash ) { + return $user->{id}; + } + return; +} + +sub get_privacy_by { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + + my %where; + + if ( $opt{name} ) { + $where{name} = $opt{name}; + } + else { + $where{id} = $opt{uid}; + } + + my $res = $db->select( + 'users', + [ 'id', 'name', 'public_level', 'accept_follows' ], + { %where, status => 1 } + ); + + if ( my $user = $res->hash ) { + return { + id => $user->{id}, + name => $user->{name}, + default_visibility => $user->{public_level} & 0x7f, + default_visibility_str => + $visibility_itoa{ $user->{public_level} & 0x7f }, + comments_visible => $user->{public_level} & 0x80 ? 1 : 0, + past_visibility => ( $user->{public_level} & 0x7f00 ) >> 8, + past_visibility_str => + $visibility_itoa{ ( $user->{public_level} & 0x7f00 ) >> 8 }, + past_status => $user->{public_level} & 0x08000 ? 1 : 0, + past_all => $user->{public_level} & 0x10000 ? 1 : 0, + accept_follows => $user->{accept_follows} == 2 ? 1 : 0, + accept_follow_requests => $user->{accept_follows} == 1 ? 1 : 0, + }; + } + return; +} + +sub set_privacy { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $public_level = $opt{level}; + + if ( not defined $public_level and defined $opt{default_visibility} ) { + $public_level + = ( $opt{default_visibility} & 0x7f ) + | ( $opt{comments_visible} ? 0x80 : 0 ) + | ( ( $opt{past_visibility} & 0x7f ) << 8 ) + | ( $opt{past_status} ? 0x08000 : 0 ) + | ( $opt{past_all} ? 0x10000 : 0 ); + } + + $db->update( 'users', { public_level => $public_level }, { id => $uid } ); +} + +sub set_social { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $accept_follows = 0; + + if ( $opt{accept_follows} ) { + $accept_follows = 2; + } + elsif ( $opt{accept_follow_requests} ) { + $accept_follows = 1; + } + + $db->update( + 'users', + { accept_follows => $accept_follows }, + { id => $uid } + ); +} + +sub mark_for_password_reset { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $token = $opt{token}; + + my $res = $db->select( + 'pending_passwords', + 'count(*) as count', + { user_id => $uid } + ); + if ( $res->hash->{count} ) { + return 'in progress'; + } + + $db->insert( + 'pending_passwords', + { + user_id => $uid, + token => $token, + requested_at => DateTime->now( time_zone => 'Europe/Berlin' ) + } + ); + + return undef; +} + +sub verify_password_token { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $token = $opt{token}; + + my $res = $db->select( + 'pending_passwords', + 'count(*) as count', + { + user_id => $uid, + token => $token + } + ); + + if ( $res->hash->{count} ) { + return 1; + } + return; +} + +sub mark_for_mail_change { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $email = $opt{email}; + my $token = $opt{token}; + + $db->insert( + 'pending_mails', + { + user_id => $uid, + email => $email, + token => $token, + requested_at => DateTime->now( time_zone => 'Europe/Berlin' ) + }, + { + on_conflict => \ +'(user_id) do update set email = EXCLUDED.email, token = EXCLUDED.token, requested_at = EXCLUDED.requested_at' + }, + ); +} + +sub change_mail_with_token { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $token = $opt{token}; + + my $tx = $db->begin; + + my $res_h = $db->select( + 'pending_mails', + ['email'], + { + user_id => $uid, + token => $token + } + )->hash; + + if ($res_h) { + $db->update( 'users', { email => $res_h->{email} }, { id => $uid } ); + $db->delete( 'pending_mails', { user_id => $uid } ); + $tx->commit; + return 1; + } + return; +} + +sub is_name_invalid { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $name = $opt{name}; + + if ( not length($name) ) { + return 'user_empty'; + } + + if ( $name !~ m{ ^ [0-9a-zA-Z_-]+ $ }x ) { + return 'user_format'; + } + + if ( + $self->user_name_exists( + db => $db, + name => $name + ) + ) + { + return 'user_collision'; + } + + return; +} + +sub change_name { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + eval { $db->update( 'users', { name => $opt{name} }, { id => $uid } ); }; + + if ($@) { + return 0; + } + + return 1; +} + +sub remove_password_token { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $token = $opt{token}; + + $db->delete( + 'pending_passwords', + { + user_id => $uid, + token => $token + } + ); +} + +sub get { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $user = $db->select( + 'users', + 'id, name, status, public_level, email, ' + . 'external_services, accept_follows, notifications, ' + . 'extract(epoch from registered_at) as registered_at_ts, ' + . 'extract(epoch from last_seen) as last_seen_ts, ' + . 'extract(epoch from deletion_requested) as deletion_requested_ts', + { id => $uid } + )->hash; + if ($user) { + return { + id => $user->{id}, + name => $user->{name}, + status => $user->{status}, + notifications => $user->{notifications}, + accept_follows => $user->{accept_follows} == 2 ? 1 : 0, + accept_follow_requests => $user->{accept_follows} == 1 ? 1 : 0, + default_visibility => $user->{public_level} & 0x7f, + default_visibility_str => + $visibility_itoa{ $user->{public_level} & 0x7f }, + comments_visible => $user->{public_level} & 0x80 ? 1 : 0, + past_visibility => ( $user->{public_level} & 0x7f00 ) >> 8, + past_visibility_str => + $visibility_itoa{ ( $user->{public_level} & 0x7f00 ) >> 8 }, + past_status => $user->{public_level} & 0x08000 ? 1 : 0, + past_all => $user->{public_level} & 0x10000 ? 1 : 0, + email => $user->{email}, + sb_name => $user->{external_services} + ? $sb_templates[ $user->{external_services} & 0x07 ][0] + : undef, + sb_template => $user->{external_services} + ? $sb_templates[ $user->{external_services} & 0x07 ][1] + : undef, + registered_at => DateTime->from_epoch( + epoch => $user->{registered_at_ts}, + time_zone => 'Europe/Berlin' + ), + last_seen => DateTime->from_epoch( + epoch => $user->{last_seen_ts}, + time_zone => 'Europe/Berlin' + ), + deletion_requested => $user->{deletion_requested_ts} + ? DateTime->from_epoch( + epoch => $user->{deletion_requested_ts}, + time_zone => 'Europe/Berlin' + ) + : undef, + }; + } + return undef; +} + +sub get_login_data { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $name = $opt{name}; + + my $res_h = $db->select( + 'users', + 'id, name, status, password as password_hash', + { name => $name } + )->hash; + + return $res_h; +} + +sub add { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $user_name = $opt{name}; + my $email = $opt{email}; + my $token = $opt{token}; + my $password = $self->hash_password( $opt{password} ); + + # This helper must be called during a transaction, as user creation + # may fail even after the database entry has been generated, e.g. if + # the registration mail cannot be sent. We therefore use $db (the + # database handle performing the transaction) instead of $self->pg->db + # (which may be a new handle not belonging to the transaction). + + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + + my $res = $db->insert( + 'users', + { + name => $user_name, + status => 0, + public_level => $visibility_atoi{unlisted} + | ( $visibility_atoi{unlisted} << 8 ), + email => $email, + password => $password, + registered_at => $now, + last_seen => $now, + }, + { returning => 'id' } + ); + my $uid = $res->hash->{id}; + + $db->insert( + 'pending_registrations', + { + user_id => $uid, + token => $token + } + ); + + return $uid; +} + +sub flag_deletion { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + + $db->update( + 'users', + { deletion_requested => $now }, + { + id => $uid, + } + ); +} + +sub unflag_deletion { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + $db->update( + 'users', + { + deletion_requested => undef, + }, + { + id => $uid, + } + ); +} + +sub delete { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + my %res; + + $res{tokens} = $db->delete( 'tokens', { user_id => $uid } ); + $res{stats} = $db->delete( 'journey_stats', { user_id => $uid } ); + $res{journeys} = $db->delete( 'journeys', { user_id => $uid } ); + $res{transit} = $db->delete( 'in_transit', { user_id => $uid } ); + $res{hooks} = $db->delete( 'webhooks', { user_id => $uid } ); + $res{trwl} = $db->delete( 'traewelling', { user_id => $uid } ); + $res{password} = $db->delete( 'pending_passwords', { user_id => $uid } ); + $res{relations} = $db->delete( 'relations', + [ { subject_id => $uid }, { object_id => $uid } ] ); + $res{users} = $db->delete( 'users', { id => $uid } ); + + for my $key ( keys %res ) { + $res{$key} = $res{$key}->rows; + } + + if ( $res{users} != 1 ) { + die("Deleted $res{users} rows from users, expected 1. Rolling back.\n"); + } + + if ($tx) { + $tx->commit; + } + + return \%res; +} + +sub set_password { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $password = $self->hash_password( $opt{password} ); + + $db->update( 'users', { password => $password }, { id => $uid } ); +} + +sub user_name_exists { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $user_name = $opt{name}; + + my $count + = $db->select( 'users', 'count(*) as count', { name => $user_name } ) + ->hash->{count}; + + if ($count) { + return 1; + } + return 0; +} + +sub mail_is_blacklisted { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $mail = $opt{email}; + + my $count = $db->select( + 'users', + 'count(*) as count', + { + email => $mail, + status => 0, + } + )->hash->{count}; + + if ($count) { + return 1; + } + + $count = $db->select( + 'mail_blacklist', + 'count(*) as count', + { + email => $mail, + num_tries => { '>', 1 }, + } + )->hash->{count}; + + if ($count) { + return 1; + } + return 0; +} + +sub use_history { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $value = $opt{set}; + + if ($value) { + $db->update( 'users', { use_history => $value }, { id => $uid } ); + } + else { + return $db->select( 'users', ['use_history'], { id => $uid } ) + ->hash->{use_history}; + } +} + +sub use_external_services { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $value = $opt{set}; + + if ( defined $value ) { + if ( $value < 0 or $value > 4 ) { + $value = 0; + } + $db->update( 'users', { external_services => $value }, { id => $uid } ); + } + else { + return $db->select( 'users', ['external_services'], { id => $uid } ) + ->hash->{external_services}; + } +} + +sub get_webhook { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $res_h = $db->select( 'webhooks_str', '*', { user_id => $uid } )->hash; + + $res_h->{latest_run} = DateTime->from_epoch( + epoch => $res_h->{latest_run_ts} // 0, + time_zone => 'Europe/Berlin', + locale => 'de-DE', + ); + + return $res_h; +} + +sub set_webhook { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + + if ( $opt{token} ) { + $opt{token} =~ tr{\r\n}{}d; + } + + my $res = $db->insert( + 'webhooks', + { + user_id => $opt{uid}, + enabled => $opt{enabled}, + url => $opt{url}, + token => $opt{token} + }, + { + on_conflict => \ +'(user_id) do update set enabled = EXCLUDED.enabled, url = EXCLUDED.url, token = EXCLUDED.token, errored = null, latest_run = null, output = null' + } + ); +} + +sub update_webhook_status { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $url = $opt{url}; + my $success = $opt{success}; + my $text = $opt{text}; + + if ( length($text) > 1000 ) { + $text = substr( $text, 0, 1000 ) . '…'; + } + + $db->update( + 'webhooks', + { + errored => $success ? 0 : 1, + latest_run => DateTime->now( time_zone => 'Europe/Berlin' ), + output => $text, + }, + { + user_id => $uid, + url => $url + } + ); +} + +sub set_profile { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $profile = $opt{profile}; + + $db->update( + 'users', + { profile => JSON->new->encode($profile) }, + { id => $uid } + ); +} + +sub get_profile { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + return $db->select( 'users', ['profile'], { id => $uid } ) + ->expand->hash->{profile}; +} + +sub get_relation { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $subject = $opt{subject}; + my $object = $opt{object}; + + my $res_h = $db->select( + 'relations', + ['predicate'], + { + subject_id => $subject, + object_id => $object, + } + )->hash; + + if ($res_h) { + return $predicate_itoa{ $res_h->{predicate} }; + } + return; + + #my $res_h = $db->select( 'relations', ['subject_id', 'predicate'], + # { subject_id => [$uid, $target], object_id => [$target, $target] } )->hash; +} + +sub update_notifications { + my ( $self, %opt ) = @_; + + # must be called inside a transaction, so $opt{db} is mandatory. + my $db = $opt{db}; + my $uid = $opt{uid}; + + my $has_follow_requests = $opt{has_follow_requests} + // $self->has_follow_requests( + db => $db, + uid => $uid + ); + + my $notifications + = $db->select( 'users', ['notifications'], { id => $uid } ) + ->hash->{notifications}; + if ($has_follow_requests) { + $notifications |= 0x01; + } + else { + $notifications &= ~0x01; + } + $db->update( 'users', { notifications => $notifications }, { id => $uid } ); +} + +sub follow { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $target = $opt{target}; + + $db->insert( + 'relations', + { + subject_id => $uid, + predicate => $predicate_atoi{follows}, + object_id => $target, + ts => DateTime->now( time_zone => 'Europe/Berlin' ), + } + ); +} + +sub request_follow { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $target = $opt{target}; + + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + $db->insert( + 'relations', + { + subject_id => $uid, + predicate => $predicate_atoi{requests_follow}, + object_id => $target, + ts => DateTime->now( time_zone => 'Europe/Berlin' ), + } + ); + $self->update_notifications( + db => $db, + uid => $target, + has_follow_requests => 1, + ); + + if ($tx) { + $tx->commit; + } +} + +sub accept_follow_request { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $applicant = $opt{applicant}; + + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + $db->update( + 'relations', + { + predicate => $predicate_atoi{follows}, + ts => DateTime->now( time_zone => 'Europe/Berlin' ), + }, + { + subject_id => $applicant, + predicate => $predicate_atoi{requests_follow}, + object_id => $uid + } + ); + $self->update_notifications( + db => $db, + uid => $uid + ); + + if ($tx) { + $tx->commit; + } +} + +sub reject_follow_request { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $applicant = $opt{applicant}; + + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + $db->delete( + 'relations', + { + subject_id => $applicant, + predicate => $predicate_atoi{requests_follow}, + object_id => $uid + } + ); + $self->update_notifications( + db => $db, + uid => $uid + ); + + if ($tx) { + $tx->commit; + } +} + +sub cancel_follow_request { + my ( $self, %opt ) = @_; + + $self->reject_follow_request( + db => $opt{db}, + uid => $opt{target}, + applicant => $opt{uid}, + ); +} + +sub unfollow { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $target = $opt{target}; + + $db->delete( + 'relations', + { + subject_id => $uid, + predicate => $predicate_atoi{follows}, + object_id => $target + } + ); +} + +sub remove_follower { + my ( $self, %opt ) = @_; + + $self->unfollow( + db => $opt{db}, + uid => $opt{follower}, + target => $opt{uid}, + ); +} + +sub block { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $target = $opt{target}; + + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + $db->insert( + 'relations', + { + subject_id => $target, + predicate => $predicate_atoi{is_blocked_by}, + object_id => $uid, + ts => DateTime->now( time_zone => 'Europe/Berlin' ), + }, + { + on_conflict => \ +'(subject_id, object_id) do update set predicate = EXCLUDED.predicate' + }, + ); + $self->update_notifications( + db => $db, + uid => $uid + ); + + if ($tx) { + $tx->commit; + } +} + +sub unblock { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $target = $opt{target}; + + $db->delete( + 'relations', + { + subject_id => $target, + predicate => $predicate_atoi{is_blocked_by}, + object_id => $uid + }, + ); +} + +sub get_followers { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $res = $db->select( + 'followers', + [ 'id', 'name', 'accept_follows', 'inverse_predicate' ], + { self_id => $uid } + ); + + my @ret; + while ( my $row = $res->hash ) { + push( + @ret, + { + id => $row->{id}, + name => $row->{name}, + following_back => ( + $row->{inverse_predicate} + and $row->{inverse_predicate} == $predicate_atoi{follows} + ) ? 1 : 0, + followback_requested => ( + $row->{inverse_predicate} + and $row->{inverse_predicate} + == $predicate_atoi{requests_follow} + ) ? 1 : 0, + can_follow_back => ( + not $row->{inverse_predicate} + and $row->{accept_follows} == 2 + ) ? 1 : 0, + can_request_follow_back => ( + not $row->{inverse_predicate} + and $row->{accept_follows} == 1 + ) ? 1 : 0, + } + ); + } + return @ret; +} + +sub has_followers { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + return $db->select( 'followers', 'count(*) as count', { self_id => $uid } ) + ->hash->{count}; +} + +sub get_follow_requests { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $table = $opt{sent} ? 'tx_follow_requests' : 'rx_follow_requests'; + + my $res + = $db->select( $table, [ 'id', 'name' ], { self_id => $uid } ); + + return $res->hashes->each; +} + +sub has_follow_requests { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $table = $opt{sent} ? 'tx_follow_requests' : 'rx_follow_requests'; + + return $db->select( $table, 'count(*) as count', { self_id => $uid } ) + ->hash->{count}; +} + +sub get_followees { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $res = $db->select( + 'followees', + [ 'id', 'name', 'inverse_predicate' ], + { self_id => $uid } + ); + + my @ret; + while ( my $row = $res->hash ) { + push( + @ret, + { + id => $row->{id}, + name => $row->{name}, + following_back => ( + $row->{inverse_predicate} + and $row->{inverse_predicate} == $predicate_atoi{follows} + ) ? 1 : 0, + } + ); + } + return @ret; +} + +sub has_followees { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + return $db->select( 'followees', 'count(*) as count', { self_id => $uid } ) + ->hash->{count}; +} + +sub get_blocked_users { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $res + = $db->select( 'blocked_users', [ 'id', 'name' ], { self_id => $uid } ); + + return $res->hashes->each; +} + +sub has_blocked_users { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + return $db->select( 'blocked_users', 'count(*) as count', + { self_id => $uid } )->hash->{count}; +} + +1; |