travelynx/lib/Travelynx/Controller/Api.pm
Birte Kristina Friesel 47f76da4f8
Multi-backend support
Squashed commit of the following:

commit 92518024ba
Author: Birte Kristina Friesel <birte.friesel@uos.de>
Date:   Fri Jul 26 18:39:46 2024 +0200

    add_or_update station: remove superfluos 'new backend id := old backend id'

commit df21c20c6e
Author: Birte Kristina Friesel <birte.friesel@uos.de>
Date:   Fri Jul 26 18:35:51 2024 +0200

    revert connection targets min_count to 3

commit be335cef07
Author: Birte Kristina Friesel <birte.friesel@uos.de>
Date:   Fri Jul 26 18:20:05 2024 +0200

    mention backend selection in API documentation

commit 9f41828fb4
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Thu Jul 25 20:19:23 2024 +0200

    use_history: not all backends provide route data in departure monitor

commit 09714b4d89
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Thu Jul 25 20:11:44 2024 +0200

    disambiguation: pass correct hafas parameter

commit 8cdf1120fc
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Thu Jul 25 20:11:28 2024 +0200

    _checked_in: hide Zuglauf link for non-db checkins

commit 7455653f54
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Thu Jul 25 20:01:47 2024 +0200

    debug output

commit b9cda07f85
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Thu Jul 25 19:09:07 2024 +0200

    fix remaining get_connection_targets / get_connecting_trains_p invocations

commit 2759d7258c
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Wed Jul 24 20:50:12 2024 +0200

    support non-DB HAFAS backends (WiP)
2024-07-26 18:55:58 +02:00

682 lines
14 KiB
Perl
Executable file

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 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) = @_;
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_v1 {
my ($self) = @_;
my $api_action = $self->stash('user_action');
my $api_token = $self->stash('token');
if ( $api_action !~ qr{ ^ (?: status | history | action ) $ }x ) {
$self->render(
json => {
error => 'Invalid action',
},
);
return;
}
if ( $api_token !~ qr{ ^ (?<id> \d+ ) - (?<token> .* ) $ }x ) {
$self->render(
json => {
error => 'Malformed token',
},
);
return;
}
my $uid = $+{id};
$api_token = $+{token};
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',
},
);
return;
}
if ( $api_action eq 'status' ) {
$self->render( json => $self->get_user_status_json_v1( uid => $uid ) );
}
else {
$self->render(
json => {
error => 'not implemented',
},
);
}
}
sub travel_v1 {
my ($self) = @_;
my $payload = $self->req->json;
if ( not $payload or ref($payload) ne 'HASH' ) {
$self->render(
json => {
success => \0,
deprecated => \0,
error => 'Malformed JSON',
},
status => 400,
);
return;
}
my $api_token = $payload->{token} // '';
if ( $api_token !~ qr{ ^ (?<id> \d+ ) - (?<token> .* ) $ }x ) {
$self->render(
json => {
success => \0,
deprecated => \0,
error => 'Malformed token',
},
status => 400,
);
return;
}
my $uid = $+{id};
$api_token = $+{token};
if ( $uid > 2147483647 ) {
$self->render(
json => {
success => \0,
deprecated => \0,
error => 'Malformed token',
},
status => 400,
);
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',
},
status => 400,
);
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 )
},
status => 400,
);
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 = sanitize(undef, $payload->{hafas});
$hafas //= exists $payload->{train}{journeyID} ? 'DB' : undef;
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 )
},
status => 400,
);
return;
}
if ( not $hafas and not $self->stations->search($from_station, iris => 1) ) {
$self->render(
json => {
success => \0,
deprecated => \0,
error => 'Unknown fromStation',
status => $self->get_user_status_json_v1( uid => $uid )
},
status => 400,
);
return;
}
if ( $to_station
and not $hafas
and not $self->stations->search($to_station, iris => 1) )
{
$self->render(
json => {
success => \0,
deprecated => \0,
error => 'Unknown toStation',
status => $self->get_user_status_json_v1( uid => $uid )
},
status => 400,
);
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,
hafas => $hafas,
);
}
)->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 import_v1 {
my ($self) = @_;
my $payload = $self->req->json;
if ( not $payload or ref($payload) ne 'HASH' ) {
$self->render(
json => {
success => \0,
deprecated => \0,
error => 'Malformed JSON',
},
);
return;
}
my $api_token = $payload->{token} // '';
if ( $api_token !~ qr{ ^ (?<id> \d+ ) - (?<token> .* ) $ }x ) {
$self->render(
json => {
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->{'import'} or $api_token ne $token->{'import'} ) {
$self->render(
json => {
success => \0,
deprecated => \0,
error => 'Invalid token',
},
);
return;
}
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 => {
success => \1,
deprecated => \0,
id => $journey_id,
result => $journey
}
);
}
}
sub set_token {
my ($self) = @_;
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
'bad_request',
csrf => 1,
status => 400
);
return;
}
my $token = make_token();
my $token_id = $self->users->get_token_id( $self->param('token') );
if ( not $token_id ) {
$self->redirect_to('account');
return;
}
if ( $self->param('action') eq 'delete' ) {
$self->pg->db->delete(
'tokens',
{
user_id => $self->current_user->{id},
type => $token_id
}
);
}
else {
$self->pg->db->insert(
'tokens',
{
user_id => $self->current_user->{id},
type => $token_id,
token => $token
},
{
on_conflict => \
'(user_id, type) do update set token = EXCLUDED.token'
},
);
}
$self->redirect_to('account');
}
sub autocomplete {
my ($self) = @_;
$self->res->headers->cache_control('max-age=86400, immutable');
my $backend_id = $self->param('backend_id') // 1;
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( backend_id => $backend_id ) );
$output .= "\n});});\n";
$self->render(
format => 'js',
data => $output
);
}
1;