File Coverage

blib/lib/Story/Interact/WWW.pm
Criterion Covered Total %
statement 32 230 13.9
branch 0 44 0.0
condition 0 20 0.0
subroutine 11 25 44.0
pod 1 1 100.0
total 44 320 13.7


line stmt bran cond sub pod time code
1 2     2   473105 use 5.024000;
  2         33  
2 2     2   10 use strict;
  2         5  
  2         40  
3 2     2   10 use warnings;
  2         4  
  2         113  
4              
5             package Story::Interact::WWW;
6              
7             our $AUTHORITY = 'cpan:TOBYINK';
8             our $VERSION = '0.001006';
9              
10 2     2   12 use constant DISTRIBUTION => 'Story-Interact-WWW';
  2         3  
  2         123  
11              
12 2     2   1141 use Digest::SHA qw( sha256_hex );
  2         6492  
  2         156  
13 2     2   1014 use Mojo::ShareDir;
  2         396415  
  2         147  
14 2     2   20 use Mojo::Base 'Mojolicious', -signatures;
  2         7  
  2         9  
15 2     2   625712 use Mojo::Util qw( xml_escape );
  2         6  
  2         111  
16 2     2   967 use Nanoid ();
  2         21947  
  2         47  
17 2     2   904 use Story::Interact::State ();
  2         727951  
  2         94  
18 2     2   1032 use Text::Markdown::Hoedown;
  2         2543  
  2         6508  
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           local $Story::Interact::SESSION;
    131 0           local $Story::Interact::DATABASE;
    132            
    133 0 0         if ( $c->req->json->{session} ) {
    134 0           $Story::Interact::SESSION = $self->$get_session( $c );
    135 0           $Story::Interact::DATABASE = $self->config( 'database' );
    136             }
    137            
    138 0           my $page = $page_source->get_page( $state, $page_id );
    139 0           my %data = (
    140             %$page,
    141             state => $state->dump,
    142             html => $render_html->( $page ),
    143             );
    144 0           $munge->( \%data, $page, $state );
    145 0           $c->render( json => \%data );
    146             },
    147 0           )->name( 'api-story-page' );
    148             }
    149              
    150             # API endpoint for user creation
    151             {
    152 0           $self->routes->post( '/api/user/init' )->to(
      0            
    153 0     0     cb => sub ( $c ) {
      0            
    154 0 0         $self->config( 'open_signups' ) or die;
    155            
    156 0 0         my $db = $self->config( 'database' ) or die;
    157 0           my $u = $c->req->json->{username};
    158 0 0         my $p = $c->req->json->{password} or die;
    159 0           my $e = $c->req->json->{email};
    160            
    161 0           my $hash = sha256_hex( sprintf( '%s:%s', $u, $p ) );
    162 0           my $sth = $db->prepare( 'INSERT INTO user ( username, password, email, created ) VALUES ( ?, ?, ?, ? )' );
    163 0 0         if ( $sth->execute( $u, $hash, $e, scalar(time) ) ) {
    164 0           my $id = $db->last_insert_id;
    165 0           my $session_id = Nanoid::generate();
    166 0           my $sth = $db->prepare( 'INSERT INTO session ( user_id, token, last_access ) VALUES ( ?, ?, ? )' );
    167 0           $sth->execute( $id, $session_id, scalar(time) );
    168 0           $c->render( json => { session => $session_id, username => $u } );
    169             }
    170             else {
    171 0           $c->render( json => { error => 'User creation error' } );
    172             }
    173             },
    174 0           )->name( 'api-user-init' );
    175             }
    176              
    177             # API endpoint for logins
    178             {
    179 0           $self->routes->post( '/api/session/init' )->to(
      0            
    180 0     0     cb => sub ( $c ) {
      0            
    181 0 0         my $db = $self->config( 'database' ) or die;
    182 0           my $u = $c->req->json->{username};
    183 0           my $p = $c->req->json->{password};
    184            
    185 0           my $hash = sha256_hex( sprintf( '%s:%s', $u, $p ) );
    186 0           my $sth = $db->prepare( 'SELECT id, username FROM user WHERE username=? AND password=?' );
    187 0           $sth->execute( $u, $hash );
    188 0 0         if ( my $row = $sth->fetchrow_hashref ) {
    189 0           my $session_id = Nanoid::generate();
    190 0           my $sth = $db->prepare( 'INSERT INTO session ( user_id, token, last_access ) VALUES ( ?, ?, ? )' );
    191 0           $sth->execute( $row->{id}, $session_id, scalar(time) );
    192 0           $c->render( json => { session => $session_id, username => $u } );
    193             }
    194             else {
    195 0           $c->render( json => { error => 'Authentication error' } );
    196             }
    197             },
    198 0           )->name( 'api-session-init' );
    199             }
    200              
    201             # API endpoint for logout
    202             {
    203 0           $self->routes->post( '/api/session/destroy' )->to(
      0            
    204 0     0     cb => sub ( $c ) {
      0            
    205 0 0         my $db = $self->config( 'database' ) or die;
    206 0           my $session = $self->$get_session( $c );
    207 0           my $sth = $db->prepare( 'DELETE FROM session WHERE id=? AND token=? AND user_id=?' );
    208 0           $sth->execute( $session->{session_id}, $session->{session}, $session->{id} );
    209 0           $c->render( json => { session => \0 } );
    210             },
    211 0           )->name( 'api-session-destroy' );
    212             }
    213              
    214             # API endpoints for bookmarks
    215             {
    216 0           $self->routes->get( '/api/story/:story/bookmark' )->to(
      0            
      0            
    217 0     0     cb => sub ( $c ) {
      0            
    218 0 0         my $db = $self->config( 'database' ) or die;
    219 0           my $story_id = $c->stash( 'story' );
    220 0           my $session = $self->$get_session( $c );
    221 0           my $sth = $db->prepare( 'SELECT slug, label, created, modified FROM bookmark WHERE user_id=? AND story=?' );
    222 0           $sth->execute( $session->{id}, $story_id );
    223 0           my @results;
    224 0           while ( my $row = $sth->fetchrow_hashref ) {
    225 0           push @results, $row;
    226             }
    227 0           $c->render( json => { bookmarks => \@results } );
    228             },
    229 0           )->name( 'api-story-bookmark' );
    230            
    231 0           $self->routes->post( '/api/story/:story/bookmark' )->to(
    232 0     0     cb => sub ( $c ) {
      0            
    233 0 0         my $db = $self->config( 'database' ) or die;
    234 0           my $story_id = $c->stash( 'story' );
    235 0           my $session = $self->$get_session( $c );
    236 0           my $slug = Nanoid::generate( size => 14 );
    237 0   0       my $label = $c->req->json->{label} // 'Unlabelled';
    238 0 0         my $data = $c->req->json->{stored_data} or die;
    239 0           my $now = time;
    240 0           my $sth = $db->prepare( 'INSERT INTO bookmark ( user_id, story, slug, label, created, modified, stored_data ) VALUES ( ?, ?, ?, ?, ?, ?, ? )' );
    241 0 0         if ( $sth->execute( $session->{id}, $story_id, $slug, $label, $now, $now, $data ) ) {
    242 0           $c->render( json => { slug => $slug, label => $label, created => $now, modified => $now } );
    243             }
    244             else {
    245 0           $c->render( json => { error => 'Error storing bookmark data' } );
    246             }
    247             },
    248 0           )->name( 'api-story-bookmark-post' );
    249            
    250 0           $self->routes->get( '/api/story/:story/bookmark/:slug' )->to(
    251 0     0     cb => sub ( $c ) {
      0            
    252 0 0         my $db = $self->config( 'database' ) or die;
    253 0           my $story_id = $c->stash( 'story' );
    254 0           my $slug = $c->stash( 'slug' );
    255 0           my $session = $self->$get_session( $c );
    256 0           my $sth = $db->prepare( 'SELECT slug, label, created, modified, stored_data FROM bookmark WHERE story=? AND slug=?' );
    257 0           $sth->execute( $story_id, $slug );
    258 0 0         if ( my $row = $sth->fetchrow_hashref ) {
    259 0           $c->render( json => $row );
    260             }
    261             else {
    262 0           $c->render( json => { error => 'Bookmark not found' } );
    263             }
    264             },
    265 0           )->name( 'api-story-bookmark-slug' );
    266            
    267 0           $self->routes->post( '/api/story/:story/bookmark/:slug' )->to(
    268 0     0     cb => sub ( $c ) {
      0            
    269 0 0         my $db = $self->config( 'database' ) or die;
    270 0           my $story_id = $c->stash( 'story' );
    271 0           my $slug = $c->stash( 'slug' );
    272 0           my $session = $self->$get_session( $c );
    273 0 0         if ( $c->req->json->{stored_data} ) {
    274 0           my $sth = $db->prepare( 'UPDATE bookmark SET modified=?, stored_data=? WHERE user_id=? AND story=? AND slug=?' );
    275 0 0         if ( $sth->execute( scalar(time), $c->req->json->{stored_data}, $session->{id}, $story_id, $slug ) ) {
    276 0           $c->render( json => {} );
    277             }
    278             else {
    279 0           $c->render( json => { error => 'Error storing bookmark data' } );
    280             }
    281             }
    282             else {
    283 0           my $sth = $db->prepare( 'DELETE FROM bookmark WHERE user_id=? AND story=? AND slug=?' );
    284 0 0         if ( $sth->execute( $session->{id}, $story_id, $slug ) ) {
    285 0           $c->render( json => {} );
    286             }
    287             else {
    288 0           $c->render( json => { error => 'Error removing bookmark data' } );
    289             }
    290             }
    291             },
    292 0           )->name( 'api-story-bookmark-slug-post' );
    293             }
    294              
    295             # Done!
    296             }
    297              
    298             1;
    299              
    300             __END__