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> | 
