Permalink
Find file
Fetching contributors…
Cannot retrieve contributors at this time
executable file 467 lines (382 sloc) 12.3 KB
#!/usr/bin/perl
#
# Spoor web service
#
# - accept posts via an api (json messages via HTTP POSTs)
# - simple interfaces for post listing and display (html and atom)
#
use Mojolicious::Lite;
use File::Basename;
use Getopt::Long qw(:config no_ignore_case bundling);
use Encode 2.43;
use XML::Atom;
use Carp;
plugin 'basic_auth';
use FindBin qw($Bin);
use lib "$Bin/lib";
use lib 'lib';
use Spoor::Config;
use Spoor::Util qw(get_schema ts2iso8601 ts2iso8601_offset);
use XML::Atom::Ext::GeoRSS;
sub usage {
warn @_ if @_;
die "usage: " . basename($0) . " [-v] [--config <config_file>]\n";
}
my $verbose = 0;
my ($help, $config_file);
usage unless GetOptions(
'help|h|?' => \$help,
'verbose|v+' => \$verbose,
'config|c=s' => \$config_file,
);
usage if $help;
# -------------------------------------------------------------------------
# Config
#
my %config_file = $config_file ? ( config_file => $config_file ) : ();
my $config = Spoor::Config->new(log => app->log, %config_file);
my $spoor_url = $config->get('url')
or die "Missing 'url' setting in spoor.conf\n";
my $secret = $config->get('secret')
or die "Missing 'secret' setting in spoor.conf\n";
app->secret($secret);
my $title = $config->get('title') || 'Spoor microblog';
my $schema = get_schema($config->get('environment'));
# -------------------------------------------------------------------------
# Collective GET routes
#
# GET /
helper render_html => sub {
my ($self) = @_;
my $publish_delay = ($config->get('publish_delay') || 180) + 30;
$self->stash(publish_delay => $publish_delay);
$self->stash(js_list => [ qw(post.js) ]);
$self->stash(ts_cutoff_epoch => time - $publish_delay);
$self->stash(title => $title);
$self->stash(config => $config);
$self->render('index');
};
helper render_atom => sub {
my ($self, $rs, $path) = @_;
$XML::Atom::DefaultVersion = "1.0";
my $author = XML::Atom::Person->new;
$author->name($config->get('author_name') || 'Some Author');
$author->email($config->get('author_email')) if $config->get('author_email');
$author->uri($config->get('author_url')) if $config->get('author_url');
my $feed = XML::Atom::Feed->new;
$feed->title($title);
$feed->id($spoor_url . $path);
my $link = XML::Atom::Link->new;
$link->rel('self');
$link->href($feed->id);
$feed->add_link($link);
if (my $hub = $config->get('pshb_hub')) {
my $pshb = XML::Atom::Link->new;
$pshb->rel('hub');
$pshb->href($hub);
$feed->add_link($pshb);
}
$feed->author($author);
my @entries;
my $max_updated = '';
while (my $post = $rs->next) {
# If we find a post that is paused, reset the feed and start again from the next post
if (! $post->forward_flag) {
@entries = ();
$max_updated = '';
next;
}
my $id = $post->id;
my $entry = XML::Atom::Entry->new;
$entry->id("$spoor_url/post/$id");
$entry->title($post->post_processed);
$entry->content($post->post_processed);
# forward_flag == 1 is a normal forward after publish_delay seconds
if ($post->forward_flag == 1) {
$entry->published(ts2iso8601_offset($post->timestamp, $config->get('publish_delay') || 180));
}
# forward_flag == 2 means publish immediately, ignoring publish_delay
elsif ($post->forward_flag == 2) {
$entry->published(ts2iso8601($post->timestamp));
}
$entry->updated($entry->published);
$max_updated = $entry->updated if $entry->updated gt $max_updated;
if (defined $post->latitude && defined $post->longitude) {
$entry->point(join(' ', $post->latitude, $post->longitude));
}
push @entries, $entry;
}
$feed->updated($max_updated) if $max_updated;
# Add entries
$feed->add_entry($_) for @entries;
# Note XML::Atom encodes as utf-8, so render => data avoids double-encoding here
$self->render(data => $feed->as_xml, format => 'atom');
};
helper render_rs => sub {
my ($self, $rs, $path) = @_;
croak 'Missing required argument $rs' unless $rs;
croak 'Missing required argument $path' unless $path;
my $format = $self->stash('format') || '';
if ($format eq 'atom') {
$self->render_atom($rs, "$path.atom");
}
else {
$self->stash(rs => $rs);
$self->render_html(template => 'index');
}
};
get '/' => sub {
my $self = shift;
my $rs = $schema->resultset('Post')->search_for_index($config, app->log, $self->session('user'))
or return;
$self->render_rs($rs, '/index');
} => 'index';
get '/index' => sub {
my $self = shift;
my $rs = $schema->resultset('Post')->search_for_index($config, app->log, $self->session('user'))
or return;
$self->render_rs($rs, '/index');
};
get '/tag/:tag' => sub {
my $self = shift;
my $tag = $self->param('tag');
# Private tags are only viewable by user
if (! $self->session('user') && $config->is_private_tag($tag)) {
return $self->redirect_to('index');
}
my $rs = $schema->resultset('Post')->search_by_tag($tag, '#', $config)
or return;
$self->render_rs($rs, "/tag/$tag");
};
get '/gtag/:tag' => sub {
my $self = shift;
my $tag = $self->param('tag');
my $rs = $schema->resultset('Post')->search_by_tag($tag, '!', $config)
or return;
$self->render_rs($rs, "/gtag/$tag");
};
get '/user/:user' => sub {
my $self = shift;
my $user = $self->param('user');
my $rs = $schema->resultset('Post')->search_by_tag($user, '@', $config)
or return;
$self->render_rs($rs, "/user/$user");
};
# -------------------------------------------------------------------------
# Individual post GET routes
#
# GET /post/<id>
# GET /post/<id>.json
#
helper render_json_post => sub {
my ($self, $id) = @_;
my $rs = $schema->resultset('Post');
$rs->result_class('DBIx::Class::ResultClass::HashRefInflator');
my $post = $rs->find($id)
or return $self->render(status => 403,
json => { error => "Invalid post '$id'" });
delete $post->{post_processed};
$post->{post} = delete $post->{post_raw};
$self->render_json($post);
};
get '/post/:id' => [format => [ 'json' ]] => sub {
my $self = shift;
$self->render_json_post($self->param('id'));
};
get '/post/:id' => sub {
my $self = shift;
my $id = $self->param('id');
my $post = $schema->resultset('Post')->find($id)
or $self->render('notfound');
$self->stash(title => $title);
$self->stash(post => $post);
$self->stash(js_list => undef);
$self->stash(config => $config);
} => 'display';
# -------------------------------------------------------------------------
# Authentication
helper 'login' => sub {
my $self = shift;
my $log = app->log;
my $conf_username = $config->get('username');
my $conf_password = $config->get('password');
unless (defined $conf_username && defined $conf_password) {
$log->warn("Config 'username' parameter not defined") if ! defined $conf_username;
$log->warn("Config 'password' parameter not defined") if ! defined $conf_password;
return 0;
}
return 1 if $self->basic_auth(
Spoor => sub {
my ($user, $pass) = @_;
unless (defined $user && defined $pass) {
$log->warn("No 'user' parameter passed to basic_auth checking") if ! defined $user;
$log->warn("No 'pass' parameter passed to basic_auth checking") if ! defined $pass;
return 0;
}
$log->debug("Checking user '$user', pass '$pass' ...");
if ($user eq $conf_username && $pass eq $conf_password) {
$log->debug("User/pass found and good, setting session for $user");
$self->session(user => $user);
return 1;
}
},
);
return 0;
};
get '/login' => sub {
my $self = shift;
# Upgrade to https if http
return $self->redirect_to($self->req->url->to_abs->clone->scheme('https'))
if $config->get('secure') &&
($self->req->headers->header('X-Forwarded-Protocol') eq 'http' ||
$self->req->url->to_abs->scheme ne 'https');
# Check login credentials
if ($self->login) {
$self->redirect_to('index');
}
# Bad login
else {
$self->stash(title => 'Login unsuccessful');
$self->stash(js_list => undef);
$self->stash(config => $config);
$self->render(template => 'badlogin');
}
} => 'login';
get '/logout' => sub {
my $self = shift;
$self->session(expires => 1);
$self->redirect_to('index');
};
# Everything below here requires authentication
under sub {
my $self = shift;
return $self->login;
};
# -------------------------------------------------------------------------
# POST and DELETE routes
#
# POST / - post message
# DELETE /post/<id> - delete
# POST /post/<id> - partial updates (yes I know, not entirely restful ...)
# post => 'updated post'
# forward => 0/1/2
# delete => 0
#
# Top-level api route, processing json POST posts
helper api_post => sub {
my $self = shift;
my $content_type = $self->req->headers->content_type;
# TODO: why aren't json bodies automatically deserialised into params?
my ($text, $latitude, $longitude);
if ($content_type =~ /\bjson$/) {
my $params = $self->req->json
or return $self->render(status => 403,
json => { error => "Not a json request?" });
$text = $params->{post} || $params->{status}
or return $self->render(status => 403,
json => { error => "Missing 'post' or 'status' parameter?" });
if ($params->{post_geo}) {
$latitude = $params->{latitude};
$longitude = $params->{longitude};
}
}
else {
$text = $self->param('post') || $self->param('status')
or return $self->render(status => 403,
text => "Missing 'post' or 'status parameter?");
if ($self->param('post_geo')) {
$latitude = $self->param('latitude');
$longitude = $self->param('longitude');
}
}
my $post_content = decode_utf8($text);
my $post = $schema->resultset('Post')->create({
post_raw => $post_content,
latitude => $latitude,
longitude => $longitude,
}) or return $self->render(status => 500,
json => { error => "Creating post failed" });
my $post_url = "${spoor_url}/post/" . $post->id;
if ($content_type =~ /\bjson$/) {
$self->render_json({ status => {
id => $post->id,
text => $post->post_raw,
url => $post_url,
}});
}
else {
$self->redirect_to($config->get('url'));
}
};
# Top-level post
#post '/index.json' => sub {
post '/' => sub {
my $self = shift;
return $self->api_post;
};
# Twitter-compatible api post route
post '/statuses/update' => sub {
my $self = shift;
return $self->api_post;
};
# Update post (partial, not pull PUT)
post '/post/:id' => sub {
my $self = shift;
my $id = $self->param('id');
my $post = $schema->resultset('Post')->find($id)
or return $self->render(status => 403,
json => { error => "Invalid post '$id'" });
my %param_map = (
post => 'post_raw',
forward => 'forward_flag',
delete => 'delete_b',
);
my %update;
for my $p (keys %param_map) {
my $val = $self->param($p);
if (defined $val && $val ne '') {
$update{ $param_map{$p} } = decode_utf8( $val );
}
}
if (keys %update) {
$post->update( \%update )
or return $self->render(status => 500,
json => { error => "Update failed: $!" });
$self->render_json_post($post->id);
}
else {
return $self->render(status => 403,
json => { error => "No valid parameters found [post|forward|delete]" });
}
};
del '/post/:id' => sub {
my $self = shift;
my $id = $self->param('id');
my $post = $schema->resultset('Post')->find($id)
or $self->render('notfound');
$post->update({ delete_b => 1 });
$self->render(status => 204, json => { msg => 'Deleted' });
};
# -------------------------------------------------------------------------
# Session routes
#
# POST /session - session updates
#
# Supported parameters: geo => 1 | 0
#
post '/session' => sub {
my $self = shift;
my $geo = $self->param('geo');
$self->session->{geo} = $geo if defined $geo;
$self->render(status => 200, json => { msg => 'OK' });
};
# -------------------------------------------------------------------------
# Bump up default session expiration time to 3h
app->sessions->default_expiration(3 * 3600)->secure($config->get('secure'));
# Tweak base url when reverse proxy with https
app->hook(before_dispatch => sub {
my $self = shift;
$self->req->url->base->scheme('https')
if $self->req->headers->header('X-Forwarded-Protocol')||'' eq 'https';
});
app->start;