diff options
Diffstat (limited to 'lib/Travelynx/Controller')
-rw-r--r-- | lib/Travelynx/Controller/Account.pm | 1095 | ||||
-rwxr-xr-x | lib/Travelynx/Controller/Api.pm | 591 | ||||
-rw-r--r-- | lib/Travelynx/Controller/Passengerrights.pm | 30 | ||||
-rwxr-xr-x | lib/Travelynx/Controller/Profile.pm | 603 | ||||
-rw-r--r-- | lib/Travelynx/Controller/Static.pm | 20 | ||||
-rw-r--r-- | lib/Travelynx/Controller/Traewelling.pm | 154 | ||||
-rwxr-xr-x | lib/Travelynx/Controller/Traveling.pm | 1998 |
7 files changed, 3870 insertions, 621 deletions
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) { |