diff options
-rwxr-xr-x | lib/Travelynx.pm | 113 | ||||
-rw-r--r-- | lib/Travelynx/Command/database.pm | 25 | ||||
-rw-r--r-- | lib/Travelynx/Controller/Account.pm | 25 | ||||
-rw-r--r-- | templates/webhooks.html.ep | 62 |
4 files changed, 225 insertions, 0 deletions
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm index 48fb22b..f058eee 100755 --- a/lib/Travelynx.pm +++ b/lib/Travelynx.pm @@ -347,6 +347,7 @@ sub startup { "Checkin($uid): INSERT failed: $@"); return ( undef, 'INSERT failed: ' . $@ ); } + $self->run_hook( $self->current_user->{id}, 'checkin' ); return ( $train, undef ); } } @@ -366,6 +367,7 @@ sub startup { $self->app->log->error("Undo($uid, $journey_id): $@"); return "Undo($journey_id): $@"; } + $self->run_hook( $uid, 'undo' ); return undef; } if ( $journey_id !~ m{ ^ \d+ $ }x ) { @@ -421,6 +423,7 @@ sub startup { $self->app->log->error("Undo($uid, $journey_id): $@"); return "Undo($journey_id): $@"; } + $self->run_hook( $uid, 'undo' ); return undef; } ); @@ -572,6 +575,7 @@ sub startup { if ( $has_arrived or $force ) { return ( 0, undef ); + $self->run_hook( $uid, 'checkout' ); } return ( 1, undef ); } @@ -985,6 +989,113 @@ sub startup { ); $self->helper( + 'get_webhook' => sub { + my ( $self, $uid ) = @_; + $uid //= $self->current_user->{id}; + + my $res_h + = $self->pg->db->select( 'webhooks_str', '*', + { user_id => $uid } )->hash; + + $res_h->{latest_run} = epoch_to_dt( $res_h->{latest_run_ts} ); + + return $res_h; + } + ); + + $self->helper( + 'set_webhook' => sub { + my ( $self, %opt ) = @_; + + $opt{uid} //= $self->current_user->{id}; + + my $res = $self->pg->db->insert( + 'webhooks', + { + user_id => $opt{uid}, + enabled => $opt{enabled}, + url => $opt{url}, + token => $opt{token} + }, + { + on_conflict => \ +'(user_id) do update set enabled = EXCLUDED.enabled, url = EXCLUDED.url, token = EXCLUDED.token, errored = null, latest_run = null, output = null' + } + ); + } + ); + + $self->helper( + 'mark_hook_status' => sub { + my ( $self, $uid, $url, $success, $text ) = @_; + + if ( length($text) > 1024 ) { + $text = "(output too long)"; + } + + $self->pg->db->update( + 'webhooks', + { + errored => !$success, + latest_run => DateTime->now( time_zone => 'Europe/Berlin' ), + output => $text, + }, + { + user_id => $uid, + url => $url + } + ); + } + ); + + $self->helper( + 'run_hook' => sub { + my ( $self, $uid, $reason ) = @_; + + my $hook = $self->get_webhook($uid); + + if ( not $hook->{enabled} or not $hook->{url} =~ m{^ https?:// }x ) + { + return; + } + + my $status = { todo => 1 }; + my $header = {}; + my $hook_body = { + reason => $reason, + status => $status, + }; + + if ( $hook->{token} ) { + $hook->{token} =~ tr{\r\n}{}d; + $header->{Authorization} = "Bearer $hook->{token}"; + } + + my $ua = $self->ua; + $ua->request_timeout(10); + + $ua->post_p( $hook->{url} => $header => json => $hook_body )->then( + sub { + my ($tx) = @_; + if ( my $err = $tx->error ) { + $self->mark_hook_status( $uid, $hook->{url}, 0, + "HTTP $err->{code} $err->{message}" ); + } + else { + $self->mark_hook_status( $uid, $hook->{url}, 1, + $tx->result->body ); + } + } + )->catch( + sub { + my ($err) = @_; + $self->mark_hook_status( $uid, $hook->{url}, 0, $err ); + } + )->wait; + } + ); + + $self->helper( 'get_user_password' => sub { my ( $self, $name ) = @_; @@ -1753,6 +1864,7 @@ sub startup { $authed_r->get('/account')->to('account#account'); $authed_r->get('/account/privacy')->to('account#privacy'); + $authed_r->get('/account/hooks')->to('account#webhook'); $authed_r->get('/ajax/status_card.html')->to('traveling#status_card'); $authed_r->get('/cancelled')->to('traveling#cancelled'); $authed_r->get('/account/password')->to('account#password_form'); @@ -1767,6 +1879,7 @@ sub startup { $authed_r->get('/s/*station')->to('traveling#station'); $authed_r->get('/confirm_mail/:token')->to('account#confirm_mail'); $authed_r->post('/account/privacy')->to('account#privacy'); + $authed_r->post('/account/hooks')->to('account#webhook'); $authed_r->post('/journey/add')->to('traveling#add_journey_form'); $authed_r->post('/journey/edit')->to('traveling#edit_journey'); $authed_r->post('/account/password')->to('account#change_password'); diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm index 79ff086..11a946e 100644 --- a/lib/Travelynx/Command/database.pm +++ b/lib/Travelynx/Command/database.pm @@ -456,6 +456,31 @@ my @migrations = ( } ); }, + + # v10 -> v11 + sub { + my ($db) = @_; + $db->query( + qq{ + create table webhooks ( + user_id integer not null references users (id) primary key, + enabled boolean not null, + url varchar(1000) not null, + token varchar(250), + errored boolean, + latest_run timestamptz, + output text + ); + comment on table webhooks is 'URLs and bearer tokens for push events'; + create view webhooks_str as select + user_id, enabled, url, token, errored, output, + extract(epoch from latest_run) as latest_run_ts + from webhooks + ; + update schema_version set version = 11; + } + ); + }, ); sub setup_db { diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm index 8d5b21f..75b8f02 100644 --- a/lib/Travelynx/Controller/Account.pm +++ b/lib/Travelynx/Controller/Account.pm @@ -230,6 +230,31 @@ sub privacy { } } +sub webhook { + my ($self) = @_; + + my $hook = $self->get_webhook; + + 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( + url => $hook->{url}, + token => $hook->{token}, + enabled => $hook->{enabled} + ); + $hook = $self->get_webhook; + } + else { + $self->param( url => $hook->{url} ); + $self->param( token => $hook->{token} ); + $self->param( enabled => $hook->{enabled} ); + } + + $self->render( 'webhooks', hook => $hook ); +} + sub change_mail { my ($self) = @_; diff --git a/templates/webhooks.html.ep b/templates/webhooks.html.ep new file mode 100644 index 0000000..fec485d --- /dev/null +++ b/templates/webhooks.html.ep @@ -0,0 +1,62 @@ +% if (my $invalid = stash('invalid')) { + %= include '_invalid_input', invalid => $invalid +% } + +<h1>Web Hooks</h1> + +<!-- -H "Authorization: Bearer ${TOKEN}" --> +<div class="row"> + <div class="col s12"> + <p> + Die im Web Hook konfigurierte URL wird bei jedem Checkin und Checkout + des ausgewählten Zuges aufgerufen. Falls ein Token eingetragen + ist, wird er als Bearer Token verwendet. + </p> + <p> + Events werden als JSON POST übertragen. Das JSON-Dokument besteht aus + zwei Feldern: „reason“ gibt den Grund des API-Aufrufs an (checkin, + checkout, undo), „status“ den <a href="/api">aktuellen Status</a>. + </p> + </div> + %= form_for '/account/hooks' => (method => 'POST') => begin + %= csrf_field + <div class="col s12 center-align"> + <label> + %= check_box enabled => 1 + <span>Aktiv</span> + </label> + </div> + <div class="input-field col s12"> + <i class="material-icons prefix">link</i> + %= text_field 'url', id => 'url', class => 'validate', maxlength => 1000 + <label for="url">URL</label> + </div> + <div class="input-field col s12"> + <i class="material-icons prefix">lock</i> + %= text_field 'token', id => 'token', class => 'validate', maxlength => 250 + <label for="token">Token</label> + </div> + <div class="col s12"> + % if ($hook->{latest_run}->epoch) { + Zuletzt ausgeführt: <%= $hook->{latest_run} %><br/> + % if ($hook->{errored}) { + <i class="material-icons left">error</i> + Status: <%= $hook->{output} %> + % } + % else { + <i class="material-icons left">check</i> + Server-Antwort: <%= $hook->{output} %> + % } + % } + % else { + Noch nicht ausgeführt. + % } + </div> + <div class="col s12 center-align"> + <button class="btn waves-effect waves-light" type="submit" name="action" value="save"> + Speichern + <i class="material-icons right">send</i> + </button> + </div> + %= end +</div> |