summaryrefslogtreecommitdiff
path: root/lib/Travelynx/Controller
diff options
context:
space:
mode:
Diffstat (limited to 'lib/Travelynx/Controller')
-rw-r--r--lib/Travelynx/Controller/Account.pm1095
-rwxr-xr-xlib/Travelynx/Controller/Api.pm591
-rw-r--r--lib/Travelynx/Controller/Passengerrights.pm30
-rwxr-xr-xlib/Travelynx/Controller/Profile.pm603
-rw-r--r--lib/Travelynx/Controller/Static.pm20
-rw-r--r--lib/Travelynx/Controller/Traewelling.pm154
-rwxr-xr-xlib/Travelynx/Controller/Traveling.pm1998
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) {