diff --git a/README.md b/README.md index 1f91c97..383f6d3 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ tests locally: ## TODOs -1. Full text search + archive old posts +1. Winter/Christmas/New Years background 1. "All new posts flagged" mode (require approval for new posts) 1. Tests for mod-only user? diff --git a/assets/css/elements.css b/assets/css/elements.css index 49a92eb..2a2d37b 100644 --- a/assets/css/elements.css +++ b/assets/css/elements.css @@ -32,6 +32,8 @@ body { /* background-image: url('/images/topwwbackground.gif'); */ /* Spooky time! */ background-image: url('/images/halloween_background_1.gif'); + /* Christmas/New Years-ish? */ + /* background-image: url('/images/windowstars.gif'); */ width: 95vmin; margin: 0 auto; font-family: 'w95fa', sans-serif; diff --git a/example_post_text.conf b/example_post_text.conf index 21ef9a4..7926ff1 100644 --- a/example_post_text.conf +++ b/example_post_text.conf @@ -1,12 +1,18 @@ { + max_thread_pages => 10, threads_per_page => 5, remarks_per_page => 5, + results_per_page => 5, body_max_length => 8_000, secrets => ['t0p_s3cr3t'], development => { pg_string => 'postgresql://post_text:t0p_s3cr3t@127.0.0.1/post_text' }, + production => { + pg_string => + 'postgresql://post_text:t0p_s3cr3t@127.0.0.1/post_text' + }, 'TagHelpers-Pagination', { separator => ' ', current => '{current}', diff --git a/lib/PostText.pm b/lib/PostText.pm index 3d61ae7..eb53c6e 100644 --- a/lib/PostText.pm +++ b/lib/PostText.pm @@ -13,6 +13,7 @@ use HTML::Restrict; use PostText::Model::Thread; use PostText::Model::Remark; use PostText::Model::Moderator; +use PostText::Model::Page; sub startup($self) { $self->plugin('Config'); @@ -59,6 +60,10 @@ sub startup($self) { ) }); + $self->helper(page => sub ($c) { + state $moderator = PostText::Model::Page->new(pg => $c->pg) + }); + $self->helper(truncate_text => sub ($c, $input_text) { my $truncated_text = 299 < length($input_text) ? substr($input_text, 0, 299) . '…' : $input_text; @@ -85,7 +90,7 @@ sub startup($self) { # Finish configuring some things $self->secrets($self->config->{'secrets'}) || die $@; - $self->pg->migrations->from_dir('migrations')->migrate(14); + $self->pg->migrations->from_dir('migrations')->migrate(15); if (my $threads_per_page = $self->config->{'threads_per_page'}) { $self->thread->per_page($threads_per_page) @@ -95,6 +100,14 @@ sub startup($self) { $self->remark->per_page($remarks_per_page) } + if (my $results_per_page = $self->config->{'results_per_page'}) { + $self->page->per_page($results_per_page) + } + + if (my $max_thread_pages = $self->config->{'max_thread_pages'}) { + $self->thread->max_pages($max_thread_pages) + } + $self->asset->process; push @{$self->commands->namespaces}, 'PostText::Command'; @@ -116,7 +129,7 @@ sub startup($self) { return $c->redirect_to( captcha_page => return_url => - b64_encode gzip $c->url_for->to_abs->to_string + b64_encode gzip $c->url_with->to_abs->to_string ), undef; }); @@ -137,6 +150,9 @@ sub startup($self) { $r->get('/feeds')->to('page#feeds')->name('feeds_page'); + # Not-so-static but I mean they're all 'pages' c'mon + $human->get('/search')->to('page#search')->name('search_page'); + $r->any([qw{GET POST}], '/captcha/*return_url') ->to('page#captcha') ->name('captcha_page'); diff --git a/lib/PostText/Controller/Moderator.pm b/lib/PostText/Controller/Moderator.pm index 1d8891d..93e9f64 100644 --- a/lib/PostText/Controller/Moderator.pm +++ b/lib/PostText/Controller/Moderator.pm @@ -7,7 +7,7 @@ sub flagged($self) { my @post_links = map { $self->url_for( 'hidden_' . $_->{'type'}, $_->{'type'} . '_id' => $_->{'id'} - ) + ) } @{$flagged_posts}; $self->stash(post_links => \@post_links); @@ -20,7 +20,7 @@ sub hidden($self) { my @post_links = map { $self->url_for( 'hidden_' . $_->{'type'}, $_->{'type'} . '_id' => $_->{'id'} - ) + ) } @{$hidden_posts}; $self->stash(post_links => \@post_links); @@ -57,7 +57,7 @@ sub login($self) { author => $mod_name, is_admin => $admin_status ); - $self->flash(info => "Hello, $mod_name 😎"); + $self->flash(info => "Hello, $mod_name. 😎"); $self->moderator->login_timestamp($mod_id); return $self->redirect_to('flagged_list'); @@ -77,7 +77,7 @@ sub login($self) { sub logout($self) { delete $self->session->%{qw(mod_id is_admin)}; - $self->flash(info => 'Logged out successfully 👋'); + $self->flash(info => 'Logged out successfully. 👋'); $self->redirect_to('threads_list'); } @@ -163,7 +163,7 @@ sub create($self) { my $password = $v->param('password'); $self->moderator->create($name, $email, $password); - $self->stash(info => "Created moderator account for $name 🧑‍🏭"); + $self->stash(info => "Created moderator account for $name. 🧑‍🏭"); } } @@ -187,7 +187,7 @@ sub admin_reset($self) { my $password = $v->param('password'); $self->moderator->admin_reset($email, $password); - $self->stash(info => "Reset password for $email 🔐"); + $self->stash(info => "Reset password for $email. 🔐"); } } @@ -210,7 +210,7 @@ sub mod_reset($self) { my $mod_id = $self->session->{'mod_id'}; $self->moderator->mod_reset($mod_id, $password); - $self->flash(info => "Password has been reset 🔐"); + $self->flash(info => "Password has been reset. 🔐"); return $self->redirect_to('flagged_list'); } @@ -234,7 +234,7 @@ sub lock_acct($self) { my $email = $v->param('email'); $self->moderator->lock_acct($email); - $self->stash(info => "Account $email has been locked 🔒"); + $self->stash(info => "Account $email has been locked. 🔒"); } } @@ -256,7 +256,7 @@ sub unlock_acct($self) { my $email = $v->param('email'); $self->moderator->unlock_acct($email); - $self->stash(info => "Account $email has been unlocked 🔓"); + $self->stash(info => "Account $email has been unlocked. 🔓"); } } @@ -278,7 +278,7 @@ sub promote($self) { my $email = $v->param('email'); $self->moderator->promote($email); - $self->stash(info => "Account $email has been promoted to admin 🧑‍🎓"); + $self->stash(info => "Account $email has been promoted to admin. 🧑‍🎓"); } } @@ -300,7 +300,7 @@ sub demote($self) { my $email = $v->param('email'); $self->moderator->demote($email); - $self->stash(info => "Account $email has been demoted to mod 🧒"); + $self->stash(info => "Account $email has been demoted to mod. 🧒"); } } @@ -326,7 +326,7 @@ sub thread_by_id($self) { $self->stash(thread => $thread); - $self->stash(status => 404, error => 'Thread not found 🤷') + $self->stash(status => 404, error => 'Thread not found. 🤷') unless keys %{$thread}; $self->render; @@ -338,7 +338,7 @@ sub remark_by_id($self) { $self->stash(remark => $remark); - $self->stash(status => 404, error => 'Remark not found 🤷') + $self->stash(status => 404, error => 'Remark not found. 🤷') unless keys %{$remark}; $self->render; diff --git a/lib/PostText/Controller/Page.pm b/lib/PostText/Controller/Page.pm index e8614bc..2b5dba1 100644 --- a/lib/PostText/Controller/Page.pm +++ b/lib/PostText/Controller/Page.pm @@ -50,4 +50,38 @@ sub captcha($self) { $self->render; } +sub search($self) { + my $v = $self->validation; + my $search_results = []; + my ($search_query, $this_page, $last_page, $base_path); + + if ($v->has_data) { + $v->required('q' )->size(1, 2_047); + $v->optional('page'); + + if ($v->has_error) { + $self->stash(status => 400) + } + else { + $search_query = $v->param('q'); + $this_page = $v->param('page') || 1; + $last_page = $self->page->last_page_for($search_query); + $base_path = $self->url_for->query(q => $search_query); + $search_results = $self->page->search($search_query, $this_page); + + $self->stash(status => 404, error => 'No posts found. 🔎') + unless scalar @{$search_results}; + } + } + + $self->stash( + this_page => $this_page, + last_page => $last_page, + base_path => $base_path, + search_results => $search_results + ); + + $self->render; +} + 1; diff --git a/lib/PostText/Controller/Remark.pm b/lib/PostText/Controller/Remark.pm index 4cbe8d4..7af6dd7 100644 --- a/lib/PostText/Controller/Remark.pm +++ b/lib/PostText/Controller/Remark.pm @@ -10,7 +10,7 @@ sub by_id($self) { $self->stash(remark => $remark); - $self->stash(status => 404, error => 'Remark not found 🤷') + $self->stash(status => 404, error => 'Remark not found. 🤷') unless keys %{$remark}; # Set filename for right-click & save-as behavior @@ -79,7 +79,7 @@ sub create($self) { body_limit => $body_limit ); - $self->stash(status => 404, error => 'Thread not found 🤷') + $self->stash(status => 404, error => 'Thread not found. 🤷') unless keys %{$thread}; return $self->render; diff --git a/lib/PostText/Controller/Thread.pm b/lib/PostText/Controller/Thread.pm index b258a94..d0cd240 100644 --- a/lib/PostText/Controller/Thread.pm +++ b/lib/PostText/Controller/Thread.pm @@ -67,12 +67,12 @@ sub by_id($self) { remarks => $remarks ); - $self->stash(status => 404, error => 'Thread not found 🤷') + $self->stash(status => 404, error => 'Thread not found. 🤷') unless keys %{$thread}; # Check for remarks or thread page number to make sure # remark->by_page_for did its job - $self->stash(status => 404, error => 'Page not found 🕵️') + $self->stash(status => 404, error => 'Page not found. 🕵️') unless scalar @{$remarks} || $this_page == $last_page; # Set filename for right-click & save-as behavior @@ -98,7 +98,7 @@ sub by_page($self) { base_path => $base_path ); - $self->stash(status => 404, error => 'Page not found 🕵️') + $self->stash(status => 404, error => 'Page not found. 🕵️') unless scalar @{$threads}; $self->render; diff --git a/lib/PostText/Model/Page.pm b/lib/PostText/Model/Page.pm new file mode 100644 index 0000000..a4b5651 --- /dev/null +++ b/lib/PostText/Model/Page.pm @@ -0,0 +1,64 @@ +package PostText::Model::Page; + +use Mojo::Base -base, -signatures; + +has 'pg'; + +has per_page => 5; + +has date_format => 'Dy, FMDD Mon YYYY HH24:MI:SS TZHTZM'; + +sub search($self, $search_query, $this_page = 1) { + my $date_format = $self->date_format; + my $row_count = $self->per_page; + my $offset = ($this_page - 1) * $row_count; + my @data = ($date_format, $search_query, $row_count, $offset); + + $self->pg->db->query(<<~'END_SQL', @data)->hashes; + SELECT 'thread' AS post_type, + thread_id AS post_id, + TO_CHAR(thread_date, $1) AS post_date, + thread_author AS post_author, + thread_body AS post_body, + TS_RANK(search_tokens, PLAINTO_TSQUERY('english', $2)) AS search_rank + FROM threads + WHERE search_tokens @@ PLAINTO_TSQUERY('english', $2) + UNION ALL + SELECT 'remark', + remark_id, + TO_CHAR(remark_date, $1), + remark_author, + remark_body, + TS_RANK(search_tokens, PLAINTO_TSQUERY('english', $2)) + FROM remarks + WHERE search_tokens @@ PLAINTO_TSQUERY('english', $2) + ORDER BY search_rank DESC, post_date DESC + LIMIT $3 OFFSET $4; + END_SQL +} + +sub count_for($self, $search_query) { + $self->pg->db->query(<<~'END_SQL', $search_query)->hash->{'post_tally'} + SELECT COUNT(*) AS post_tally + FROM (SELECT thread_date AS post_date + FROM threads + WHERE search_tokens @@ PLAINTO_TSQUERY('english', $1) + UNION ALL + SELECT remark_date + FROM remarks + WHERE search_tokens @@ PLAINTO_TSQUERY('english', $1)) + AS posts; + END_SQL +} + +sub last_page_for($self, $search_query) { + my $post_count = $self->count_for($search_query); + my $last_page = int($post_count / $self->per_page); + + # Add a page for 'remainder' posts + $last_page++ if $post_count % $self->per_page; + + return $last_page; +} + +1; diff --git a/lib/PostText/Model/Thread.pm b/lib/PostText/Model/Thread.pm index 9564910..f380650 100644 --- a/lib/PostText/Model/Thread.pm +++ b/lib/PostText/Model/Thread.pm @@ -2,7 +2,7 @@ package PostText::Model::Thread; use Mojo::Base -base, -signatures; -has [qw{pg hr}]; +has [qw{pg hr max_pages}]; has per_page => 5; @@ -53,11 +53,16 @@ sub by_page($self, $this_page = 1) { sub last_page($self) { my $thread_count = $self->count; my $last_page = int($thread_count / $self->per_page); + my $max_pages = $self->max_pages; # Add a page for 'remainder' posts $last_page++ if $thread_count % $self->per_page; - $last_page; + if ($max_pages) { + $last_page = $max_pages if $last_page > $max_pages + } + + return $last_page; } sub count($self) { diff --git a/migrations/15/down.sql b/migrations/15/down.sql new file mode 100644 index 0000000..404e499 --- /dev/null +++ b/migrations/15/down.sql @@ -0,0 +1,7 @@ +DROP EXTENSION pg_trgm; + +ALTER TABLE threads + DROP COLUMN search_tokens; + +ALTER TABLE remarks + DROP COLUMN search_tokens; diff --git a/migrations/15/up.sql b/migrations/15/up.sql new file mode 100644 index 0000000..ef2ba0d --- /dev/null +++ b/migrations/15/up.sql @@ -0,0 +1,27 @@ +-- Fuzzy search +-- https://hevodata.com/blog/postgresql-full-text-search-setup/#Fuzzy_Search_vs_Full_Text_Search +CREATE EXTENSION pg_trgm; + +-- Create column for seearch tokens + ALTER TABLE threads + ADD COLUMN search_tokens TSVECTOR +GENERATED ALWAYS AS + (TO_TSVECTOR('english', thread_author) || + TO_TSVECTOR('english', thread_title ) || + TO_TSVECTOR('english', thread_body )) STORED; + +-- Create GIN index for search tokens +CREATE INDEX threads_search_idx + ON threads + USING GIN(search_tokens); + +-- Same for remarks + ALTER TABLE remarks + ADD COLUMN search_tokens TSVECTOR +GENERATED ALWAYS AS + (TO_TSVECTOR('english', remark_author) || + TO_TSVECTOR('english', remark_body )) STORED; + +CREATE INDEX remarks_search_idx + ON remarks + USING GIN(search_tokens); diff --git a/public/images/windowstars.gif b/public/images/windowstars.gif new file mode 100644 index 0000000..5a9db73 Binary files /dev/null and b/public/images/windowstars.gif differ diff --git a/t/search.t b/t/search.t new file mode 100644 index 0000000..4ebbef9 --- /dev/null +++ b/t/search.t @@ -0,0 +1,34 @@ +use Mojo::Base -strict; +use Test::More; +use Test::Mojo; + +my $t = Test::Mojo->new('PostText'); +my $invalid_query = 'aaaaaaaa' x 300; +my %good_human = (answer => 1, number => 'Ⅰ'); +my $search_url = + '/captcha/H4sIABJ8PGUAA8soKSmw0tfPyU9OzMnILy6xMjYwMNDPKM1NzNMvTk0sSs4AAPrUR3kiAAAA%0A'; + +subtest 'Search before CAPTCHA', sub { + $t->get_ok('/human/search')->status_is(302) + ->header_like(Location => qr/captcha/); +}; + +subtest 'Search after CAPTCHA', sub { + $t->post_ok($search_url, form => \%good_human) + ->status_is(302) + ->header_like(Location => qr{human/search}); + + $t->get_ok('/human/search')->status_is(200) + ->text_like(h2 => qr/Search Posts/); + + $t->get_ok('/human/search?q=aaaaaaaaaa')->status_is(404) + ->text_like(p => qr/No posts found/); + + $t->get_ok('/human/search?q=lmao')->status_is(200) + ->text_like(h3 => qr/Results/); + + $t->get_ok("/human/search?q=$invalid_query")->status_is(400) + ->text_like(p => qr/Must be between/); +}; + +done_testing; diff --git a/templates/page/search.html.ep b/templates/page/search.html.ep new file mode 100644 index 0000000..fab866f --- /dev/null +++ b/templates/page/search.html.ep @@ -0,0 +1,60 @@ +% layout 'default'; +% title 'Search Posts'; +<% content_for open_graph => begin %> + + + +<% end %> +<% content_for twitter_card => begin %> + + +<% end %> +

<%= title %>

+
+
+ <% if (my $error = validation->error('q')) { =%> +

Must be between <%= $error->[2] %> + and <%= $error->[3] %> characters.

+ <% } =%> + <%= label_for search => 'Search' %> + <%= text_field q => ( + id => 'search', + maxlength => 2047, + minlength => 1, + required => undef, + autofocus => undef + ) %> +
+ +
+<% if (scalar @{$search_results}) { =%> +
+

Results

+ <% for my $result (@{$search_results}) { =%> +
+

+ + <%= $result->{'post_date'} %> + + <% if ($result->{'post_type'} eq 'thread') { =%> + <%= link_to "#$result->{'post_id'}", single_thread => + {thread_id => $result->{'post_id'}}, (class => 'post__id') %> + <% } else { =%> + <%= link_to "#$result->{'post_id'}", single_remark => + {remark_id => $result->{'post_id'}}, (class => 'post__id') %> + <% } =%> +

+ +
+ <%== markdown $result->{'post_body'} =%> +
+
+ <% } =%> + <% if ($last_page && $last_page != 1) { =%> + + <% } =%> +
+<% } =%> diff --git a/templates/thread/by_page.html.ep b/templates/thread/by_page.html.ep index 844fec5..5212c8b 100644 --- a/templates/thread/by_page.html.ep +++ b/templates/thread/by_page.html.ep @@ -12,6 +12,22 @@

<%= title %>

<% if (scalar @{$threads}) { =%>
+ <%= form_for search_page => (class => 'form-body'), begin %> +
+ <% if (my $error = validation->error('q')) { =%> +

Must be between <%= $error->[2] %> + and <%= $error->[3] %> characters.

+ <% } =%> + <%= label_for search => 'Search' %> + <%= text_field q => ( + id => 'search', + maxlength => 2047, + minlength => 1, + required => undef + ) %> +
+ + <% end %> <% for my $thread (@{$threads}) { =%>