File Coverage

blib/lib/Story/Interact/WWW.pm
Criterion Covered Total %
statement 32 240 13.3
branch 0 48 0.0
condition 0 26 0.0
subroutine 11 26 42.3
pod 1 1 100.0
total 44 341 12.9


line stmt bran cond sub pod time code
1 2     2   400361 use 5.024000;
  2         8  
2 2     2   13 use strict;
  2         3  
  2         67  
3 2     2   10 use warnings;
  2         3  
  2         204  
4              
5             package Story::Interact::WWW;
6              
7             our $AUTHORITY = 'cpan:TOBYINK';
8             our $VERSION = '0.002000';
9              
10 2     2   11 use constant DISTRIBUTION => 'Story-Interact-WWW';
  2         6  
  2         152  
11              
12 2     2   1144 use Digest::SHA qw( sha256_hex );
  2         8798  
  2         200  
13 2     2   5437 use Mojo::ShareDir;
  2         674916  
  2         166  
14 2     2   20 use Mojo::Base 'Mojolicious', -signatures;
  2         5  
  2         17  
15 2     2   998677 use Mojo::Util qw( xml_escape );
  2         7  
  2         87  
16 2     2   1131 use Nanoid ();
  2         17960  
  2         59  
17 2     2   846 use Story::Interact::State ();
  2         928638  
  2         94  
18 2     2   1425 use Text::Markdown::Hoedown;
  2         3656  
  2         9784  
19              
20 0     0 1   sub startup ( $self ) {
  0            
  0            
21              
22 0           $self->log->info( 'Story::Interact::State->VERSION = ' . Story::Interact::State->VERSION );
23              
24 0           $self->secrets( [ __PACKAGE__ . '/' . $VERSION ] );
25              
26             # Setup app config, paths, etc.
27 0           $self->plugin( 'Config', { file => 'si_www.conf' } );
28 0           unshift(
29             $self->static->paths->@*,
30             $self->home->rel_file( 'local/public' ),
31             Mojo::ShareDir->new( DISTRIBUTION, 'public' ),
32             );
33 0           unshift(
34             $self->renderer->paths->@*,
35             $self->home->rel_file( 'local/templates' ),
36             Mojo::ShareDir->new( DISTRIBUTION, 'templates' ),
37             );
38              
39 0     0     my $get_db = sub ( $self ) {
  0            
  0            
40 0           state $dsn = $ENV{SIWWW_DB_DSN};
41 0   0       state $u = $ENV{SIWWW_DB_USERNAME} // '';
42 0   0       state $p = $ENV{SIWWW_DB_PASSWORD} // '';
43 0 0         $dsn or return;
44 0           DBI->connect( $dsn, $u, $p );
45 0           };
46            
47 0     0     my $get_session = sub ( $self, $c ) {
  0            
  0            
  0            
48 0 0         my $db = $self->$get_db or return undef;
49 0           my $sth = $db->prepare( 'SELECT u.id, u.username, u.email, u.created, s.id AS session_id, s.token AS session FROM "user" u INNER JOIN session s ON u.id=s.user_id WHERE s.token=?' );
50 0 0 0       $sth->execute( ref($c) ? ( $c->req->param('session') // $c->req->json->{session} ) : $c );
51 0 0         if ( my $row = $sth->fetchrow_hashref ) {
52 0           my $sth2 = $db->prepare( 'UPDATE session SET last_access=? WHERE id=?' );
53 0           $sth2->execute( $row->{session_id}, scalar(time) );
54 0           return $row;
55             }
56 0           return undef;
57 0           };
58            
59             # Story list
60             {
61 0           $self->routes->get( '/' )->to(
62 0     0     cb => sub ($c) {
  0            
63 0           my $stories = $self->config( 'story' );
64             my @keys = sort {
65 0   0       ( $stories->{$a}{title} // 'Story' ) cmp ( $stories->{$b}{title} // 'Story' )
  0   0        
66             } keys %$stories;
67 0           my $html = '
    ';
68 0           for my $k ( @keys ) {
69             $html .= sprintf(
70             '
  • %s
  • ',
    71             xml_escape( $c->url_for( "/story/$k" ) ),
    72 0           xml_escape( $stories->{$k}{title} ),
    73             );
    74             }
    75 0           $html .= '';
    76 0           $c->stash->{title} = 'Stories';
    77 0           $c->stash->{story_list} = $html;
    78 0           $c->render( template => 'index' );
    79             },
    80 0           )->name( 'index' );
    81             }
    82            
    83             # HTML + JavaScript story harness
    84             {
    85 0           $self->routes->get( '/story/:story' )->to(
      0            
    86 0     0     cb => sub ($c) {
      0            
    87 0           my $story_id = $c->stash( 'story' );
    88 0           my $story_config = $self->config( 'story' )->{$story_id};
    89 0           $c->stash->{api} = $c->url_for('/api');
    90 0           $c->stash->{story_id} = $story_id;
    91 0   0       $c->stash->{title} = $story_config->{title} // 'Story';
    92 0   0       $c->stash->{storage_key} = $story_config->{storage_key} // $story_id;
    93 0           $c->stash->{server_storage} = !!$self->$get_db;
    94 0           $c->stash->{server_signups} = !!$self->config( 'open_signups' );
    95 0   0       $c->render( template => $story_config->{template} // 'story' );
    96             },
    97 0           )->name( 'story' );
    98             }
    99            
    100             # API endpoint to get a blank slate state
    101             {
    102 0           $self->routes->get( '/api/state/init' )->to(
      0            
    103 0     0     cb => sub ( $c ) {
      0            
    104 0           my $blank = Story::Interact::State->new;
    105 0           $c->render( json => { state => $blank->dump } );
    106             },
    107 0           )->name( 'api-state-init' );
    108             }
    109            
    110             # API endpoint to read a page
    111             {
    112 0     0     my $render_html = sub ( $page ) {
      0            
      0            
      0            
    113 0           my $markdown = join "\n\n", @{ $page->text };
      0            
    114 0           return markdown( $markdown );
    115 0           };
    116 0           $self->routes->post( '/api/story/:story/page/:page' )->to(
    117 0     0     cb => sub ( $c ) {
      0            
    118 0           my $story_id = $c->stash( 'story' );
    119 0           my $page_id = $c->stash( 'page' );
    120 0           $c->log->info("Request for page `$page_id` from story `$story_id`");
    121 0           my $story_config = $self->config( 'story' )->{$story_id};
    122 0           my $page_source = $story_config->{page_source};
    123 0   0       my $munge_state = $story_config->{state_munge} // sub {};
    124 0   0       my $munge = $story_config->{data_munge} // sub {};
    125 0           my $state = Story::Interact::State->load( $c->req->json( '/state' ) );
    126 0           $munge_state->( $c, $state );
    127            
    128 0 0         if ( $page_id =~ /\A(.+)\?(.+)\z/ms ) {
    129 0           $page_id = $1;
    130 0           require URI::Query;
    131 0           my $params = URI::Query->new( $2 )->hash;
    132 0           $state->params( $params );
    133             }
    134             else {
    135 0           $state->params( {} );
    136             }
    137            
    138 0           local $Story::Interact::SESSION;
    139 0           local $Story::Interact::DATABASE;
    140            
    141 0 0         if ( $c->req->json->{session} ) {
    142 0           $Story::Interact::SESSION = $self->$get_session( $c );
    143 0           $Story::Interact::DATABASE = sub { $self->$get_db };
      0            
    144             }
    145            
    146 0           my $page = $page_source->get_page( $state, $page_id );
    147 0           my %data = (
    148             %$page,
    149             state => $state->dump,
    150             html => $render_html->( $page ),
    151             );
    152 0           $munge->( \%data, $page, $state );
    153 0           $c->render( json => \%data );
    154             },
    155 0           )->name( 'api-story-page' );
    156             }
    157              
    158             # API endpoint for user creation
    159             {
    160 0           $self->routes->post( '/api/user/init' )->to(
      0            
    161 0     0     cb => sub ( $c ) {
      0            
    162 0 0         $self->config( 'open_signups' ) or die;
    163            
    164 0 0         my $db = $self->$get_db or die;
    165 0           my $u = $c->req->json->{username};
    166 0 0         my $p = $c->req->json->{password} or die;
    167 0           my $e = $c->req->json->{email};
    168            
    169 0           my $hash = sha256_hex( sprintf( '%s:%s', $u, $p ) );
    170 0           my $sth = $db->prepare( 'INSERT INTO "user" ( username, password, email, created ) VALUES ( ?, ?, ?, ? )' );
    171 0 0         if ( $sth->execute( $u, $hash, $e, scalar(time) ) ) {
    172 0           my $id = $db->last_insert_id;
    173 0           my $session_id = Nanoid::generate();
    174 0           my $sth = $db->prepare( 'INSERT INTO session ( user_id, token, last_access ) VALUES ( ?, ?, ? )' );
    175 0           $sth->execute( $id, $session_id, scalar(time) );
    176 0           $c->render( json => { session => $session_id, username => $u } );
    177             }
    178             else {
    179 0           $c->render( json => { error => 'User creation error' } );
    180             }
    181             },
    182 0           )->name( 'api-user-init' );
    183             }
    184              
    185             # API endpoint for logins
    186             {
    187 0           $self->routes->post( '/api/session/init' )->to(
      0            
    188 0     0     cb => sub ( $c ) {
      0            
    189 0 0         my $db = $self->$get_db or die;
    190 0           my $u = $c->req->json->{username};
    191 0           my $p = $c->req->json->{password};
    192            
    193 0           my $hash = sha256_hex( sprintf( '%s:%s', $u, $p ) );
    194 0           my $sth = $db->prepare( 'SELECT id, username FROM "user" WHERE username=? AND password=?' );
    195 0           $sth->execute( $u, $hash );
    196 0 0         if ( my $row = $sth->fetchrow_hashref ) {
    197 0           my $session_id = Nanoid::generate();
    198 0           my $sth = $db->prepare( 'INSERT INTO session ( user_id, token, last_access ) VALUES ( ?, ?, ? )' );
    199 0           $sth->execute( $row->{id}, $session_id, scalar(time) );
    200 0           $c->render( json => { session => $session_id, username => $u } );
    201             }
    202             else {
    203 0           $c->render( json => { error => 'Authentication error' } );
    204             }
    205             },
    206 0           )->name( 'api-session-init' );
    207             }
    208              
    209             # API endpoint for logout
    210             {
    211 0           $self->routes->post( '/api/session/destroy' )->to(
      0            
    212 0     0     cb => sub ( $c ) {
      0            
    213 0 0         my $db = $self->$get_db or die;
    214 0           my $session = $self->$get_session( $c );
    215 0           my $sth = $db->prepare( 'DELETE FROM session WHERE id=? AND token=? AND user_id=?' );
    216 0           $sth->execute( $session->{session_id}, $session->{session}, $session->{id} );
    217 0           $c->render( json => { session => \0 } );
    218             },
    219 0           )->name( 'api-session-destroy' );
    220             }
    221              
    222             # API endpoints for bookmarks
    223             {
    224 0           $self->routes->get( '/api/story/:story/bookmark' )->to(
      0            
      0            
    225 0     0     cb => sub ( $c ) {
      0            
    226 0 0         my $db = $self->$get_db or die;
    227 0           my $story_id = $c->stash( 'story' );
    228 0           my $session = $self->$get_session( $c );
    229 0           my $sth = $db->prepare( 'SELECT slug, label, created, modified FROM bookmark WHERE user_id=? AND story=?' );
    230 0           $sth->execute( $session->{id}, $story_id );
    231 0           my @results;
    232 0           while ( my $row = $sth->fetchrow_hashref ) {
    233 0           push @results, $row;
    234             }
    235 0           $c->render( json => { bookmarks => \@results } );
    236             },
    237 0           )->name( 'api-story-bookmark' );
    238            
    239 0           $self->routes->post( '/api/story/:story/bookmark' )->to(
    240 0     0     cb => sub ( $c ) {
      0            
    241 0 0         my $db = $self->$get_db or die;
    242 0           my $story_id = $c->stash( 'story' );
    243 0 0         my $session = $self->$get_session( $c ) or die;
    244 0           my $slug = Nanoid::generate( size => 14 );
    245 0   0       my $label = $c->req->json->{label} // 'Unlabelled';
    246 0 0         my $data = $c->req->json->{stored_data} or die;
    247 0           my $now = time;
    248 0           my $sth = $db->prepare( 'INSERT INTO bookmark ( user_id, story, slug, label, created, modified, stored_data ) VALUES ( ?, ?, ?, ?, ?, ?, ? )' );
    249 0 0         if ( $sth->execute( $session->{id}, $story_id, $slug, $label, $now, $now, $data ) ) {
    250 0           $c->render( json => { slug => $slug, label => $label, created => $now, modified => $now } );
    251             }
    252             else {
    253 0           $c->render( json => { error => 'Error storing bookmark data' } );
    254             }
    255             },
    256 0           )->name( 'api-story-bookmark-post' );
    257            
    258 0           $self->routes->get( '/api/story/:story/bookmark/:slug' )->to(
    259 0     0     cb => sub ( $c ) {
      0            
    260 0 0         my $db = $self->$get_db or die;
    261 0           my $story_id = $c->stash( 'story' );
    262 0           my $slug = $c->stash( 'slug' );
    263 0           my $session = $self->$get_session( $c );
    264 0           my $sth = $db->prepare( 'SELECT slug, label, created, modified, stored_data FROM bookmark WHERE story=? AND slug=?' );
    265 0           $sth->execute( $story_id, $slug );
    266 0 0         if ( my $row = $sth->fetchrow_hashref ) {
    267 0           $c->render( json => $row );
    268             }
    269             else {
    270 0           $c->render( json => { error => 'Bookmark not found' } );
    271             }
    272             },
    273 0           )->name( 'api-story-bookmark-slug' );
    274            
    275 0           $self->routes->post( '/api/story/:story/bookmark/:slug' )->to(
    276 0     0     cb => sub ( $c ) {
      0            
    277 0 0         my $db = $self->$get_db or die;
    278 0           my $story_id = $c->stash( 'story' );
    279 0           my $slug = $c->stash( 'slug' );
    280 0           my $session = $self->$get_session( $c );
    281 0 0         if ( $c->req->json->{stored_data} ) {
    282 0           my $sth = $db->prepare( 'UPDATE bookmark SET modified=?, stored_data=? WHERE user_id=? AND story=? AND slug=?' );
    283 0 0         if ( $sth->execute( scalar(time), $c->req->json->{stored_data}, $session->{id}, $story_id, $slug ) ) {
    284 0           $c->render( json => {} );
    285             }
    286             else {
    287 0           $c->render( json => { error => 'Error storing bookmark data' } );
    288             }
    289             }
    290             else {
    291 0           my $sth = $db->prepare( 'DELETE FROM bookmark WHERE user_id=? AND story=? AND slug=?' );
    292 0 0         if ( $sth->execute( $session->{id}, $story_id, $slug ) ) {
    293 0           $c->render( json => {} );
    294             }
    295             else {
    296 0           $c->render( json => { error => 'Error removing bookmark data' } );
    297             }
    298             }
    299             },
    300 0           )->name( 'api-story-bookmark-slug-post' );
    301             }
    302              
    303             # Done!
    304             }
    305              
    306             1;
    307              
    308             __END__