File Coverage

blib/lib/Story/Interact/WWW.pm
Criterion Covered Total %
statement 32 228 14.0
branch 0 44 0.0
condition 0 20 0.0
subroutine 11 25 44.0
pod 1 1 100.0
total 44 318 13.8


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