├── public ├── favicon.ico ├── images │ ├── rss.png │ ├── gravatar-20.jpg │ └── gravatar-40.jpg ├── font-awesome │ └── fonts │ │ ├── FontAwesome.otf │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.ttf │ │ └── fontawesome-webfont.woff ├── js │ ├── autosize.js │ ├── tags.js │ ├── essentials.js │ ├── actions.js │ └── models.js ├── jquery-ui │ ├── images │ │ ├── ui-icons_222222_256x240.png │ │ ├── ui-icons_228ef1_256x240.png │ │ ├── ui-icons_ef8c08_256x240.png │ │ ├── ui-icons_ffd27a_256x240.png │ │ ├── ui-icons_ffffff_256x240.png │ │ ├── ui-bg_flat_10_000000_40x100.png │ │ ├── ui-bg_glass_65_ffffff_1x400.png │ │ ├── ui-bg_glass_100_f6f6f6_1x400.png │ │ ├── ui-bg_glass_100_fdf5ce_1x400.png │ │ ├── ui-bg_gloss-wave_35_f6a828_500x100.png │ │ ├── ui-bg_diagonals-thick_18_b81900_40x40.png │ │ ├── ui-bg_diagonals-thick_20_666666_40x40.png │ │ ├── ui-bg_highlight-soft_100_eeeeee_1x100.png │ │ └── ui-bg_highlight-soft_75_ffe45c_1x100.png │ └── jquery-ui.structure.min.css ├── 502.html ├── unsemantic │ ├── js │ │ ├── adapt.min.js │ │ └── html5.js │ └── css │ │ ├── reset.css │ │ └── reset-rtl.css ├── tagsinput │ └── jquery.tagsinput.css └── formalize │ └── js │ └── jquery.formalize.min.js ├── .gitignore ├── schema ├── 07thread-updated.sql ├── 12reply-updated.sql ├── 01thread-slug.sql ├── 13user-role.sql ├── 16thread_editor.sql ├── 17thread_ascii_slug.sql ├── 06thread_activity.sql ├── 15notifications_sent_flag.sql ├── 18unique-user-name.sql ├── 09nonces.sql ├── 08disposable-email-blacklist.sql ├── 04notifications.sql ├── 10reports.sql ├── 02reply-thank.sql ├── 03subscriptions.sql ├── 14tags.sql ├── 05thread-views.sql ├── 11user.sql └── 00schema.sql ├── templates ├── forbidden.apl ├── include │ ├── admin_nav.apl │ ├── markup-help-button.apl │ ├── thread-controls.apl │ ├── markup-help.apl │ ├── reply-thank.apl │ ├── quick-edit-form.apl │ ├── pager.apl │ ├── reply-meta.apl │ ├── quick-reply-form.apl │ └── reply-controls.apl ├── not_found.apl ├── admin_index.apl ├── password_changed.apl ├── email │ ├── notifications_digest.apl │ ├── confirmation_required.apl │ ├── password_reset.apl │ └── deregistration_confirmation_required.apl ├── password_reset_success.apl ├── deregistration_confirmation_success.apl ├── password_reset_confirmation_needed.apl ├── deregistration_confirmation_needed.apl ├── activation_needed.apl ├── resend_registration_confirmation_success.apl ├── activation_success.apl ├── deregister.apl ├── activation_failure.apl ├── request_password_reset.apl ├── reset_password.apl ├── resend_registration_confirmation.apl ├── change_password.apl ├── settings.apl ├── login.apl ├── register.apl ├── list_subscriptions.apl ├── profile.apl ├── admin_list_users.apl ├── index.apl ├── threads_rss.apl ├── list_notifications.apl ├── create_thread.apl └── update_thread.apl ├── lib └── Threads │ ├── DB.pm │ ├── Helper │ ├── Displayer.pm │ ├── Config.pm │ ├── Markup.pm │ ├── Truncate.pm │ ├── Meta.pm │ ├── User.pm │ ├── AdminUser.pm │ ├── Gravatar.pm │ ├── Date.pm │ ├── Antibot.pm │ ├── Url.pm │ ├── Notification.pm │ ├── Acl.pm │ ├── Subscription.pm │ ├── Reply.pm │ └── Pager.pm │ ├── Action.pm │ ├── Validator │ ├── FakeField.pm │ ├── MaxLength.pm │ ├── MinLength.pm │ ├── Readable.pm │ ├── Captcha.pm │ ├── Tags.pm │ ├── TooFast.pm │ ├── Email.pm │ └── NotDisposableEmail.pm │ ├── Job │ ├── Base.pm │ ├── CleanupInactiveRegistrations.pm │ ├── CleanupThreadViews.pm │ └── SendEmailNotifications.pm │ ├── Action │ ├── TranslateMixin.pm │ ├── Preview.pm │ ├── Logout.pm │ ├── AdminListUsers.pm │ ├── DeleteSubscriptions.pm │ ├── ListNotifications.pm │ ├── ListSubscriptions.pm │ ├── JSONMixin.pm │ ├── DeleteNotifications.pm │ ├── Settings.pm │ ├── ReadReply.pm │ ├── AdminToggleBlocked.pm │ ├── ThreadsRss.pm │ ├── AutocompleteTags.pm │ ├── DeleteThread.pm │ ├── ConfirmRegistration.pm │ ├── DeleteReply.pm │ ├── FormBase.pm │ ├── ToggleSubscription.pm │ ├── ChangePassword.pm │ ├── ConfirmDeregistration.pm │ ├── Deregister.pm │ ├── Index.pm │ ├── ToggleReport.pm │ ├── ThankReply.pm │ ├── ResetPassword.pm │ ├── ViewThread.pm │ ├── UpdateReply.pm │ ├── CreateThread.pm │ ├── UpdateThread.pm │ ├── RequestPasswordReset.pm │ └── Login.pm │ ├── DB │ ├── Thank.pm │ ├── View.pm │ ├── DisposableEmailBlacklist.pm │ ├── Subscription.pm │ ├── Notification.pm │ ├── Nonce.pm │ ├── MapThreadTag.pm │ ├── Report.pm │ ├── Tag.pm │ ├── User.pm │ └── Confirmation.pm │ ├── LimitChecker.pm │ ├── Middleware │ └── Origin.pm │ ├── Notificator.pm │ ├── Util.pm │ ├── Origin.pm │ ├── ObjectACL.pm │ ├── UserLoader.pm │ └── MarkupRenderer.pm ├── t ├── lib │ ├── TestLib.pm │ ├── TestFunctional.pm │ ├── TestMail.pm │ ├── TestRequest.pm │ └── TestDB.pm ├── util.t ├── validator │ ├── email.t │ ├── fake_field.t │ ├── captcha.t │ ├── readable.t │ ├── min_length.t │ ├── max_length.t │ ├── tags.t │ ├── too_fast.t │ └── not_disposable_email.t ├── helper │ ├── user.t │ ├── truncate.t │ ├── subscription.t │ ├── gravatar.t │ ├── thread.t │ └── url.t ├── action │ ├── preview.t │ ├── settings.t │ ├── logout.t │ ├── delete_notifications.t │ ├── delete_subscriptions.t │ ├── autocomplete_tags.t │ └── change_password.t ├── generate.pl ├── object_acl.t ├── jobs │ ├── cleanup_thread_views.t │ └── cleanup_inactive_registrations.t ├── functional │ ├── feeds.t │ └── replies.t └── db │ ├── user.t │ └── confirmation.t ├── config ├── config.test.yml └── config.yml.example ├── tjs ├── app.psgi ├── models │ ├── value_object.js │ └── value_object_observable.js ├── suite.html └── actions │ ├── no_count_title.js │ └── no_count.js ├── util ├── update-thread-slug-ascii.pl ├── create-user.pl ├── update-po-files.pl ├── import-disposable-emails.pl ├── deploy.sh ├── block-user.pl └── run-job.pl ├── .gitmodules ├── cpanfile └── README.md /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | db.db 2 | util/deploy.sh.rc 3 | config/config.dev.yml 4 | config/config.yml 5 | -------------------------------------------------------------------------------- /public/images/rss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/images/rss.png -------------------------------------------------------------------------------- /schema/07thread-updated.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `threads` ADD COLUMN `updated` INT NOT NULL DEFAULT 0; 2 | -------------------------------------------------------------------------------- /schema/12reply-updated.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `replies` ADD COLUMN `updated` INT NOT NULL DEFAULT 0; 2 | -------------------------------------------------------------------------------- /schema/01thread-slug.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `threads` ADD COLUMN `slug` VARCHAR(255) NOT NULL DEFAULT ''; 2 | -------------------------------------------------------------------------------- /schema/13user-role.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users` ADD COLUMN `role` VARCHAR(32) NOT NULL DEFAULT 'user'; 2 | -------------------------------------------------------------------------------- /schema/16thread_editor.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `threads` ADD COLUMN `editor_id` INT NOT NULL DEFAULT 0; 2 | -------------------------------------------------------------------------------- /schema/17thread_ascii_slug.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `threads` ADD COLUMN `slug_ascii` NOT NULL DEFAULT ''; 2 | -------------------------------------------------------------------------------- /public/images/gravatar-20.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/images/gravatar-20.jpg -------------------------------------------------------------------------------- /public/images/gravatar-40.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/images/gravatar-40.jpg -------------------------------------------------------------------------------- /schema/06thread_activity.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `threads` ADD COLUMN `last_activity` INT NOT NULL DEFAULT 0; 2 | -------------------------------------------------------------------------------- /templates/forbidden.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 | Forbidden 4 | 5 |
6 | -------------------------------------------------------------------------------- /templates/include/admin_nav.apl: -------------------------------------------------------------------------------- 1 | <%= loc('Users') %> 2 | -------------------------------------------------------------------------------- /templates/not_found.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 | Not found 4 | 5 |
6 | -------------------------------------------------------------------------------- /schema/15notifications_sent_flag.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `notifications` ADD COLUMN `is_sent` INTEGER NOT NULL DEFAULT 0; 2 | -------------------------------------------------------------------------------- /lib/Threads/DB.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'ObjectDB'; 7 | 8 | 1; 9 | -------------------------------------------------------------------------------- /schema/18unique-user-name.sql: -------------------------------------------------------------------------------- 1 | update users set name = id where name = ''; 2 | create unique index name on users(`name`); 3 | -------------------------------------------------------------------------------- /public/font-awesome/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/font-awesome/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /templates/admin_index.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 | %== $helpers->displayer->render('include/admin_nav'); 4 | 5 |
6 | -------------------------------------------------------------------------------- /templates/password_changed.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 | <%= loc('Password changed.') %> 4 | 5 |
6 | -------------------------------------------------------------------------------- /templates/email/notifications_digest.apl: -------------------------------------------------------------------------------- 1 | %== loc('You have got unread notifications. Check the link below:'); 2 | 3 | 4 | <%= $url %> 5 | -------------------------------------------------------------------------------- /public/font-awesome/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/font-awesome/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/font-awesome/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/font-awesome/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/font-awesome/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/font-awesome/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /public/js/autosize.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | $(document).ready(function() { 4 | $('textarea').autosize(); 5 | }); 6 | 7 | })(); 8 | -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-icons_222222_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-icons_222222_256x240.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-icons_228ef1_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-icons_228ef1_256x240.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-icons_ef8c08_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-icons_ef8c08_256x240.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-icons_ffd27a_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-icons_ffd27a_256x240.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-icons_ffffff_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-icons_ffffff_256x240.png -------------------------------------------------------------------------------- /templates/include/markup-help-button.apl: -------------------------------------------------------------------------------- 1 | <%= loc('markup') %> 2 |
3 | -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-bg_flat_10_000000_40x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-bg_flat_10_000000_40x100.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-bg_glass_65_ffffff_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-bg_glass_65_ffffff_1x400.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-bg_glass_100_f6f6f6_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-bg_glass_100_f6f6f6_1x400.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-bg_glass_100_fdf5ce_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-bg_glass_100_fdf5ce_1x400.png -------------------------------------------------------------------------------- /lib/Threads/Helper/Displayer.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Displayer; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper::Displayer'; 7 | 8 | 1; 9 | -------------------------------------------------------------------------------- /templates/password_reset_success.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Your password was successfully reset!') %>

4 | 5 |
6 | -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-bg_gloss-wave_35_f6a828_500x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-bg_gloss-wave_35_f6a828_500x100.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-bg_diagonals-thick_18_b81900_40x40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-bg_diagonals-thick_18_b81900_40x40.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-bg_diagonals-thick_20_666666_40x40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-bg_diagonals-thick_20_666666_40x40.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-bg_highlight-soft_100_eeeeee_1x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-bg_highlight-soft_100_eeeeee_1x100.png -------------------------------------------------------------------------------- /public/jquery-ui/images/ui-bg_highlight-soft_75_ffe45c_1x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vti/threads/HEAD/public/jquery-ui/images/ui-bg_highlight-soft_75_ffe45c_1x100.png -------------------------------------------------------------------------------- /templates/deregistration_confirmation_success.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Your account was successfully removed!') %>

4 | 5 |
6 | -------------------------------------------------------------------------------- /public/502.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Please wait. We are updating... 7 | 8 | 9 | -------------------------------------------------------------------------------- /templates/password_reset_confirmation_needed.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Please check your email for a confirmation link.') %>

4 | 5 |
6 | -------------------------------------------------------------------------------- /lib/Threads/Action.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use base 'Tu::Action'; 7 | 8 | use Threads::Action::JSONMixin 'new_json_response'; 9 | 10 | 1; 11 | -------------------------------------------------------------------------------- /schema/09nonces.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `nonces`; 2 | CREATE TABLE `nonces` ( 3 | `id` INTEGER NOT NULL PRIMARY KEY, 4 | `user_id` INTEGER NOT NULL, 5 | `created` integer(4) not null default (strftime('%s','now')) 6 | ); 7 | -------------------------------------------------------------------------------- /templates/deregistration_confirmation_needed.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Please check your email for a confirmation link.') %>

4 | 5 |

<%= loc('Good luck!') %>

6 | 7 |
8 | -------------------------------------------------------------------------------- /templates/activation_needed.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Thank you for registering. Please check your email for a confirmation link.') %>

4 | 5 |

<%= loc('See you soon!') %>

6 | 7 |
8 | -------------------------------------------------------------------------------- /templates/resend_registration_confirmation_success.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Please check your email for a confirmation link.') %>

4 | 5 |

<%= loc('See you soon!') %>

6 | 7 |
8 | -------------------------------------------------------------------------------- /templates/activation_success.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Your registration was successfully activated!') %>

4 | 5 |

<%= loc('Login') %>

6 | 7 |
8 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Config.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Config; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | sub config { 9 | my $self = shift; 10 | 11 | return $self->service('config'); 12 | } 13 | 14 | 1; 15 | -------------------------------------------------------------------------------- /schema/08disposable-email-blacklist.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `disposable_email_blacklist`; 2 | CREATE TABLE `disposable_email_blacklist` ( 3 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | `domain` VARCHAR(64) NOT NULL, 5 | UNIQUE (`domain`) 6 | ); 7 | -------------------------------------------------------------------------------- /templates/deregister.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Deregistration') %>

4 | 5 |
6 | 7 | 8 | 9 |
10 | 11 |
12 | -------------------------------------------------------------------------------- /templates/activation_failure.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Your confirmation token has expired') %>

4 | 5 |

<%= loc('Resend registration confirmation') %>

6 | 7 |
8 | -------------------------------------------------------------------------------- /schema/04notifications.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `notifications`; 2 | CREATE TABLE `notifications` ( 3 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | `user_id` INT NOT NULL, 5 | `reply_id` INT NOT NULL, 6 | `created` integer(4) not null default (strftime('%s','now')), 7 | UNIQUE(`user_id`,`reply_id`) 8 | ); 9 | -------------------------------------------------------------------------------- /t/lib/TestLib.pm: -------------------------------------------------------------------------------- 1 | package TestLib; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use File::Spec; 7 | use File::Basename qw(dirname); 8 | 9 | BEGIN { 10 | unshift @INC, "$_/lib" 11 | for glob File::Spec->catfile(dirname(__FILE__), '../../contrib/*'); 12 | 13 | $ENV{PLACK_ENV} = 'test'; 14 | } 15 | 16 | 1; 17 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Markup.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Markup; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use Threads::MarkupRenderer; 9 | 10 | sub render { 11 | my $self = shift; 12 | my ($text) = @_; 13 | 14 | return Threads::MarkupRenderer->new->render($text); 15 | } 16 | 17 | 1; 18 | -------------------------------------------------------------------------------- /lib/Threads/Validator/FakeField.pm: -------------------------------------------------------------------------------- 1 | package Threads::Validator::FakeField; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Validator::Base'; 7 | 8 | sub is_valid { 9 | my $self = shift; 10 | my ($value) = @_; 11 | 12 | return 0 if defined $value && length $value; 13 | 14 | return 1; 15 | } 16 | 17 | 1; 18 | -------------------------------------------------------------------------------- /schema/10reports.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `replies` ADD COLUMN `reports_count` NOT NULL DEFAULT 0; 2 | 3 | DROP TABLE IF EXISTS `reports`; 4 | CREATE TABLE `reports` ( 5 | `id` INTEGER NOT NULL PRIMARY KEY, 6 | `user_id` INTEGER NOT NULL, 7 | `reply_id` INTEGER NOT NULL, 8 | `created` integer(4) not null default (strftime('%s','now')) 9 | ); 10 | -------------------------------------------------------------------------------- /lib/Threads/Validator/MaxLength.pm: -------------------------------------------------------------------------------- 1 | package Threads::Validator::MaxLength; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Validator::Base'; 7 | 8 | sub is_valid { 9 | my $self = shift; 10 | my ($value, $max_length) = @_; 11 | 12 | return 0 unless length $value <= $max_length; 13 | 14 | return 1; 15 | } 16 | 17 | 1; 18 | -------------------------------------------------------------------------------- /lib/Threads/Validator/MinLength.pm: -------------------------------------------------------------------------------- 1 | package Threads::Validator::MinLength; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Validator::Base'; 7 | 8 | sub is_valid { 9 | my $self = shift; 10 | my ($value, $min_length) = @_; 11 | 12 | return 0 unless length $value >= $min_length; 13 | 14 | return 1; 15 | } 16 | 17 | 1; 18 | -------------------------------------------------------------------------------- /schema/02reply-thank.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `replies` ADD COLUMN `thanks_count` INT NOT NULL DEFAULT 0; 2 | 3 | DROP TABLE IF EXISTS `thanks`; 4 | CREATE TABLE `thanks` ( 5 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 6 | `user_id` INT NOT NULL, 7 | `reply_id` INT NOT NULL, 8 | `created` integer(4) not null default (strftime('%s','now')) 9 | ); 10 | -------------------------------------------------------------------------------- /lib/Threads/Validator/Readable.pm: -------------------------------------------------------------------------------- 1 | package Threads::Validator::Readable; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Validator::Base'; 7 | 8 | sub is_valid { 9 | my $self = shift; 10 | my ($value) = @_; 11 | 12 | $value =~ s/[^[:alnum:]]//g; 13 | 14 | return 1 if length $value >= 3; 15 | 16 | return 0; 17 | } 18 | 19 | 1; 20 | -------------------------------------------------------------------------------- /lib/Threads/Job/Base.pm: -------------------------------------------------------------------------------- 1 | package Threads::Job::Base; 2 | 3 | use strict; 4 | use warnings; 5 | use attrs 'dry_run', 'verbose', 'config' => sub { {} }; 6 | 7 | binmode STDOUT, ':utf8'; 8 | 9 | sub run { ... } 10 | 11 | sub _config { $_[0]->{config} } 12 | sub _is_verbose { $_[0]->{verbose} || $_[0]->{dry_run} } 13 | sub _is_dry_run { $_[0]->{dry_run} } 14 | 15 | 1; 16 | -------------------------------------------------------------------------------- /schema/03subscriptions.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users` ADD COLUMN email_notifications INT NOT NULL DEFAULT 1; 2 | 3 | DROP TABLE IF EXISTS `subscriptions`; 4 | CREATE TABLE `subscriptions` ( 5 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 6 | `user_id` INT NOT NULL, 7 | `thread_id` INT NOT NULL, 8 | `created` integer(4) not null default (strftime('%s','now')) 9 | ); 10 | -------------------------------------------------------------------------------- /config/config.test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | base_url: 'http://localhost:5000' 3 | session: 4 | secret: '123' 5 | mailer: 6 | subject_prefix: '[Threads]' 7 | headers: 8 | - 'From' 9 | - 'foo@bar.com' 10 | transport: 11 | name: 'test' 12 | path: '/tmp/mailer.log' 13 | i18n: 14 | default_language: 'en' 15 | languages: 16 | - 'en' 17 | -------------------------------------------------------------------------------- /templates/email/confirmation_required.apl: -------------------------------------------------------------------------------- 1 | % my $url = $helpers->config->config->{base_url} . $helpers->url->confirm_registration(token => $token); 2 | %== loc(q{You or somebody else has registered '[_1]' at our website. If that was you, please use the following link to confirm your registration:}, $email) 3 | 4 | 5 | <%= $url %> 6 | 7 | %== loc('Otherwise just ignore this email.') 8 | -------------------------------------------------------------------------------- /templates/email/password_reset.apl: -------------------------------------------------------------------------------- 1 | % my $url = $helpers->config->config->{base_url} . $helpers->url->reset_password(token => $token); 2 | %== loc(q{You or somebody else has requested a password reset for '[_1]' at our website. If that was you, please use the following link to confirm your action:}, $email) 3 | 4 | 5 | <%= $url %> 6 | 7 | %== loc('Otherwise just ignore this email.') 8 | -------------------------------------------------------------------------------- /lib/Threads/Validator/Captcha.pm: -------------------------------------------------------------------------------- 1 | package Threads::Validator::Captcha; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Validator::Base'; 7 | 8 | sub is_valid { 9 | my $self = shift; 10 | my ($value) = @_; 11 | 12 | my $expected = $self->{args}->[0]; 13 | 14 | return defined $value && defined $expected && $value eq $expected ? 1 : 0; 15 | } 16 | 17 | 1; 18 | -------------------------------------------------------------------------------- /tjs/app.psgi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Plack::Builder; 7 | 8 | builder { 9 | enable 'Static', path => qr{^/js}, root => '../public/'; 10 | enable 'Static', path => qr/\.(?:js|css|html)$/, root => '.'; 11 | sub { 12 | my $env = shift; 13 | 14 | return [302, [Location => '/suite.html'], []]; 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /lib/Threads/Action/TranslateMixin.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::TranslateMixin; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use base 'Exporter'; 7 | 8 | our @EXPORT_OK = qw(loc); 9 | 10 | sub loc { 11 | my $self = shift; 12 | 13 | my $handle = $self->env->{'plack.i18n.handle'}; 14 | return join ' ', @_ unless $handle; 15 | 16 | return $handle->loc(@_); 17 | } 18 | 19 | 1; 20 | -------------------------------------------------------------------------------- /schema/14tags.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `tags`; 2 | CREATE TABLE `tags` ( 3 | `id` integer PRIMARY KEY, 4 | `title` varchar(64) NOT NULL DEFAULT '', 5 | UNIQUE(`title`) 6 | ); 7 | 8 | DROP TABLE IF EXISTS `map_thread_tag`; 9 | CREATE TABLE `map_thread_tag` ( 10 | `thread_id` integer NOT NULL, 11 | `tag_id` integer NOT NULL, 12 | PRIMARY KEY(`thread_id`, `tag_id`) 13 | ); 14 | -------------------------------------------------------------------------------- /templates/email/deregistration_confirmation_required.apl: -------------------------------------------------------------------------------- 1 | % my $url = $helpers->config->config->{base_url} . $helpers->url->confirm_deregistration(token => $token); 2 | %== loc(q{You or somebody else has deregistered '[_1]' at our website. If that was you, please use the following link to confirm your account removal}, $email) 3 | 4 | 5 | <%= $url %> 6 | 7 | %== loc('Otherwise just ignore this email.') 8 | -------------------------------------------------------------------------------- /templates/request_password_reset.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Password reset') %>

4 | 5 |
6 | 7 | <%== $helpers->form->input('email', label => 'E-mail') %> 8 | 9 |
10 | 11 |
12 | 13 |
14 | 15 |
16 | -------------------------------------------------------------------------------- /t/util.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | 6 | use Threads::Util qw(gentoken from_hex to_hex); 7 | 8 | subtest 'generates token' => sub { 9 | my $token = gentoken(32); 10 | 11 | is length $token, 32; 12 | }; 13 | 14 | subtest 'hex and binary' => sub { 15 | my $token = gentoken(32); 16 | 17 | is $token, from_hex to_hex $token; 18 | }; 19 | 20 | done_testing; 21 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Truncate.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Truncate; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use HTML::Truncate; 9 | 10 | sub truncate { 11 | my $self = shift; 12 | my ($text, $chars) = @_; 13 | 14 | $chars ||= 200; 15 | 16 | my $ht = HTML::Truncate->new(chars => $chars); 17 | 18 | return $ht->truncate($text); 19 | } 20 | 21 | 1; 22 | -------------------------------------------------------------------------------- /schema/05thread-views.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `threads` ADD COLUMN `views_count` INT NOT NULL DEFAULT 0; 2 | 3 | DROP TABLE IF EXISTS `views`; 4 | CREATE TABLE `views` ( 5 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 6 | `user_id` INT NOT NULL DEFAULT 0, 7 | `thread_id` INT NOT NULL, 8 | `created` integer(4) not null default (strftime('%s','now')), 9 | `hash` varchar(32) NOT NULL DEFAULT '' 10 | ); 11 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Meta.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Meta; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | sub set { 9 | my $self = shift; 10 | my ($key, $value) = @_; 11 | 12 | $self->{meta}->{$key} = $value; 13 | 14 | return ''; 15 | } 16 | 17 | sub get { 18 | my $self = shift; 19 | my ($key) = @_; 20 | 21 | return $self->{meta}->{$key}; 22 | } 23 | 24 | 1; 25 | -------------------------------------------------------------------------------- /lib/Threads/Action/Preview.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::Preview; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action'; 7 | 8 | use Threads::MarkupRenderer; 9 | 10 | sub run { 11 | my $self = shift; 12 | 13 | my $content = $self->req->param('content'); 14 | 15 | $content = Threads::MarkupRenderer->new->render($content); 16 | 17 | return $self->new_json_response(200, {content => $content}); 18 | } 19 | 20 | 1; 21 | -------------------------------------------------------------------------------- /templates/reset_password.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Reset password') %>

4 | 5 |
6 | 7 | <%== $helpers->form->password('new_password', label => loc('New password')) %> 8 | <%== $helpers->form->password('new_password_confirmation', label => loc('Repeat new password')) %> 9 | 10 | 11 | 12 |
13 | 14 |
15 | -------------------------------------------------------------------------------- /lib/Threads/DB/Thank.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::Thank; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::DB'; 7 | 8 | __PACKAGE__->meta( 9 | table => 'thanks', 10 | columns => [ 11 | qw/ 12 | id 13 | created 14 | user_id 15 | reply_id 16 | / 17 | ], 18 | primary_key => 'id', 19 | auto_increment => 'id', 20 | generate_columns_methods => 1, 21 | ); 22 | 23 | 1; 24 | -------------------------------------------------------------------------------- /tjs/models/value_object.js: -------------------------------------------------------------------------------- 1 | QUnit.module('models/value_object'); 2 | QUnit.test("simple set/get", function(assert) { 3 | var valueObject = new ValueObject(); 4 | 5 | valueObject.set('foo', 'bar'); 6 | 7 | assert.equal(valueObject.get('foo'), 'bar'); 8 | }); 9 | 10 | QUnit.test("accept properties from constructor", function(assert) { 11 | var valueObject = new ValueObject({foo: 'bar'}); 12 | 13 | assert.equal(valueObject.get('foo'), 'bar'); 14 | }); 15 | -------------------------------------------------------------------------------- /t/lib/TestFunctional.pm: -------------------------------------------------------------------------------- 1 | package TestFunctional; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use File::Basename qw(dirname); 7 | use Test::WWW::Mechanize::PSGI; 8 | 9 | sub build_ua { 10 | my $psgi = do { 11 | local $/; 12 | open my $fh, '<', dirname(__FILE__) . '/../../app.psgi' or die $!; 13 | <$fh>; 14 | }; 15 | my $app = eval $psgi || die $@; 16 | return Test::WWW::Mechanize::PSGI->new(app => $app); 17 | } 18 | 19 | 1; 20 | -------------------------------------------------------------------------------- /lib/Threads/DB/View.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::View; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::DB'; 7 | 8 | __PACKAGE__->meta( 9 | table => 'views', 10 | columns => [ 11 | qw/ 12 | id 13 | created 14 | user_id 15 | thread_id 16 | hash 17 | / 18 | ], 19 | primary_key => 'id', 20 | auto_increment => 'id', 21 | generate_columns_methods => 1, 22 | ); 23 | 24 | 1; 25 | -------------------------------------------------------------------------------- /lib/Threads/Action/Logout.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::Logout; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | use Threads::DB::Nonce; 9 | 10 | sub run { 11 | my $self = shift; 12 | 13 | my $auth = $self->scope->auth; 14 | 15 | Threads::DB::Nonce->table->delete( 16 | where => [id => $auth->session($self->env)->{id}]); 17 | 18 | $auth->logout($self->env); 19 | 20 | return $self->redirect('index'); 21 | } 22 | 23 | 1; 24 | -------------------------------------------------------------------------------- /lib/Threads/Validator/Tags.pm: -------------------------------------------------------------------------------- 1 | package Threads::Validator::Tags; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Validator::Base'; 7 | 8 | sub is_valid { 9 | my $self = shift; 10 | my ($value) = @_; 11 | 12 | return 0 unless $value =~ m/^[[:alnum:],-: ]+$/; 13 | 14 | my @tags = grep { $_ ne '' && /\w/ } split /,/, $value; 15 | 16 | return 0 unless @tags && @tags <= 10 && !grep { length > 32 } @tags; 17 | 18 | return 1; 19 | } 20 | 21 | 1; 22 | -------------------------------------------------------------------------------- /lib/Threads/DB/DisposableEmailBlacklist.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::DisposableEmailBlacklist; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::DB'; 7 | 8 | __PACKAGE__->meta( 9 | table => 'disposable_email_blacklist', 10 | columns => [ 11 | qw/ 12 | id 13 | domain 14 | / 15 | ], 16 | primary_key => 'id', 17 | auto_increment => 'id', 18 | unique_keys => ['domain'], 19 | generate_columns_methods => 1, 20 | ); 21 | 22 | 1; 23 | -------------------------------------------------------------------------------- /t/validator/email.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | 7 | use Threads::Validator::Email; 8 | 9 | subtest 'return 0 when invalid' => sub { 10 | my $rule = _build_rule(); 11 | 12 | is $rule->is_valid('foo'), 0; 13 | }; 14 | 15 | subtest 'return 1 when valid' => sub { 16 | my $rule = _build_rule(); 17 | 18 | is $rule->is_valid('foo@bar.com'), 1; 19 | }; 20 | 21 | sub _build_rule { 22 | Threads::Validator::Email->new; 23 | } 24 | 25 | done_testing; 26 | -------------------------------------------------------------------------------- /t/validator/fake_field.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | 7 | use Threads::Validator::FakeField; 8 | 9 | subtest 'return 0 when invalid' => sub { 10 | my $rule = _build_rule(); 11 | 12 | is $rule->is_valid('present'), 0; 13 | }; 14 | 15 | subtest 'return 1 when valid' => sub { 16 | my $rule = _build_rule(); 17 | 18 | is $rule->is_valid(undef), 1; 19 | }; 20 | 21 | sub _build_rule { 22 | Threads::Validator::FakeField->new; 23 | } 24 | 25 | done_testing; 26 | -------------------------------------------------------------------------------- /lib/Threads/Validator/TooFast.pm: -------------------------------------------------------------------------------- 1 | package Threads::Validator::TooFast; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Validator::Base'; 7 | 8 | use Plack::Session; 9 | 10 | sub is_valid { 11 | my $self = shift; 12 | my ($value) = @_; 13 | 14 | my $env = $self->{args}->[0] || {}; 15 | 16 | my $session = Plack::Session->new($env); 17 | 18 | return 0 unless my $too_fast = $session->get('too_fast'); 19 | 20 | return 0 unless time - $too_fast > 1; 21 | 22 | return 1; 23 | } 24 | 25 | 1; 26 | -------------------------------------------------------------------------------- /lib/Threads/Action/AdminListUsers.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::AdminListUsers; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | sub run { 9 | my $self = shift; 10 | 11 | my $page = $self->req->param('page'); 12 | $page = 1 unless $page && $page =~ m/^\d+$/; 13 | 14 | my $page_size = 10; 15 | 16 | $self->set_var( 17 | params => { 18 | page => $page, 19 | page_size => $page_size 20 | } 21 | ); 22 | 23 | return; 24 | } 25 | 26 | 1; 27 | -------------------------------------------------------------------------------- /lib/Threads/Action/DeleteSubscriptions.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::DeleteSubscriptions; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action'; 7 | 8 | use Threads::DB::Subscription; 9 | 10 | sub run { 11 | my $self = shift; 12 | 13 | my $user = $self->scope->user; 14 | 15 | Threads::DB::Subscription->table->delete(where => [user_id => $user->id]); 16 | 17 | my $url = $self->url_for('list_subscriptions'); 18 | 19 | return $self->new_json_response(200, {redirect => "$url"}); 20 | } 21 | 22 | 1; 23 | -------------------------------------------------------------------------------- /lib/Threads/Validator/Email.pm: -------------------------------------------------------------------------------- 1 | package Threads::Validator::Email; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Validator::Base'; 7 | 8 | use Email::Valid (); 9 | 10 | sub is_valid { 11 | my $self = shift; 12 | my ($value) = @_; 13 | 14 | my $ok = Email::Valid->address( 15 | -address => $value, 16 | ($ENV{PLACK_ENV} || '') eq 'production' 17 | ? (-mxcheck => 1, -tldcheck => 1) 18 | : () 19 | ); 20 | 21 | return 0 unless $ok; 22 | 23 | return 1; 24 | } 25 | 26 | 1; 27 | -------------------------------------------------------------------------------- /t/validator/captcha.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | 7 | use Threads::Validator::Captcha; 8 | 9 | subtest 'return 0 when invalid' => sub { 10 | my $rule = _build_rule(args => ['expected']); 11 | 12 | is $rule->is_valid('got'), 0; 13 | }; 14 | 15 | subtest 'return 1 when valid' => sub { 16 | my $rule = _build_rule(args => ['expected']); 17 | 18 | is $rule->is_valid('expected'), 1; 19 | }; 20 | 21 | sub _build_rule { 22 | Threads::Validator::Captcha->new(@_); 23 | } 24 | 25 | done_testing; 26 | -------------------------------------------------------------------------------- /t/lib/TestMail.pm: -------------------------------------------------------------------------------- 1 | package TestMail; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use MIME::Base64 (); 7 | 8 | sub setup { 9 | unlink '/tmp/mailer.log'; 10 | } 11 | 12 | sub get_last_message { 13 | my $class = shift; 14 | 15 | my $mail = 16 | do { local $/; open my $fh, '<', '/tmp/mailer.log' or die $!; <$fh> }; 17 | 18 | if (my ($headers, $body) = $mail =~ m/^(.*?)\r?\n\r?\n(.*)$/ms) { 19 | ($body) = MIME::Base64::decode_base64($body); 20 | return $headers, $body; 21 | } 22 | 23 | return; 24 | } 25 | 26 | 1; 27 | -------------------------------------------------------------------------------- /t/validator/readable.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | 7 | use Threads::Validator::Readable; 8 | 9 | subtest 'return 0 when invalid' => sub { 10 | my $rule = _build_rule(); 11 | 12 | is $rule->is_valid(' '), 0; 13 | is $rule->is_valid(',,,---'), 0; 14 | }; 15 | 16 | subtest 'return 1 when valid' => sub { 17 | my $rule = _build_rule(); 18 | 19 | is $rule->is_valid(' fo o'), 1; 20 | }; 21 | 22 | sub _build_rule { 23 | Threads::Validator::Readable->new; 24 | } 25 | 26 | done_testing; 27 | -------------------------------------------------------------------------------- /lib/Threads/Validator/NotDisposableEmail.pm: -------------------------------------------------------------------------------- 1 | package Threads::Validator::NotDisposableEmail; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Validator::Base'; 7 | 8 | use Threads::DB::DisposableEmailBlacklist; 9 | 10 | sub is_valid { 11 | my $self = shift; 12 | my ($value) = @_; 13 | 14 | my (undef, $domain) = split /\@/, $value; 15 | 16 | return 0 17 | if Threads::DB::DisposableEmailBlacklist->find( 18 | first => 1, 19 | where => [domain => $domain] 20 | ); 21 | 22 | return 1; 23 | } 24 | 25 | 1; 26 | -------------------------------------------------------------------------------- /lib/Threads/Helper/User.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::User; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use Threads::DB::User; 9 | 10 | sub display_name { 11 | my $self = shift; 12 | my ($user) = @_; 13 | 14 | return '' unless $user; 15 | 16 | return 'deleted' if $user->{status} eq 'deleted'; 17 | 18 | return $user->{name}; 19 | } 20 | 21 | sub count { 22 | my $self = shift; 23 | my (%params) = @_; 24 | 25 | return Threads::DB::User->table->count(where => [status => 'active']); 26 | } 27 | 28 | 1; 29 | -------------------------------------------------------------------------------- /public/js/tags.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | $(document).ready(function() { 4 | $('input[name=tags]').tagsInput({ 5 | autocomplete_url:'/tags/autocomplete', 6 | width:'243px', 7 | height:'auto', 8 | maxChars: 32, 9 | defaultText:'' 10 | }); 11 | 12 | $('div.tagsinput input').focus(function() { 13 | $(this).closest('div.tagsinput').addClass('glow'); 14 | }).focusout(function() { 15 | $(this).closest('div.tagsinput').removeClass('glow'); 16 | }); 17 | }); 18 | })(); 19 | -------------------------------------------------------------------------------- /lib/Threads/Action/ListNotifications.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ListNotifications; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | sub run { 9 | my $self = shift; 10 | 11 | my $page = $self->req->param('page'); 12 | $page = 1 unless $page && $page =~ m/^\d+$/; 13 | 14 | my $page_size = $self->service('config')->{pagers}->{notifications} || 10; 15 | 16 | $self->set_var( 17 | params => { 18 | page => $page, 19 | page_size => $page_size 20 | } 21 | ); 22 | 23 | return; 24 | } 25 | 26 | 1; 27 | -------------------------------------------------------------------------------- /lib/Threads/Action/ListSubscriptions.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ListSubscriptions; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | sub run { 9 | my $self = shift; 10 | 11 | my $page = $self->req->param('page'); 12 | $page = 1 unless $page && $page =~ m/^\d+$/; 13 | 14 | my $page_size = $self->service('config')->{pagers}->{subscriptions} || 10; 15 | 16 | $self->set_var( 17 | params => { 18 | page => $page, 19 | page_size => $page_size 20 | } 21 | ); 22 | 23 | return; 24 | } 25 | 26 | 1; 27 | -------------------------------------------------------------------------------- /t/validator/min_length.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | 7 | use Threads::Validator::MinLength; 8 | 9 | subtest 'return 0 when invalid' => sub { 10 | my $rule = _build_rule(); 11 | 12 | is $rule->is_valid('', 3), 0; 13 | is $rule->is_valid('12', 3), 0; 14 | }; 15 | 16 | subtest 'return 1 when valid' => sub { 17 | my $rule = _build_rule(); 18 | 19 | is $rule->is_valid('123', 3), 1; 20 | is $rule->is_valid('1234', 3), 1; 21 | }; 22 | 23 | sub _build_rule { 24 | Threads::Validator::MinLength->new; 25 | } 26 | 27 | done_testing; 28 | -------------------------------------------------------------------------------- /t/validator/max_length.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | 7 | use Threads::Validator::MaxLength; 8 | 9 | subtest 'return 0 when invalid' => sub { 10 | my $rule = _build_rule(); 11 | 12 | is $rule->is_valid('1234', 3), 0; 13 | is $rule->is_valid('12345', 3), 0; 14 | }; 15 | 16 | subtest 'return 1 when valid' => sub { 17 | my $rule = _build_rule(); 18 | 19 | is $rule->is_valid('123', 3), 1; 20 | is $rule->is_valid('12', 3), 1; 21 | }; 22 | 23 | sub _build_rule { 24 | Threads::Validator::MaxLength->new; 25 | } 26 | 27 | done_testing; 28 | -------------------------------------------------------------------------------- /lib/Threads/Action/JSONMixin.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::JSONMixin; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Exporter'; 7 | 8 | our @EXPORT_OK = qw(new_json_response); 9 | 10 | use JSON (); 11 | 12 | sub new_json_response { 13 | my $self = shift; 14 | my ($status, $perl) = @_; 15 | 16 | my $json = JSON::encode_json($perl || {}); 17 | 18 | return $self->new_response( 19 | $status, 20 | [ 21 | 'Content-Type' => 'application/json', 22 | 'Content-Length' => length($json) 23 | ], 24 | $json 25 | ); 26 | } 27 | 28 | 1; 29 | -------------------------------------------------------------------------------- /templates/resend_registration_confirmation.apl: -------------------------------------------------------------------------------- 1 | % $helpers->meta->set(title => loc('Resend registration confirmation')); 2 | 3 |
4 | 5 |

<%= loc('Resend registration confirmation') %>

6 | 7 |
8 | 9 | <%== $helpers->form->input('email', label => 'E-mail') %> 10 | <%== $helpers->form->password('password', label => loc('Password')) %> 11 | 12 |
13 | 14 |
15 | 16 |
17 | 18 |
19 | -------------------------------------------------------------------------------- /templates/change_password.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%= loc('Change password') %>

4 | 5 |
6 | 7 | <%== $helpers->form->password('old_password', label => loc('Current password')) %> 8 | <%== $helpers->form->password('new_password', label => loc('New password')) %> 9 | <%== $helpers->form->password('new_password_confirmation', label => loc('Repeat new password')) %> 10 | 11 |
12 | 13 |
14 | 15 |
16 | 17 |
18 | -------------------------------------------------------------------------------- /util/update-thread-slug-ascii.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use FindBin '$RealBin'; 7 | 8 | binmode STDOUT, ':utf8'; 9 | 10 | BEGIN { 11 | unshift @INC, "$RealBin/../lib"; 12 | unshift @INC, "$_/lib" for glob "$RealBin/../contrib/*"; 13 | } 14 | 15 | use Tu::Config; 16 | use Threads::DB; 17 | use Threads::DB::Thread; 18 | 19 | my $config = Tu::Config->new(mode => 1)->load("$RealBin/../config/config.yml"); 20 | Threads::DB->init_db(%{$config->{database}}); 21 | 22 | my @threads = Threads::DB::Thread->find; 23 | foreach my $thread (@threads) { 24 | $thread->update; 25 | } 26 | -------------------------------------------------------------------------------- /t/helper/user.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | 7 | use Threads::Helper::User; 8 | 9 | subtest 'returns name when available' => sub { 10 | my $helper = _build_helper(); 11 | 12 | is $helper->display_name({name => 'foo', status => 'active'}), 'foo'; 13 | }; 14 | 15 | subtest 'returns deleted when user deleted' => sub { 16 | my $helper = _build_helper(); 17 | 18 | is $helper->display_name({status => 'deleted'}), 'deleted'; 19 | }; 20 | 21 | my $env = {}; 22 | 23 | sub _build_helper { 24 | Threads::Helper::User->new(env => $env); 25 | } 26 | 27 | done_testing; 28 | -------------------------------------------------------------------------------- /templates/settings.apl: -------------------------------------------------------------------------------- 1 | % $helpers->meta->set(title => loc('Settings')); 2 | 3 |
4 | 5 |

<%= loc('Settings') %>

6 | 7 |
8 | 9 |
10 |
11 | 12 | <%== $helpers->form->input('email_notifications', type => 'checkbox', label => loc('Email notifications'), default => $user->{email_notifications}) %> 13 | 14 |
15 | 16 |
17 | 18 |
19 | 20 |
21 | -------------------------------------------------------------------------------- /lib/Threads/Action/DeleteNotifications.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::DeleteNotifications; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action'; 7 | 8 | use Threads::DB::Notification; 9 | 10 | sub run { 11 | my $self = shift; 12 | 13 | my $user = $self->scope->user; 14 | 15 | my $id = $self->req->param('id'); 16 | 17 | Threads::DB::Notification->table->delete( 18 | where => [ 19 | user_id => $user->id, 20 | $id ? (id => $id) : () 21 | ] 22 | ); 23 | 24 | my $url = $self->url_for('list_notifications'); 25 | 26 | return $self->new_json_response(200, {redirect => "$url"}); 27 | } 28 | 29 | 1; 30 | -------------------------------------------------------------------------------- /lib/Threads/DB/Subscription.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::Subscription; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::DB'; 7 | 8 | __PACKAGE__->meta( 9 | table => 'subscriptions', 10 | columns => [ 11 | qw/ 12 | id 13 | created 14 | user_id 15 | thread_id 16 | / 17 | ], 18 | primary_key => 'id', 19 | auto_increment => 'id', 20 | generate_columns_methods => 1, 21 | relationships => { 22 | thread => { 23 | type => 'many to one', 24 | class => 'Threads::DB::Thread', 25 | map => {thread_id => 'id'} 26 | }, 27 | } 28 | ); 29 | 30 | 1; 31 | -------------------------------------------------------------------------------- /lib/Threads/LimitChecker.pm: -------------------------------------------------------------------------------- 1 | package Threads::LimitChecker; 2 | 3 | use strict; 4 | use warnings; 5 | use attrs; 6 | 7 | sub check { 8 | my $self = shift; 9 | my ($limits, $user, $db) = @_; 10 | 11 | return 0 unless $limits && ref $limits eq 'HASH'; 12 | 13 | foreach my $time (keys %$limits) { 14 | my $limit = $limits->{$time}; 15 | 16 | my $count = $db->table->count( 17 | where => [ 18 | created => {'>=' => time - $time}, 19 | user_id => $user->id 20 | ] 21 | ); 22 | 23 | if ($count >= $limit) { 24 | return 1; 25 | } 26 | } 27 | 28 | return 0; 29 | } 30 | 31 | 1; 32 | -------------------------------------------------------------------------------- /lib/Threads/Helper/AdminUser.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::AdminUser; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use Threads::DB::User; 9 | 10 | sub find { 11 | my $self = shift; 12 | my (%params) = @_; 13 | 14 | my $page = $self->param('page') || 1; 15 | my $page_size = $self->param('page_size') || 10; 16 | 17 | return map { $_->to_hash } Threads::DB::User->find( 18 | page => $page, 19 | page_size => $page_size, 20 | order_by => [id => 'DESC'] 21 | ); 22 | } 23 | 24 | sub count { 25 | my $self = shift; 26 | my (%params) = @_; 27 | 28 | return Threads::DB::User->table->count; 29 | } 30 | 31 | 1; 32 | -------------------------------------------------------------------------------- /util/create-user.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use FindBin '$RealBin'; 7 | 8 | BEGIN { 9 | unshift @INC, "$RealBin/../lib"; 10 | unshift @INC, "$_/lib" for glob "$RealBin/../contrib/*"; 11 | } 12 | 13 | use Tu::Config; 14 | use Threads::DB; 15 | use Threads::DB::User; 16 | 17 | my ($email, $role, $password) = @ARGV; 18 | die 'Usage: ' unless $email && $password; 19 | 20 | my $config = Tu::Config->new(mode => 1)->load("$RealBin/../config/config.yml"); 21 | Threads::DB->init_db(%{$config->{database}}); 22 | 23 | Threads::DB::User->new( 24 | email => $email, 25 | role => $role, 26 | password => $password, 27 | status => 'active' 28 | )->create; 29 | -------------------------------------------------------------------------------- /lib/Threads/DB/Notification.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::Notification; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::DB'; 7 | 8 | __PACKAGE__->meta( 9 | table => 'notifications', 10 | columns => [ 11 | qw/ 12 | id 13 | created 14 | user_id 15 | reply_id 16 | is_sent 17 | / 18 | ], 19 | primary_key => 'id', 20 | auto_increment => 'id', 21 | unique_keys => [qw/user_id reply_id/], 22 | generate_columns_methods => 1, 23 | relationships => { 24 | reply => { 25 | type => 'many to one', 26 | class => 'Threads::DB::Reply', 27 | map => {reply_id => 'id'} 28 | }, 29 | } 30 | ); 31 | 32 | 1; 33 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Gravatar.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Gravatar; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use Digest::MD5 qw(md5_hex); 9 | 10 | sub img { 11 | my $self = shift; 12 | my ($user, $size) = @_; 13 | 14 | $size ||= 40; 15 | 16 | if ( $user 17 | && $user->{status} ne 'deleted' 18 | && $ENV{PLACK_ENV} 19 | && $ENV{PLACK_ENV} eq 'production') 20 | { 21 | my $email = $user->{email}; 22 | 23 | my $hash = md5_hex lc $email; 24 | 25 | return 26 | qq{}; 27 | } 28 | else { 29 | return qq{}; 30 | } 31 | } 32 | 33 | 1; 34 | -------------------------------------------------------------------------------- /tjs/suite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | QUnit basic example 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /t/validator/tags.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | 7 | use Threads::Validator::Tags; 8 | 9 | subtest 'return 0 when invalid' => sub { 10 | my $rule = _build_rule(); 11 | 12 | is $rule->is_valid(' '), 0; 13 | is $rule->is_valid(',,,---'), 0; 14 | is $rule->is_valid('hi!'), 0; 15 | is $rule->is_valid('1,2,3,4,5,6,7,8,9,10,11'), 0; 16 | is $rule->is_valid('1' x 100), 0; 17 | }; 18 | 19 | subtest 'return 1 when valid' => sub { 20 | my $rule = _build_rule(); 21 | 22 | is $rule->is_valid('foo, bar, baz'), 1; 23 | is $rule->is_valid('dbix::class'), 1; 24 | is $rule->is_valid('hell-there'), 1; 25 | }; 26 | 27 | sub _build_rule { 28 | Threads::Validator::Tags->new; 29 | } 30 | 31 | done_testing; 32 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Date.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Date; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use Time::Moment; 9 | 10 | sub format { 11 | my $self = shift; 12 | my ($epoch) = @_; 13 | 14 | return Time::Moment->from_epoch($epoch)->strftime('%Y-%m-%d %H:%M'); 15 | } 16 | 17 | sub format_rss { 18 | my $self = shift; 19 | my ($epoch) = @_; 20 | 21 | return Time::Moment->from_epoch($epoch)->strftime('%a, %d %b %Y %T GMT'); 22 | } 23 | 24 | sub is_distant_update { 25 | my $self = shift; 26 | my ($object) = @_; 27 | 28 | my $created = $object->{created}; 29 | my $updated = $object->{updated}; 30 | 31 | return 0 unless $updated; 32 | 33 | return $updated - $created > 15 * 60; 34 | } 35 | 36 | 1; 37 | -------------------------------------------------------------------------------- /util/update-po-files.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use File::Copy (); 7 | use Locale::Maketext::Extract::Run 'xgettext'; 8 | 9 | my ($dir) = @ARGV; 10 | $dir ||= 'locale'; 11 | die 'Usage: ' unless $dir && -d $dir; 12 | 13 | xgettext('-D', 'lib', '-D', 'templates'); 14 | 15 | my $messages = 16 | do { local $/; open my $fh, '<', 'messages.po' or die $!; <$fh> }; 17 | $messages =~ s{^.*?\n\n}{}ms; 18 | open my $fh, '>', 'messages.po' or die $!; 19 | print $fh $messages; 20 | close $fh; 21 | 22 | my @files = glob "$dir/*.po"; 23 | for my $file (@files) { 24 | File::Copy::move($file, "$file.bak"); 25 | system("msgmerge $file.bak messages.po | msguniq > $file"); 26 | unlink "$file.bak"; 27 | } 28 | 29 | unlink 'messages.po'; 30 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "contrib/tu"] 2 | path = contrib/tu 3 | url = http://github.com/vti/tu 4 | [submodule "contrib/sql-composer"] 5 | path = contrib/sql-composer 6 | url = http://github.com/vti/sql-composer 7 | [submodule "contrib/object-db"] 8 | path = contrib/object-db 9 | url = http://github.com/vti/object-db 10 | [submodule "contrib/attrs"] 11 | path = contrib/attrs 12 | url = http://github.com/vti/attrs 13 | [submodule "contrib/antibot"] 14 | path = contrib/antibot 15 | url = http://github.com/vti/plack-middleware-antibot 16 | [submodule "contrib/plack-i18n"] 17 | path = contrib/plack-i18n 18 | url = http://github.com/vti/plack-i18n 19 | [submodule "contrib/plack-app-eventsource"] 20 | path = contrib/plack-app-eventsource 21 | url = http://github.com/vti/plack-app-eventsource 22 | -------------------------------------------------------------------------------- /t/validator/too_fast.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | 7 | use Threads::Validator::TooFast; 8 | 9 | subtest 'return 0 when no session' => sub { 10 | my $rule = _build_rule(args => [{'psgix.session' => {}}]); 11 | 12 | is $rule->is_valid('value'), 0; 13 | }; 14 | 15 | subtest 'return 0 when too fast' => sub { 16 | my $rule = _build_rule(args => [{'psgix.session' => {too_fast => time}}]); 17 | 18 | is $rule->is_valid('value'), 0; 19 | }; 20 | 21 | subtest 'return 1 when not too fast' => sub { 22 | my $rule = _build_rule(args => [{'psgix.session' => {too_fast => 123}}]); 23 | 24 | is $rule->is_valid('value'), 1; 25 | }; 26 | 27 | sub _build_rule { 28 | Threads::Validator::TooFast->new(@_); 29 | } 30 | 31 | done_testing; 32 | -------------------------------------------------------------------------------- /lib/Threads/DB/Nonce.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::Nonce; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::DB'; 7 | 8 | use Time::HiRes qw(time); 9 | 10 | __PACKAGE__->meta( 11 | table => 'nonces', 12 | columns => [ 13 | qw/ 14 | id 15 | user_id 16 | created 17 | / 18 | ], 19 | primary_key => 'id', 20 | auto_increment => 'id', 21 | generate_columns_methods => 1, 22 | ); 23 | 24 | sub create { 25 | my $self = shift; 26 | 27 | $self->id($self->_generate_id); 28 | 29 | return $self->SUPER::create; 30 | } 31 | 32 | sub _generate_id { 33 | my $class = shift; 34 | 35 | my $id = sprintf '%.04f', time - 1396607860; 36 | 37 | $id =~ s{\.}{}; 38 | 39 | return $id; 40 | } 41 | 42 | 1; 43 | -------------------------------------------------------------------------------- /t/validator/not_disposable_email.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestDB; 6 | use TestLib; 7 | 8 | use Threads::DB::DisposableEmailBlacklist; 9 | use Threads::Validator::NotDisposableEmail; 10 | 11 | subtest 'return 0 when invalid' => sub { 12 | TestDB->setup; 13 | 14 | Threads::DB::DisposableEmailBlacklist->new(domain => 'mailinator.com') 15 | ->create; 16 | 17 | my $rule = _build_rule(); 18 | 19 | is $rule->is_valid('foo@mailinator.com'), 0; 20 | }; 21 | 22 | subtest 'return 1 when valid' => sub { 23 | TestDB->setup; 24 | 25 | my $rule = _build_rule(); 26 | 27 | is $rule->is_valid('foo@mailinator.com'), 1; 28 | }; 29 | 30 | sub _build_rule { 31 | Threads::Validator::NotDisposableEmail->new; 32 | } 33 | 34 | done_testing; 35 | -------------------------------------------------------------------------------- /templates/login.apl: -------------------------------------------------------------------------------- 1 | % $helpers->meta->set(title => loc('Authorization')); 2 | 3 |
4 | 5 |

<%= loc('Authorization') %>

6 | 7 |
8 | 9 | <%== $helpers->form->input('email', label => 'E-mail') %> 10 | <%== $helpers->form->password('password', label => loc('Password')) %> 11 | 12 | 13 | 14 |
15 | 16 |
17 | 18 |
19 | 20 |
21 | -------------------------------------------------------------------------------- /lib/Threads/Action/Settings.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::Settings; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action::FormBase'; 7 | 8 | use Threads::DB::User; 9 | use Threads::Action::TranslateMixin 'loc'; 10 | 11 | sub build_validator { 12 | my $self = shift; 13 | 14 | my $validator = $self->SUPER::build_validator; 15 | 16 | $validator->add_optional_field('email_notifications'); 17 | 18 | return $validator; 19 | } 20 | 21 | sub submit { 22 | my $self = shift; 23 | my ($params) = @_; 24 | 25 | my $user = $self->env->{'tu.user'}; 26 | 27 | $params->{email_notifications} = 1 if $params->{email_notifications}; 28 | 29 | $user->set_columns(%$params); 30 | $user->update; 31 | 32 | return $self->redirect('index'); 33 | } 34 | 35 | 1; 36 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Antibot.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Antibot; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use base 'Tu::Helper'; 7 | 8 | sub fake_field { 9 | my $self = shift; 10 | 11 | return $self->{env}->{'plack.antibot.fakefield.html'} || ''; 12 | } 13 | 14 | sub static { 15 | my $self = shift; 16 | 17 | return $self->{env}->{'plack.antibot.static.html'} || ''; 18 | } 19 | 20 | sub captcha { 21 | my $self = shift; 22 | 23 | my $env = $self->{env}; 24 | my $captcha = { 25 | text => $env->{'plack.antibot.textcaptcha.text'} || '', 26 | field_name => $env->{'plack.antibot.textcaptcha.field_name'} || '' 27 | }; 28 | 29 | return unless $captcha->{text} && $captcha->{field_name}; 30 | 31 | return $captcha; 32 | } 33 | 34 | 1; 35 | -------------------------------------------------------------------------------- /lib/Threads/Job/CleanupInactiveRegistrations.pm: -------------------------------------------------------------------------------- 1 | package Threads::Job::CleanupInactiveRegistrations; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Job::Base'; 7 | 8 | use Threads::DB::User; 9 | 10 | sub run { 11 | my $self = shift; 12 | 13 | my $week_ago = time - 7 * 24 * 3600; 14 | 15 | my @inactive_users = 16 | Threads::DB::User->find( 17 | where => [status => 'new', created => {'<=' => $week_ago}]); 18 | print "Nothing to remove\n" if $self->_is_verbose && !@inactive_users; 19 | 20 | foreach my $user (@inactive_users) { 21 | if ($self->_is_verbose) { 22 | print 'Deleting ' . $user->id, "\n"; 23 | } 24 | 25 | $user->delete unless $self->{dry_run}; 26 | } 27 | 28 | return $self; 29 | } 30 | 31 | 1; 32 | -------------------------------------------------------------------------------- /t/action/preview.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use Test::Fatal; 6 | use TestLib; 7 | use TestRequest; 8 | 9 | use JSON qw(decode_json); 10 | use HTTP::Request::Common; 11 | use Threads::Action::Preview; 12 | 13 | subtest 'renders content' => sub { 14 | my $action = _build_action(req => POST('/' => {content => '**bold**'})); 15 | 16 | my $res = $action->run; 17 | 18 | is_deeply decode_json $res->body, 19 | {content => '

bold

'}; 20 | }; 21 | 22 | sub _build_action { 23 | my (%params) = @_; 24 | 25 | my $env = $params{env} || TestRequest->to_env(%params); 26 | 27 | my $action = Threads::Action::Preview->new(env => $env); 28 | $action = Test::MonkeyMock->new($action); 29 | 30 | return $action; 31 | } 32 | 33 | done_testing; 34 | -------------------------------------------------------------------------------- /lib/Threads/Action/ReadReply.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ReadReply; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action'; 7 | 8 | use Threads::DB::Reply; 9 | use Threads::DB::Notification; 10 | 11 | sub run { 12 | my $self = shift; 13 | 14 | my $reply_id = $self->captures->{id}; 15 | 16 | return $self->new_json_response(404) 17 | unless my $reply = Threads::DB::Reply->new(id => $reply_id)->load; 18 | 19 | my $user = $self->scope->user; 20 | 21 | Threads::DB::Notification->table->delete( 22 | where => [user_id => $user->id, reply_id => $reply->id]); 23 | 24 | my $unread_count = 25 | Threads::DB::Notification->table->count(where => [user_id => $user->id]); 26 | 27 | return $self->new_json_response(200, {count => $unread_count}); 28 | } 29 | 30 | 1; 31 | -------------------------------------------------------------------------------- /lib/Threads/DB/MapThreadTag.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::MapThreadTag; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::DB'; 7 | 8 | __PACKAGE__->meta( 9 | table => 'map_thread_tag', 10 | columns => [ 11 | qw/ 12 | thread_id 13 | tag_id 14 | / 15 | ], 16 | primary_key => [qw/thread_id tag_id/], 17 | generate_columns_methods => 1, 18 | relationships => { 19 | thread => { 20 | type => 'many to one', 21 | class => 'Threads::DB::Thread', 22 | map => {thread_id => 'id'} 23 | }, 24 | tag => { 25 | type => 'many to one', 26 | class => 'Threads::DB::Tag', 27 | map => {tag_id => 'id'} 28 | }, 29 | } 30 | ); 31 | 32 | 1; 33 | -------------------------------------------------------------------------------- /cpanfile: -------------------------------------------------------------------------------- 1 | requires 'DBD::SQLite'; 2 | requires 'Digest::SHA'; 3 | requires 'Email::MIME'; 4 | requires 'Email::Valid'; 5 | requires 'HTML::Truncate'; 6 | requires 'I18N::AcceptLanguage'; 7 | requires 'JSON'; 8 | requires 'Locale::Maketext::Extract::Run'; 9 | requires 'Math::Random::ISAAC'; 10 | requires 'Net::Domain::TLD'; 11 | requires 'ObjectDB'; 12 | requires 'Plack'; 13 | requires 'Plack::Middleware::ReverseProxy'; 14 | requires 'Plack::Session'; 15 | requires 'Routes::Tiny'; 16 | requires 'String::CamelCase'; 17 | requires 'Text::APL'; 18 | requires 'Text::Unidecode'; 19 | requires 'Time::Moment'; 20 | requires 'YAML::Tiny'; 21 | 22 | requires 'AnyEvent'; 23 | requires 'Twiggy'; 24 | 25 | on 'test' => sub { 26 | requires 'Test::More'; 27 | requires 'Test::WWW::Mechanize::PSGI'; 28 | requires 'Test::MonkeyMock'; 29 | }; 30 | -------------------------------------------------------------------------------- /lib/Threads/DB/Report.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::Report; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use base 'Threads::DB'; 7 | 8 | __PACKAGE__->meta( 9 | table => 'reports', 10 | columns => [ 11 | qw/ 12 | id 13 | created 14 | user_id 15 | reply_id 16 | / 17 | ], 18 | primary_key => 'id', 19 | auto_increment => 'id', 20 | generate_columns_methods => 1, 21 | relationships => { 22 | reply => { 23 | type => 'many to one', 24 | class => 'Threads::DB::Reply', 25 | map => {parent_id => 'id'} 26 | }, 27 | user => { 28 | type => 'many to one', 29 | class => 'Threads::DB::User', 30 | map => {user_id => 'id'} 31 | } 32 | } 33 | ); 34 | 35 | 1; 36 | -------------------------------------------------------------------------------- /util/import-disposable-emails.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use FindBin '$RealBin'; 7 | 8 | BEGIN { 9 | unshift @INC, "$RealBin/../lib"; 10 | unshift @INC, "$_/lib" for glob "$RealBin/../contrib/*"; 11 | } 12 | 13 | use Tu::Config; 14 | use Threads::DB; 15 | use Threads::DB::DisposableEmailBlacklist; 16 | 17 | my ($file) = @ARGV; 18 | die 'Usage: ' unless $file && -f $file; 19 | 20 | my $config = 21 | Tu::Config->new(mode => 1)->load("$RealBin/../config/config.yml"); 22 | Threads::DB->init_db(%{$config->{database}}); 23 | 24 | Threads::DB::DisposableEmailBlacklist->table->delete; 25 | 26 | open my $fh, '<', $file or die $!; 27 | while (defined (my $line = <$fh>)) { 28 | chomp $line; 29 | 30 | Threads::DB::DisposableEmailBlacklist->new(domain => $line)->create; 31 | } 32 | 33 | -------------------------------------------------------------------------------- /public/unsemantic/js/adapt.min.js: -------------------------------------------------------------------------------- 1 | (function(a,b,c,d){function e(){clearTimeout(i);for(var c=a.innerWidth||b.documentElement.clientWidth||b.body.clientWidth||0,e,f,o,p,q=m,u=m-1;q--;){g="",e=l[q].split("="),f=e[0],p=e[1]?e[1].replace(/\s/g,""):d,e=(o=f.match("to"))?parseInt(f.split("to")[0],10):parseInt(f,10),f=o?parseInt(f.split("to")[1],10):d;if(!f&&q===u&&c>e||c>e&&c<=f){p&&(g=k+p);break}}h?h!==g&&(h=n.href=g,j&&j(q,c)):(h=n.href=g,j&&j(q,c),k&&(b.head||b.getElementsByTagName("head")[0]).appendChild(n))}function f(){clearTimeout(i),i=setTimeout(e,16)}if(c){var g,h,i,j=typeof c.callback=="function"?c.callback:d,k=c.path?c.path:"",l=c.range,m=l.length,n=b.createElement("link");n.rel="stylesheet",n.media="screen",e(),c.dynamic&&(a.addEventListener?a.addEventListener("resize",f,!1):a.attachEvent?a.attachEvent("onresize",f):a.onresize=f)}})(this,this.document,ADAPT_CONFIG) -------------------------------------------------------------------------------- /lib/Threads/Action/AdminToggleBlocked.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::AdminToggleBlocked; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | use Threads::DB::User; 9 | 10 | sub run { 11 | my $self = shift; 12 | 13 | my $user_id = $self->captures->{id}; 14 | return $self->throw_not_found 15 | unless my $user = Threads::DB::User->new(id => $user_id)->load; 16 | 17 | return $self->throw_not_found if $user->id == $self->scope->user->id; 18 | 19 | return $self->throw_not_found 20 | unless grep { $user->status eq $_ } qw(active blocked); 21 | 22 | if ($user->status eq 'active') { 23 | $user->status('blocked'); 24 | } 25 | else { 26 | $user->status('active'); 27 | } 28 | 29 | $user->update; 30 | 31 | return $self->redirect('admin_list_users'); 32 | } 33 | 34 | 1; 35 | -------------------------------------------------------------------------------- /lib/Threads/Action/ThreadsRss.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ThreadsRss; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | use List::Util qw(first); 9 | use Threads::DB::Tag; 10 | 11 | sub run { 12 | my $self = shift; 13 | 14 | my $tag = $self->req->param('tag'); 15 | $self->throw_not_found 16 | if $tag && !Threads::DB::Tag->new(title => $tag)->load; 17 | 18 | $self->set_var( 19 | params => { 20 | tag => $tag, 21 | page => 1, 22 | page_size => 100 23 | } 24 | ); 25 | 26 | my $rss = $self->render('threads_rss', layout => undef); 27 | 28 | my $res = $self->req->new_response(200); 29 | $res->header('Content-Type' => 'application/rss+xml; charset=utf-8'); 30 | $res->body($rss); 31 | 32 | return $res; 33 | } 34 | 35 | 1; 36 | -------------------------------------------------------------------------------- /lib/Threads/Action/AutocompleteTags.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::AutocompleteTags; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action'; 7 | 8 | use Threads::DB::Tag; 9 | 10 | sub run { 11 | my $self = shift; 12 | 13 | my $q = $self->req->param('term') || ''; 14 | return $self->new_json_response(200, []) unless $q; 15 | 16 | my @tags = Threads::DB::Tag->find( 17 | '+columns' => 18 | [{-col => \'COUNT(map_thread_tag.tag_id)', -as => 'count'}], 19 | where => [title => {'like' => "%$q%"}], 20 | order_by => [\'count' => 'DESC'], 21 | group_by => 'id', 22 | with => 'map_thread_tag', 23 | limit => 10 24 | ); 25 | 26 | return $self->new_json_response(200, 27 | [map { $_->title } grep { $_->get_column('count') > 0 } @tags]); 28 | } 29 | 30 | 1; 31 | -------------------------------------------------------------------------------- /lib/Threads/Job/CleanupThreadViews.pm: -------------------------------------------------------------------------------- 1 | package Threads::Job::CleanupThreadViews; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Job::Base'; 7 | 8 | use Threads::DB::View; 9 | 10 | sub run { 11 | my $self = shift; 12 | 13 | my $week_ago = time - 7 * 24 * 3600; 14 | 15 | my $views_to_remove = 16 | Threads::DB::View->table->count( 17 | where => [created => {'<=' => $week_ago}]); 18 | 19 | if ($views_to_remove) { 20 | print 'Deleting ' . $views_to_remove . ' view(s)' . "\n" 21 | if $self->_is_verbose; 22 | 23 | Threads::DB::View->table->delete( 24 | where => [created => {'<=' => $week_ago}]) 25 | unless $self->{dry_run}; 26 | } 27 | else { 28 | print "Nothing to remove\n" if $self->_is_verbose; 29 | } 30 | 31 | return $self; 32 | } 33 | 34 | 1; 35 | -------------------------------------------------------------------------------- /lib/Threads/Action/DeleteThread.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::DeleteThread; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | use Threads::ObjectACL; 9 | use Threads::DB::User; 10 | use Threads::DB::Thread; 11 | use Threads::DB::Subscription; 12 | 13 | sub run { 14 | my $self = shift; 15 | 16 | my $thread_id = $self->captures->{id}; 17 | 18 | return $self->throw_not_found 19 | unless my $thread = Threads::DB::Thread->new(id => $thread_id)->load; 20 | 21 | my $user = $self->scope->user; 22 | 23 | return $self->throw_not_found 24 | unless Threads::ObjectACL->new->is_allowed($user, $thread, 25 | 'delete_thread'); 26 | 27 | Threads::DB::Subscription->table->delete( 28 | where => [thread_id => $thread->id]); 29 | 30 | $thread->delete; 31 | 32 | return $self->redirect('index'); 33 | } 34 | 35 | 1; 36 | -------------------------------------------------------------------------------- /templates/register.apl: -------------------------------------------------------------------------------- 1 | % $helpers->meta->set(title => loc('Registration')); 2 | 3 |
4 | 5 |

<%= loc('Registration') %>

6 | 7 |
8 | 9 | <%== $helpers->form->input('name', label => loc('Username'), help => loc('Only [_1]', 'a-z, A-Z, 0-9, -, _')) %> 10 | <%== $helpers->form->input('email', label => 'E-mail') %> 11 | <%== $helpers->form->password('password', label => loc('Password')) %> 12 | 13 | % if (my $captcha = $helpers->antibot->captcha) { 14 | <%== $helpers->form->input($captcha->{field_name}, label => $captcha->{text}) %> 15 | % } 16 | 17 | %== $helpers->antibot->fake_field; 18 | 19 |
20 | 21 |
22 | 23 |
24 | 25 |
26 | 27 | %== $helpers->antibot->static; 28 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Url.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Url; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use URI::Escape qw(uri_escape_utf8); 9 | 10 | sub DESTROY { } 11 | 12 | our $AUTOLOAD; 13 | 14 | sub AUTOLOAD { 15 | my $self = shift; 16 | 17 | my $method = $AUTOLOAD; 18 | 19 | return if $method =~ /^[A-Z]+?$/; 20 | return if $method =~ /^_/; 21 | 22 | $method = (split /::/ => $method)[-1]; 23 | 24 | my $language = $self->{env}->{'plack.i18n.language'}; 25 | my $i18n = $self->{services}->service('i18n'); 26 | 27 | my $url = join '/', map { uri_escape_utf8($_) } split '/', 28 | $self->service('routes')->build_path($method, @_); 29 | $url = '/' if $url eq ''; 30 | 31 | if ($language && $language ne $i18n->default_language) { 32 | $url = '/' . $language . $url; 33 | } 34 | 35 | return $url; 36 | } 37 | 38 | 1; 39 | -------------------------------------------------------------------------------- /lib/Threads/DB/Tag.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::Tag; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use base 'Threads::DB'; 7 | 8 | __PACKAGE__->meta( 9 | table => 'tags', 10 | columns => [ 11 | qw/ 12 | id 13 | title 14 | / 15 | ], 16 | primary_key => 'id', 17 | auto_increment => 'id', 18 | unique_keys => 'title', 19 | generate_columns_methods => 1, 20 | relationships => { 21 | map_thread_tag => { 22 | type => 'one to many', 23 | class => 'Threads::DB::MapThreadTag', 24 | map => {id => 'tag_id'} 25 | }, 26 | threads => { 27 | type => 'many to many', 28 | map_class => 'Threads::DB::MapThreadTag', 29 | map_from => 'tag', 30 | map_to => 'thread' 31 | }, 32 | } 33 | ); 34 | 35 | 1; 36 | -------------------------------------------------------------------------------- /lib/Threads/Action/ConfirmRegistration.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ConfirmRegistration; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | use Threads::DB::User; 9 | use Threads::DB::Confirmation; 10 | 11 | sub run { 12 | my $self = shift; 13 | 14 | my $token = $self->captures->{token}; 15 | $self->throw_not_found unless $token; 16 | 17 | $self->throw_not_found 18 | unless my $confirmation = 19 | Threads::DB::Confirmation->find_by_token($token, 'register'); 20 | 21 | if ($confirmation->is_expired) { 22 | return $self->render('activation_failure'); 23 | } 24 | 25 | $self->throw_not_found 26 | unless my $user = 27 | Threads::DB::User->new(id => $confirmation->user_id)->load; 28 | 29 | $user->status('active'); 30 | $user->save; 31 | 32 | $confirmation->delete; 33 | 34 | return $self->render('activation_success'); 35 | } 36 | 37 | 1; 38 | -------------------------------------------------------------------------------- /config/config.yml.example: -------------------------------------------------------------------------------- 1 | --- 2 | base_url: 'http://yourwebsite.com' 3 | meta: 4 | title: 'My forum' 5 | description: 'The best forum on earth!' 6 | pagers: 7 | threads: 10 8 | subscriptions: 10 9 | notifications: 10 10 | limits: 11 | threads: 12 | 60: 2 13 | 3600: 5 14 | 86400: 10 15 | replies: 16 | 60: 2 17 | 3600: 5 18 | 86400: 20 19 | session: 20 | secret: '123' 21 | captcha: 22 | - text: '2 + 2 = ?' 23 | answer: '4' 24 | - text: '6 * 3 = ?' 25 | answer: '18' 26 | database: 27 | dsn: 'dbi:SQLite:db.db' 28 | attrs: 29 | RaiseError: 1 30 | sqlite_unicode: 1 31 | i18n: 32 | default_language: 'en' 33 | languages: 34 | - 'en' 35 | mailer: 36 | subject_prefix: '[Threads]' 37 | headers: 38 | - 'From' 39 | - 'foo@bar.com' 40 | transport: 41 | name: 'sendmail' 42 | path: '/usr/sbin/sendmail' 43 | 44 | -------------------------------------------------------------------------------- /lib/Threads/Middleware/Origin.pm: -------------------------------------------------------------------------------- 1 | package Threads::Middleware::Origin; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Middleware'; 7 | 8 | use Plack::Session; 9 | use Tu::Scope; 10 | 11 | sub call { 12 | my $self = shift; 13 | my ($env) = @_; 14 | 15 | my $dispatched_request = Tu::Scope->new($env)->dispatched_request; 16 | if ($dispatched_request && (my $action = $dispatched_request->action)) { 17 | my $session = Plack::Session->new($env); 18 | my $origin = $session->get('origin'); 19 | $origin ||= []; 20 | $origin = [$origin] unless ref $origin eq 'ARRAY'; 21 | 22 | if (!@$origin || $origin->[0]->{url} ne $env->{REQUEST_URI}) { 23 | unshift @$origin, {url => $env->{REQUEST_URI}, action => $action}; 24 | } 25 | 26 | pop @$origin while @$origin > 2; 27 | 28 | $session->set(origin => $origin); 29 | } 30 | 31 | return $self->app->($env); 32 | } 33 | 34 | 1; 35 | -------------------------------------------------------------------------------- /templates/include/thread-controls.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | % if ($helpers->acl->is_allowed($thread, 'update_thread')) { 5 |
6 | 7 |
8 | % } 9 | % if ($helpers->acl->is_allowed($thread, 'delete_thread')) { 10 |
11 | 12 |
13 | % } 14 | 15 | %== $helpers->displayer->render('include/quick-reply-form', thread => $thread, reply => var('reply')); 16 |
17 | 18 | -------------------------------------------------------------------------------- /templates/list_subscriptions.apl: -------------------------------------------------------------------------------- 1 | % $helpers->assets->require('/js/quick-reply.js'); 2 | % $helpers->meta->set(title => loc('Subscriptions')); 3 | 4 |
5 | 6 |

<%= loc('Subscriptions') %>

7 | 8 | % my @subscriptions = $helpers->subscription->find; 9 | 10 | % if (@subscriptions) { 11 |

12 |

13 | 14 |
15 |

16 | % } 17 | 18 | % foreach my $subscription (@subscriptions) { 19 | % my $thread = $subscription->{thread}; 20 | %== $helpers->displayer->render('include/thread', thread => $thread, view => 1, no_content => 1); 21 |
22 | % } 23 | 24 | %== $helpers->displayer->render('include/pager', base_url => $helpers->url->list_subscriptions, total => $helpers->subscription->count); 25 | 26 |
27 | -------------------------------------------------------------------------------- /templates/profile.apl: -------------------------------------------------------------------------------- 1 | % $helpers->meta->set(title => loc('Profile')); 2 | 3 |
4 | 5 |

<%= loc('Profile') %>

6 | 7 |

8 | <%= loc('Threads') %>
9 | <%= loc('Subscriptions') %>
10 | <%= loc('Notifications') %> 11 |

12 | 13 |

14 | <%= loc('Settings') %>
15 | <%= loc('Change password') %> 16 |

17 | 18 |

19 | <%= loc('Remove account') %> 20 |

21 | 22 |
23 | -------------------------------------------------------------------------------- /t/generate.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | 7 | use FindBin '$RealBin'; 8 | 9 | BEGIN { 10 | unshift @INC, "$RealBin/../lib"; 11 | unshift @INC, "$_/lib" for glob "$RealBin/../contrib/*"; 12 | 13 | $ENV{PLACK_ENV} = 'development'; 14 | } 15 | 16 | use Plack::Builder; 17 | use Tu::Config; 18 | use Threads::DB::User; 19 | use Threads::DB::Thread; 20 | use Threads::DB::Reply; 21 | 22 | my $config = Tu::Config->new(mode => 1)->load('config/config.yml'); 23 | Threads::DB->init_db(%{$config->{database}}); 24 | 25 | Threads::DB::User->table->delete; 26 | Threads::DB::Thread->table->delete; 27 | Threads::DB::Reply->table->delete; 28 | 29 | Threads::DB::User->new( 30 | name => 'foo', 31 | email => 'foo@bar.com', 32 | password => 'password', 33 | status => 'active' 34 | )->create; 35 | 36 | Threads::DB::User->new( 37 | name => 'foo2', 38 | email => 'foo2@bar.com', 39 | password => 'password', 40 | status => 'active' 41 | )->create; 42 | -------------------------------------------------------------------------------- /util/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | #set -x 4 | 5 | CONFIG=${1:-util/deploy.sh.rc} 6 | TARGET="origin/master" 7 | DATE=`date '+%Y-%m-%dT%H%M%S'` 8 | STAGING_DIR="/tmp/deploy-$DATE" 9 | 10 | . $CONFIG 11 | 12 | ssh -t $HOST " 13 | git clone '$CLONE_PATH' '$STAGING_DIR' && 14 | cd '$STAGING_DIR' && 15 | git checkout --force $TARGET && 16 | git submodule update --init && 17 | cp -ar '$DIR/local' . && 18 | carton install && 19 | carton exec -- prove -Ilib -It/lib -r t 20 | " || exit 1 21 | 22 | ssh -t $HOST " 23 | cd $DIR && 24 | sudo supervisorctl stop $SUPERVISORD_SERVICES 25 | rsync -avz --delete '$STAGING_DIR/local/' local/ && 26 | git fetch --all && 27 | git checkout --force $TARGET && 28 | git submodule update --init && 29 | carton install && 30 | cp '$DB_FILE' '$DB_FILE.${DATE}.bak' && 31 | carton exec -- mimi migrate --dsn 'dbi:SQLite:$DB_FILE' --schema schema --verbose && 32 | sudo supervisorctl start $SUPERVISORD_SERVICES 33 | 34 | rm -rf '$STAGING_DIR' 35 | " || exit 1 36 | -------------------------------------------------------------------------------- /t/helper/truncate.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use utf8; 4 | 5 | use Test::More; 6 | use TestLib; 7 | 8 | use Threads::Helper::Truncate; 9 | 10 | subtest 'truncates less than' => sub { 11 | my $helper = _build_helper(); 12 | 13 | is $helper->truncate('123'), '123'; 14 | }; 15 | 16 | subtest 'truncates more than' => sub { 17 | my $helper = _build_helper(); 18 | 19 | is $helper->truncate('12345678901', 10), '1234567890…'; 20 | }; 21 | 22 | subtest 'truncates correctly html code' => sub { 23 | my $helper = _build_helper(); 24 | 25 | is $helper->truncate('

very very very very long

', 10), 26 | '

very very…

'; 27 | }; 28 | 29 | subtest 'truncates correctly unicode' => sub { 30 | my $helper = _build_helper(); 31 | 32 | is $helper->truncate( 33 | '

очень очень очень длинная строка

', 34 | 10), 35 | '

очень очен…

'; 36 | }; 37 | 38 | my $env = {}; 39 | 40 | sub _build_helper { 41 | Threads::Helper::Truncate->new(env => $env); 42 | } 43 | 44 | done_testing; 45 | -------------------------------------------------------------------------------- /tjs/models/value_object_observable.js: -------------------------------------------------------------------------------- 1 | QUnit.module('models/value_object_observable'); 2 | QUnit.test("set/get still works", function(assert) { 3 | var valueObjectObservable = new ValueObjectObservable(); 4 | 5 | valueObjectObservable.set('foo', 'bar'); 6 | 7 | assert.equal(valueObjectObservable.get('foo'), 'bar'); 8 | }); 9 | 10 | QUnit.test("observe change", function(assert) { 11 | var valueObjectObservable = new ValueObjectObservable(); 12 | var got; 13 | valueObjectObservable.onchange('foo', function(newValue) { 14 | got = newValue; 15 | }); 16 | 17 | valueObjectObservable.set('foo', 'bar'); 18 | 19 | assert.equal(got, 'bar'); 20 | }); 21 | 22 | QUnit.test("not observe when value not changed", function(assert) { 23 | var valueObjectObservable = new ValueObjectObservable(); 24 | valueObjectObservable.set('foo', 'bar'); 25 | 26 | var got; 27 | valueObjectObservable.onchange('foo', function(newValue) { 28 | got = newValue; 29 | }); 30 | 31 | valueObjectObservable.set('foo', 'bar'); 32 | 33 | assert.ok(!got); 34 | }); 35 | -------------------------------------------------------------------------------- /schema/11user.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | ALTER TABLE `users` RENAME to `users_old`; 4 | 5 | CREATE TABLE `users` ( 6 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 7 | `email` VARCHAR(255) NOT NULL, 8 | `password` BLOB NOT NULL DEFAULT '', 9 | `salt` BLOB NOT NULL DEFAULT '', 10 | `name` VARCHAR(32) NOT NULL DEFAULT '', 11 | `status` VARCHAR(32) NOT NULL DEFAULT 'new', 12 | `email_notifications` INT NOT NULL DEFAULT 1, 13 | `created` integer(4) not null default (strftime('%s','now')), 14 | UNIQUE (`email`) 15 | ); 16 | 17 | INSERT INTO users(id,email,password,name,status,email_notifications,created) 18 | SELECT id,email,password,name,status,email_notifications,created FROM users_old; 19 | 20 | DROP TABLE `users_old`; 21 | 22 | DROP TABLE IF EXISTS `confirmations`; 23 | CREATE TABLE `confirmations` ( 24 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 25 | `user_id` INT NOT NULL, 26 | `token` BLOB NOT NULL, 27 | `type` VARCHAR(32) NOT NULL DEFAULT '', 28 | `created` integer(4) not null default (strftime('%s','now')), 29 | UNIQUE (`token`) 30 | ); 31 | 32 | COMMIT; 33 | -------------------------------------------------------------------------------- /lib/Threads/Action/DeleteReply.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::DeleteReply; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | use Threads::ObjectACL; 9 | use Threads::DB::User; 10 | use Threads::DB::Reply; 11 | use Threads::DB::Notification; 12 | 13 | sub run { 14 | my $self = shift; 15 | 16 | my $reply_id = $self->captures->{id}; 17 | 18 | return $self->throw_not_found 19 | unless my $reply = Threads::DB::Reply->new(id => $reply_id)->load; 20 | 21 | my $user = $self->scope->user; 22 | 23 | return $self->throw_not_found 24 | unless Threads::ObjectACL->new->is_allowed($user, $reply, 'delete_reply'); 25 | 26 | my $thread = $reply->related('thread'); 27 | 28 | Threads::DB::Notification->table->delete( 29 | where => [reply_id => $reply->id]); 30 | 31 | $reply->delete; 32 | 33 | $thread->replies_count($thread->count_related('replies')); 34 | $thread->update; 35 | 36 | return $self->redirect( 37 | 'view_thread', 38 | id => $thread->id, 39 | slug => $thread->slug 40 | ); 41 | } 42 | 43 | 1; 44 | -------------------------------------------------------------------------------- /public/js/essentials.js: -------------------------------------------------------------------------------- 1 | (function(globals){ 2 | var editors = []; 3 | $('pre.markup code').each(function() { 4 | $(this).replaceWith(''); 5 | }); 6 | $('textarea.code').each(function() { 7 | var editor = CodeMirror.fromTextArea(this, {readOnly: true, lineNumbers: true}); 8 | editors.push(editor); 9 | }); 10 | 11 | $('.date').each(function() { 12 | var time = moment.utc($(this).html().trim(), 'YYYY-MM-DD HH:mm'); 13 | $(this).html(time.local().format('YYYY-MM-DD HH:mm')); 14 | }); 15 | 16 | globals.Models = {}; 17 | globals.Actions = {}; 18 | 19 | Actions.noCount = new NoCountAction; 20 | Actions.noCountTitle = new NoCountTitleAction; 21 | 22 | Models.noCount = new ValueObjectObservable({count: Actions.noCount.get()}); 23 | Models.noCount.onchange('count', function(count) { 24 | Actions.noCount.update(count); 25 | }); 26 | Models.noCount.onchange('count', function(count) { 27 | Actions.noCountTitle.update(count); 28 | }); 29 | })(this); 30 | -------------------------------------------------------------------------------- /lib/Threads/Notificator.pm: -------------------------------------------------------------------------------- 1 | package Threads::Notificator; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use List::MoreUtils qw(uniq); 7 | use Threads::DB::Notification; 8 | use Threads::DB::User; 9 | use Threads::MarkupRenderer; 10 | 11 | sub new { 12 | my $class = shift; 13 | 14 | my $self = {}; 15 | bless $self, $class; 16 | 17 | return $self; 18 | } 19 | 20 | sub notify_mentioned_users { 21 | my $self = shift; 22 | my ($user, $reply) = @_; 23 | 24 | my $content = $reply->content; 25 | 26 | my $markup = Threads::MarkupRenderer->new; 27 | my $translated = $markup->translate($content); 28 | 29 | my @mentions = uniq $translated->{text} =~ m/\@([a-z0-9_-]{1,32})/ims; 30 | 31 | foreach my $mention (@mentions) { 32 | my $mentioned_user = Threads::DB::User->new(name => $mention)->load; 33 | next unless $mentioned_user; 34 | 35 | next if $mentioned_user->id == $user->id; 36 | 37 | Threads::DB::Notification->new( 38 | user_id => $mentioned_user->id, 39 | reply_id => $reply->id 40 | )->load_or_create; 41 | } 42 | } 43 | 44 | 45 | 1; 46 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Notification.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Notification; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use Threads::DB::Notification; 9 | 10 | sub count { 11 | my $self = shift; 12 | 13 | my $user = $self->scope->user; 14 | 15 | return Threads::DB::Notification->table->count( 16 | where => [ 17 | user_id => $user->id, 18 | 'reply.id' => {'!=' => ''} 19 | ] 20 | ); 21 | } 22 | 23 | sub find { 24 | my $self = shift; 25 | 26 | my $user = $self->scope->user; 27 | my $page = $self->param('page') || 1; 28 | my $page_size = $self->param('page_size') || 10; 29 | 30 | my @notifications = Threads::DB::Notification->find( 31 | where => [ 32 | user_id => $user->id, 33 | 'reply.id' => {'!=' => ''} 34 | ], 35 | page => $page, 36 | page_size => $page_size, 37 | order_by => [id => 'DESC'], 38 | with => [qw/reply reply.user reply.parent.user reply.thread/] 39 | ); 40 | 41 | return map { $_->to_hash } @notifications; 42 | } 43 | 44 | 1; 45 | -------------------------------------------------------------------------------- /lib/Threads/Util.pm: -------------------------------------------------------------------------------- 1 | package Threads::Util; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Exporter'; 7 | 8 | our @EXPORT_OK = qw(gentoken to_hex from_hex); 9 | 10 | use Time::HiRes qw(gettimeofday); 11 | use Math::Random::ISAAC; 12 | 13 | sub gentoken { 14 | my ($len) = @_; 15 | 16 | $len ||= 32; 17 | 18 | my $seed; 19 | 20 | if (-e '/dev/urandom') { 21 | if (open my $fh, '<', '/dev/urandom') { 22 | read $fh, $seed, 4; 23 | close $fh; 24 | 25 | $seed = unpack 'L', $seed; 26 | } 27 | } 28 | 29 | if (!defined $seed) { 30 | my ($seconds, $microseconds) = gettimeofday(); 31 | 32 | $microseconds .= '0' while length $microseconds < 6; 33 | 34 | $seed = $seconds . $microseconds; 35 | } 36 | 37 | my $rng = Math::Random::ISAAC->new($seed); 38 | 39 | return join '', map { pack 'L', $rng->irand } 1 .. $len / 4; 40 | } 41 | 42 | sub to_hex ($) { 43 | my ($bytes) = @_; 44 | 45 | return unpack 'H*', $bytes; 46 | } 47 | 48 | sub from_hex ($) { 49 | my ($hex) = @_; 50 | 51 | return pack 'H*', $hex; 52 | } 53 | 54 | 1; 55 | -------------------------------------------------------------------------------- /t/object_acl.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use utf8; 4 | 5 | use Test::More; 6 | use TestLib; 7 | use TestDB; 8 | 9 | use Threads::ObjectACL; 10 | use Threads::DB::User; 11 | use Threads::DB::Thread; 12 | 13 | subtest 'denies when not author' => sub { 14 | TestDB->setup; 15 | 16 | my $user = TestDB->create('User'); 17 | my $thread = TestDB->create('Thread', user_id => 123); 18 | my $acl = _build_acl(); 19 | 20 | ok !$acl->is_allowed($user, $thread, 'update_thread'); 21 | }; 22 | 23 | subtest 'allows when ok' => sub { 24 | TestDB->setup; 25 | 26 | my $user = TestDB->create('User'); 27 | my $thread = TestDB->create('Thread', user_id => $user->id); 28 | my $acl = _build_acl(); 29 | 30 | ok $acl->is_allowed($user, $thread, 'update_thread'); 31 | }; 32 | 33 | subtest 'denies when unknown action' => sub { 34 | TestDB->setup; 35 | 36 | my $user = TestDB->create('User'); 37 | my $thread = TestDB->create('Thread', user_id => $user->id); 38 | my $acl = _build_acl(); 39 | 40 | ok !$acl->is_allowed($user, $thread, 'unknown_action'); 41 | }; 42 | 43 | sub _build_acl { Threads::ObjectACL->new(@_) } 44 | 45 | done_testing; 46 | -------------------------------------------------------------------------------- /t/lib/TestRequest.pm: -------------------------------------------------------------------------------- 1 | package TestRequest; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Test::MonkeyMock; 7 | 8 | use Tu::DispatchedRequest; 9 | 10 | use HTTP::Request; 11 | use HTTP::Message::PSGI qw(req_to_psgi); 12 | 13 | sub to_env { 14 | my $class = shift; 15 | my (%params) = @_; 16 | 17 | my $env = 18 | req_to_psgi $params{req} ? $params{req} : HTTP::Request->new(GET => '/'); 19 | 20 | $env->{'psgix.session'} ||= $params{'psgix.session'} || {}; 21 | $env->{'psgix.session.options'} ||= {}; 22 | $env->{'tu.displayer.vars'} ||= $params{'tu.displayer.vars'} || {}; 23 | $env->{'tu.auth'} ||= $params{'tu.auth'}; 24 | $env->{'tu.user'} ||= $params{'tu.user'}; 25 | $env->{'tu.dispatched_request'} ||= 26 | $params{'tu.dispatched_request'} || _build_dispatched_request(%params); 27 | 28 | return $env; 29 | } 30 | 31 | sub _build_dispatched_request { 32 | my (%params) = @_; 33 | 34 | my $dr = Tu::DispatchedRequest->new; 35 | $dr = Test::MonkeyMock->new($dr); 36 | $dr->mock(build_path => sub { '' }); 37 | $dr->mock(captures => sub { $params{captures} }); 38 | 39 | return $dr; 40 | } 41 | 42 | 1; 43 | -------------------------------------------------------------------------------- /templates/include/markup-help.apl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
    4 |
  • @user
  • 5 | % foreach my $code ( 6 | % '_italic_', 7 | % '**bold**', 8 | % '[PP](http://pragmaticperl.com)', 9 | % '', 10 | % 'module:Plack', 11 | % 'release:URI', 12 | % 'author:VTI', 13 | % ) { 14 |
  • 15 |
    <%= $code %>
    → <%== $helpers->markup->render($code) %> 16 |
  • 17 | % } 18 |
  • 19 |
    `my $foo = 'bar'`
    20 |
  • 21 |
  • 22 |
    ```
    23 | my $multi;
    24 | $line;
    25 | ```
    26 |
  • 27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /lib/Threads/Action/FormBase.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::FormBase; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action'; 7 | 8 | use Tu::Validator; 9 | use Tu::Action::FormMixin 'validate_or_submit'; 10 | use Threads::Action::TranslateMixin 'loc'; 11 | 12 | sub build_validator { 13 | my $self = shift; 14 | 15 | return Tu::Validator->new( 16 | namespaces => ['Threads::Validator::', 'Tu::Validator::'], 17 | messages => { 18 | REQUIRED => $self->loc('Required'), 19 | EMAIL => $self->loc('Invalid email'), 20 | COMPARE => $self->loc('Password mismatch'), 21 | READABLE => $self->loc('Not readable'), 22 | MINLENGTH => $self->loc('Too short'), 23 | MAXLENGTH => $self->loc('Too long'), 24 | TAGS => $self->loc('Wrong tags'), 25 | NOTDISPOSABLEEMAIL => 26 | $self->loc('Disposable emails are not allowed'), 27 | 'name.REGEXP' => $self->loc('Invalid name'), 28 | }, 29 | @_ 30 | ); 31 | } 32 | 33 | sub show { } 34 | sub show_errors { } 35 | sub submit { } 36 | 37 | sub run { shift->validate_or_submit } 38 | 39 | 1; 40 | -------------------------------------------------------------------------------- /lib/Threads/Origin.pm: -------------------------------------------------------------------------------- 1 | package Threads::Origin; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use URI; 7 | use Plack::Session; 8 | 9 | sub new { 10 | my $class = shift; 11 | my (%params) = @_; 12 | 13 | my $self = {}; 14 | bless $self, $class; 15 | 16 | $self->{env} = $params{env}; 17 | $self->{services} = $params{services}; 18 | $self->{user} = $params{user}; 19 | 20 | return $self; 21 | } 22 | 23 | sub env { $_[0]->{env} } 24 | sub services { $_[0]->{services} } 25 | sub user { $_[0]->{user} } 26 | 27 | sub origin { 28 | my $self = shift; 29 | 30 | my $session = Plack::Session->new($self->env); 31 | return unless my $origin = $session->get('origin'); 32 | return unless ref $origin eq 'ARRAY' && @$origin > 1; 33 | 34 | $origin = $origin->[1]; 35 | return unless ref $origin eq 'HASH'; 36 | 37 | return unless my $url = $origin->{url}; 38 | return unless my $action = $origin->{action}; 39 | 40 | if (my $user = $self->user) { 41 | my $acl = $self->services->service('acl'); 42 | return unless $acl->is_allowed($user->role, $action); 43 | } 44 | 45 | return URI->new($origin->{url}); 46 | } 47 | 48 | 1; 49 | -------------------------------------------------------------------------------- /tjs/actions/no_count_title.js: -------------------------------------------------------------------------------- 1 | QUnit.module('actions/no_count_title', { 2 | beforeEach: function() { 3 | this.old_title = document.title; 4 | }, 5 | afterEach: function() { 6 | document.title = this.old_title; 7 | } 8 | }); 9 | QUnit.test("return 0 when nothing in title", function(assert) { 10 | var action = new NoCountTitleAction(); 11 | 12 | var count = action.get(); 13 | 14 | assert.ok(count === 0); 15 | assert.equal(count, 0); 16 | }); 17 | 18 | QUnit.test("return current count", function(assert) { 19 | var action = new NoCountTitleAction(); 20 | 21 | document.title = '(123) hello'; 22 | var count = action.get(); 23 | 24 | assert.ok(count === 123); 25 | assert.equal(count, 123); 26 | }); 27 | 28 | QUnit.test("update count", function(assert) { 29 | var action = new NoCountTitleAction(); 30 | 31 | document.title = '(123) hello'; 32 | 33 | action.update(7); 34 | 35 | assert.equal(document.title, '(7) hello'); 36 | }); 37 | 38 | QUnit.test("insert count", function(assert) { 39 | var action = new NoCountTitleAction(); 40 | 41 | document.title = 'hello'; 42 | 43 | action.update(7); 44 | 45 | assert.equal(document.title, '(7) hello'); 46 | }); 47 | -------------------------------------------------------------------------------- /lib/Threads/Action/ToggleSubscription.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ToggleSubscription; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action'; 7 | 8 | use Threads::DB::User; 9 | use Threads::DB::Reply; 10 | use Threads::DB::Subscription; 11 | use Threads::Action::TranslateMixin 'loc'; 12 | 13 | sub run { 14 | my $self = shift; 15 | 16 | my $thread_id = $self->captures->{id}; 17 | return $self->new_json_response(404) 18 | unless my $thread = Threads::DB::Thread->new(id => $thread_id)->load; 19 | 20 | my $user = $self->scope->user; 21 | 22 | my $subscription = Threads::DB::Subscription->find( 23 | first => 1, 24 | where => [ 25 | user_id => $user->id, 26 | thread_id => $thread->id 27 | ] 28 | ); 29 | 30 | my $state; 31 | if ($subscription) { 32 | $subscription->delete; 33 | 34 | $state = 0; 35 | } 36 | else { 37 | Threads::DB::Subscription->new( 38 | user_id => $user->id, 39 | thread_id => $thread->id 40 | )->create; 41 | 42 | $state = 1; 43 | } 44 | 45 | return $self->new_json_response(200, {state => $state}); 46 | } 47 | 48 | 1; 49 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Acl.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Acl; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use Threads::ObjectACL; 9 | 10 | sub user { 11 | my $self = shift; 12 | 13 | my $user = $self->scope->user; 14 | return {} unless $user; 15 | 16 | return $user->to_hash; 17 | } 18 | 19 | sub is_anon { 20 | my $self = shift; 21 | 22 | my $user = $self->scope->user; 23 | return $user ? 0 : 1; 24 | } 25 | 26 | sub is_user { 27 | my $self = shift; 28 | 29 | my $user = $self->scope->user; 30 | return $user ? 1 : 0; 31 | } 32 | 33 | sub is_admin { 34 | my $self = shift; 35 | 36 | my $user = $self->scope->user; 37 | return 0 unless $user && $user->role eq 'admin'; 38 | 39 | return 1; 40 | } 41 | 42 | sub is_author { 43 | my $self = shift; 44 | my ($object) = @_; 45 | 46 | my $user = $self->scope->user; 47 | 48 | return Threads::ObjectACL->new->is_author($user, $object); 49 | } 50 | 51 | sub is_allowed { 52 | my $self = shift; 53 | my ($object, $action) = @_; 54 | 55 | my $user = $self->scope->user; 56 | 57 | return Threads::ObjectACL->new->is_allowed($user, $object, $action); 58 | } 59 | 60 | 1; 61 | -------------------------------------------------------------------------------- /t/jobs/cleanup_thread_views.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use Test::Fatal; 6 | use TestLib; 7 | use TestDB; 8 | 9 | use Threads::DB::View; 10 | use Threads::Job::CleanupThreadViews; 11 | 12 | subtest 'not delete todays views' => sub { 13 | TestDB->setup; 14 | 15 | _create_view(); 16 | 17 | my $job = _build_job(); 18 | $job->run; 19 | 20 | is(Threads::DB::View->table->count, 1); 21 | }; 22 | 23 | subtest 'delete old views' => sub { 24 | TestDB->setup; 25 | 26 | _create_view(created => time - 7 * 24 * 3600); 27 | 28 | my $job = _build_job(); 29 | $job->run; 30 | 31 | is(Threads::DB::View->table->count, 0); 32 | }; 33 | 34 | subtest 'do not delete when dry-run' => sub { 35 | TestDB->setup; 36 | 37 | _create_view(created => time - 7 * 24 * 3600); 38 | 39 | my $job = _build_job(dry_run => 1); 40 | $job->run; 41 | 42 | is(Threads::DB::View->table->count, 1); 43 | }; 44 | 45 | sub _create_view { 46 | Threads::DB::View->new(user_id => 1, thread_id => 1, @_)->create; 47 | } 48 | 49 | sub _build_job { 50 | my (%params) = @_; 51 | 52 | return Threads::Job::CleanupThreadViews->new(%params); 53 | } 54 | 55 | done_testing; 56 | -------------------------------------------------------------------------------- /t/helper/subscription.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | use TestDB; 7 | use TestRequest; 8 | 9 | use Threads::DB::User; 10 | use Threads::DB::Thread; 11 | use Threads::DB::Subscription; 12 | use Threads::Helper::Subscription; 13 | 14 | subtest 'returns false when not subscribed' => sub { 15 | TestDB->setup; 16 | 17 | my $user = 18 | Threads::DB::User->new(email => 'foo@bar.com', password => 'password') 19 | ->create; 20 | my $helper = _build_helper('tu.user' => $user); 21 | 22 | ok !$helper->is_subscribed({id => 123}); 23 | }; 24 | 25 | subtest 'returns true when subscribed' => sub { 26 | TestDB->setup; 27 | 28 | my $user = 29 | Threads::DB::User->new(email => 'foo@bar.com', password => 'password') 30 | ->create; 31 | Threads::DB::Subscription->new( 32 | user_id => $user->id, 33 | thread_id => 123 34 | )->create; 35 | my $helper = _build_helper('tu.user' => $user); 36 | 37 | ok $helper->is_subscribed({id => 123}); 38 | }; 39 | 40 | my $env; 41 | 42 | sub _build_helper { 43 | $env = TestRequest->to_env(@_); 44 | Threads::Helper::Subscription->new(env => $env); 45 | } 46 | 47 | done_testing; 48 | -------------------------------------------------------------------------------- /lib/Threads/Action/ChangePassword.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ChangePassword; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action::FormBase'; 7 | 8 | use Carp qw(croak); 9 | use Threads::DB::User; 10 | 11 | sub build_validator { 12 | my $self = shift; 13 | 14 | my $validator = $self->SUPER::build_validator; 15 | 16 | $validator->add_field('old_password'); 17 | $validator->add_field('new_password'); 18 | $validator->add_field('new_password_confirmation'); 19 | 20 | $validator->add_group_rule('new_password', 21 | [qw/new_password new_password_confirmation/], 'compare'); 22 | 23 | return $validator; 24 | } 25 | 26 | sub validate { 27 | my $self = shift; 28 | my ($validator, $params) = @_; 29 | 30 | my $user = $self->scope->user; 31 | 32 | if (!$user->check_password($params->{old_password})) { 33 | $validator->add_error(old_password => $self->loc('Invalid password')); 34 | return 0; 35 | } 36 | 37 | return 1; 38 | } 39 | 40 | sub submit { 41 | my $self = shift; 42 | my ($params) = @_; 43 | 44 | my $user = $self->scope->user; 45 | 46 | $user->update_password($params->{new_password}); 47 | 48 | return $self->render('password_changed'); 49 | } 50 | 51 | 1; 52 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Subscription.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Subscription; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use Threads::DB::Subscription; 9 | 10 | sub is_subscribed { 11 | my $self = shift; 12 | my ($thread) = @_; 13 | 14 | my $user = $self->scope->user; 15 | return 0 unless $user; 16 | 17 | return 1 18 | if Threads::DB::Subscription->find( 19 | first => 1, 20 | where => 21 | [thread_id => $thread->{id}, user_id => $user->id] 22 | ); 23 | 24 | return 0; 25 | } 26 | 27 | sub find { 28 | my $self = shift; 29 | 30 | my $user = $self->scope->user; 31 | 32 | my @subscriptions = Threads::DB::Subscription->find( 33 | where => [ 34 | user_id => $user->id, 35 | 'thread.id' => {'!=' => ''} 36 | ], 37 | with => ['thread', 'thread.user'] 38 | ); 39 | 40 | return map { $_->to_hash } @subscriptions; 41 | } 42 | 43 | sub count { 44 | my $self = shift; 45 | 46 | my $user = $self->scope->user; 47 | 48 | return Threads::DB::Subscription->table->count( 49 | where => [ 50 | user_id => $user->id, 51 | 'thread.id' => {'!=' => ''} 52 | ] 53 | ); 54 | } 55 | 56 | 1; 57 | -------------------------------------------------------------------------------- /lib/Threads/Action/ConfirmDeregistration.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ConfirmDeregistration; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | use Threads::DB::User; 9 | use Threads::DB::Confirmation; 10 | use Threads::DB::Notification; 11 | use Threads::DB::Subscription; 12 | 13 | sub run { 14 | my $self = shift; 15 | 16 | my $token = $self->captures->{token}; 17 | $self->throw_not_found unless $token; 18 | 19 | $self->throw_not_found 20 | unless my $confirmation = 21 | Threads::DB::Confirmation->find_fresh_by_token($token, 'deregister'); 22 | 23 | $self->throw_not_found 24 | unless my $user = 25 | Threads::DB::User->new(id => $confirmation->user_id)->load; 26 | 27 | $user->set_columns( 28 | email => '#' . $user->id, 29 | name => '#' . $user->id, 30 | password => '', 31 | status => 'deleted' 32 | ); 33 | $user->update; 34 | 35 | $self->scope->auth->logout; 36 | 37 | $confirmation->delete; 38 | 39 | Threads::DB::Notification->table->delete( 40 | where => [user_id => $user->id]); 41 | Threads::DB::Subscription->table->delete( 42 | where => [user_id => $user->id]); 43 | 44 | return $self->render('deregistration_confirmation_success'); 45 | } 46 | 47 | 1; 48 | -------------------------------------------------------------------------------- /t/lib/TestDB.pm: -------------------------------------------------------------------------------- 1 | package TestDB; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Threads::DB; 7 | 8 | my %defaults = ( 9 | User => { 10 | name => 'foo', 11 | email => 'foo@bar.com', 12 | password => 'silly', 13 | role => 'user' 14 | } 15 | ); 16 | 17 | sub build { 18 | my $class = shift; 19 | my ($name, %params) = @_; 20 | 21 | my $class_name = "Threads::DB::$name"; 22 | return $class_name->new(%{$defaults{$name} || {}}, %params); 23 | } 24 | 25 | sub create { 26 | my $class = shift; 27 | 28 | return $class->build(@_)->create; 29 | } 30 | 31 | sub setup { 32 | my $self = shift; 33 | 34 | my $dbh = DBI->connect('dbi:SQLite::memory:', '', '', {RaiseError => 1}); 35 | die $DBI::errorstr unless $dbh; 36 | 37 | $dbh->do("PRAGMA default_synchronous = OFF"); 38 | $dbh->do("PRAGMA temp_store = MEMORY"); 39 | 40 | my @schema_files = glob('schema/*.sql'); 41 | 42 | my @schema; 43 | for my $file (@schema_files) { 44 | push @schema, split /;/, do { 45 | open my $fh, '<', $file or die $!; 46 | local $/; 47 | <$fh>; 48 | }; 49 | } 50 | $dbh->do($_) for @schema; 51 | 52 | Threads::DB->init_db($dbh); 53 | Threads::DB->init_db->{sqlite_unicode} = 1; 54 | } 55 | 56 | 1; 57 | -------------------------------------------------------------------------------- /util/block-user.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use FindBin '$RealBin'; 7 | 8 | binmode STDOUT, ':utf8'; 9 | 10 | BEGIN { 11 | unshift @INC, "$RealBin/../lib"; 12 | unshift @INC, "$_/lib" for glob "$RealBin/../contrib/*"; 13 | } 14 | 15 | use Getopt::Long; 16 | use Encode (); 17 | use Tu::Config; 18 | use Threads::DB; 19 | use Threads::DB::User; 20 | 21 | my $verbose; 22 | my $unblock; 23 | GetOptions('unblock' => \$unblock, 'verbose' => \$verbose) 24 | or die("Error in command line arguments\n"); 25 | 26 | my ($id) = map { Encode::decode('UTF-8', $_) } @ARGV; 27 | die 'Usage: ' unless $id; 28 | 29 | my $config = Tu::Config->new(mode => 1)->load("$RealBin/../config/config.yml"); 30 | Threads::DB->init_db(%{$config->{database}}); 31 | 32 | my $user = Threads::DB::User->find( 33 | first => 1, 34 | where => [ 35 | $id =~ m/^\d+$/ 36 | ? (id => $id) 37 | : ($id =~ m/\@/ ? (email => $id) : (name => $id)) 38 | ] 39 | ); 40 | 41 | die 'Unknown user' unless $user; 42 | 43 | $user->status($unblock ? 'active' : 'blocked'); 44 | $user->update; 45 | 46 | if ($verbose) { 47 | print sprintf "User id=%d email=%s name=%s %s\n", 48 | $user->id, 49 | $user->email, 50 | $user->name, 51 | $unblock ? 'unblocked' : 'blocked'; 52 | } 53 | 54 | -------------------------------------------------------------------------------- /templates/admin_list_users.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 | %== $helpers->displayer->render('include/admin_nav'); 4 | 5 | % my @users = $helpers->admin_user->find; 6 | 7 | 8 | % foreach my $user (@users) { 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 30 | 31 | % } 32 |
<%= $user->{id} %><%= $user->{email} %><%= $user->{name} %><%= $user->{status} %><%= $user->{role} %><%= $helpers->date->format($user->{created}) %> 17 | % if ($helpers->acl->user->{id} != $user->{id}) { 18 | % my $is_blocked = $user->{status} eq 'blocked'; 19 |
20 | 27 | % } 28 |
29 |
33 | 34 | %== $helpers->displayer->render('include/pager', base_url => $helpers->url->admin_list_users, total => $helpers->admin_user->count); 35 | 36 |
37 | -------------------------------------------------------------------------------- /lib/Threads/Action/Deregister.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::Deregister; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | use Threads::DB::User; 9 | use Threads::DB::Confirmation; 10 | use Threads::Action::TranslateMixin 'loc'; 11 | use Threads::Util qw(to_hex); 12 | 13 | sub run { 14 | my $self = shift; 15 | 16 | return if $self->req->method eq 'GET'; 17 | 18 | my $user = $self->env->{'tu.user'}; 19 | 20 | my $confirmation = Threads::DB::Confirmation->new( 21 | user_id => $user->id, 22 | type => 'deregister' 23 | )->create; 24 | 25 | my $email = $self->render( 26 | 'email/deregistration_confirmation_required', 27 | layout => undef, 28 | vars => { 29 | email => $user->email, 30 | token => to_hex $confirmation->token 31 | } 32 | ); 33 | 34 | $self->mailer->send( 35 | headers => [ 36 | To => $user->email, 37 | Subject => $self->loc('Deregistration confirmation') 38 | ], 39 | body => $email 40 | ); 41 | 42 | return $self->render( 43 | 'deregistration_confirmation_needed', 44 | vars => {email => $user->email} 45 | ); 46 | } 47 | 48 | sub mailer { 49 | my $self = shift; 50 | 51 | return $self->service('mailer'); 52 | } 53 | 54 | 1; 55 | -------------------------------------------------------------------------------- /templates/include/reply-thank.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 | % if ($helpers->acl->is_anon) { 4 | <%= $reply->{thanks_count} ? $reply->{thanks_count} : '' %> 5 | 6 | % } elsif ($helpers->acl->is_author($reply)) { 7 | <%= $reply->{thanks_count} ? $reply->{thanks_count} : '' %> 8 | 9 | % } else { 10 | % my $is_thanked = $helpers->reply->is_thanked($reply); 11 |
12 | 13 | 21 |
22 | % } 23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /t/functional/feeds.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use utf8; 4 | 5 | use Test::More; 6 | use Test::WWW::Mechanize::PSGI; 7 | use TestLib; 8 | use TestDB; 9 | use TestFunctional; 10 | 11 | use Threads; 12 | use Threads::DB::User; 13 | 14 | subtest 'renders empty feed' => sub { 15 | TestDB->setup; 16 | 17 | my $ua = _build_ua(); 18 | 19 | $ua->get('/threads.rss'); 20 | 21 | $ua->content_contains('xml'); 22 | }; 23 | 24 | subtest 'renders feed with threads' => sub { 25 | TestDB->setup; 26 | 27 | my $ua = _build_ua(); 28 | 29 | $ua->get('/create-thread'); 30 | $ua->submit_form( 31 | fields => { 32 | title => 'Foo', 33 | tags => 'foo, bar', 34 | content => 'This is a new thread' 35 | }, 36 | form_id => 'create-thread' 37 | ); 38 | 39 | $ua->get('/threads.rss'); 40 | 41 | $ua->content_contains('Foo'); 42 | }; 43 | 44 | sub _build_ua { 45 | Threads::DB::User->new( 46 | email => 'foo@bar.com', 47 | password => 'silly', 48 | status => 'active' 49 | )->create; 50 | 51 | my $ua = TestFunctional->build_ua; 52 | 53 | $ua->get('/'); 54 | $ua->follow_link(text => 'Login'); 55 | 56 | $ua->submit_form(fields => {email => 'foo@bar.com', password => 'silly'}); 57 | 58 | return $ua; 59 | } 60 | 61 | done_testing; 62 | -------------------------------------------------------------------------------- /lib/Threads/Action/Index.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::Index; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | use List::Util qw(first); 9 | use Threads::DB::Thread; 10 | use Threads::DB::User; 11 | use Threads::DB::Tag; 12 | 13 | sub run { 14 | my $self = shift; 15 | 16 | my $by = $self->req->param('by'); 17 | $by = 'activity' unless $by && first { $by eq $_ } qw/activity popularity/; 18 | 19 | my $q = $self->req->param('q'); 20 | 21 | my $user_id = $self->req->param('user_id'); 22 | $user_id = undef unless $user_id && $user_id =~ m/^\d+$/; 23 | 24 | $self->throw_not_found 25 | if $user_id && !Threads::DB::User->new(id => $user_id)->load; 26 | 27 | my $tag = $self->req->param('tag'); 28 | $self->throw_not_found 29 | if $tag && !Threads::DB::Tag->new(title => $tag)->load; 30 | 31 | my $page = $self->req->param('page'); 32 | $page = 1 unless $page && $page =~ m/^\d+$/; 33 | 34 | my $page_size = $self->service('config')->{pagers}->{threads} || 10; 35 | 36 | $self->set_var( 37 | params => { 38 | q => $q, 39 | by => $by, 40 | tag => $tag, 41 | user_id => $user_id, 42 | page => $page, 43 | page_size => $page_size 44 | } 45 | ); 46 | 47 | return; 48 | } 49 | 50 | 1; 51 | -------------------------------------------------------------------------------- /lib/Threads/ObjectACL.pm: -------------------------------------------------------------------------------- 1 | package Threads::ObjectACL; 2 | 3 | use strict; 4 | use warnings; 5 | use attrs; 6 | 7 | use Scalar::Util qw(blessed); 8 | use Threads::DB::Thread; 9 | use Threads::DB::Reply; 10 | 11 | sub is_author { 12 | my $self = shift; 13 | my ($user, $object) = @_; 14 | 15 | return 0 unless $user; 16 | 17 | $object = $object->to_hash if blessed $object; 18 | 19 | return 0 unless $user->id == $object->{user_id}; 20 | 21 | return 1; 22 | } 23 | 24 | sub is_allowed { 25 | my $self = shift; 26 | my ($user, $object, $action) = @_; 27 | 28 | return 0 unless $user; 29 | 30 | $object = $object->to_hash if blessed $object; 31 | 32 | return 0 unless $user->id == $object->{user_id} || $user->role eq 'admin'; 33 | 34 | if ($action eq 'update_thread') { 35 | return 1; 36 | } 37 | elsif ($action eq 'delete_thread') { 38 | return 0 39 | unless my $thread = 40 | Threads::DB::Thread->new(id => $object->{id})->load; 41 | return 1 if $thread->count_related('replies') == 0; 42 | } 43 | elsif ($action eq 'update_reply' || $action eq 'delete_reply') { 44 | return 0 45 | unless my $reply = Threads::DB::Reply->new(id => $object->{id})->load; 46 | return 1 if $reply->count_related('ansestors') == 0; 47 | } 48 | 49 | return 0; 50 | } 51 | 52 | 1; 53 | -------------------------------------------------------------------------------- /t/action/settings.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | use TestDB; 7 | use TestRequest; 8 | 9 | use HTTP::Request::Common; 10 | use Threads::DB::User; 11 | use Threads::Action::Settings; 12 | 13 | subtest 'update settings with checkbox' => sub { 14 | TestDB->setup; 15 | 16 | my $user = TestDB->create('User'); 17 | my $action = _build_action( 18 | req => POST( 19 | '/' => {email_notifications => 'on'} 20 | ), 21 | 'tu.user' => $user 22 | ); 23 | 24 | $action->run; 25 | 26 | $user->load; 27 | 28 | is $user->email_notifications, '1'; 29 | }; 30 | 31 | subtest 'redirects' => sub { 32 | TestDB->setup; 33 | 34 | my $user = TestDB->create('User'); 35 | my $action = _build_action( 36 | req => POST( 37 | '/' => {name => 'foo'} 38 | ), 39 | 'tu.user' => $user 40 | ); 41 | $action->mock('redirect'); 42 | 43 | $action->run; 44 | 45 | my ($name) = $action->mocked_call_args('redirect'); 46 | 47 | is $name, 'index'; 48 | }; 49 | 50 | sub _build_action { 51 | my (%params) = @_; 52 | 53 | my $env = $params{env} || TestRequest->to_env(%params); 54 | 55 | my $action = Threads::Action::Settings->new(env => $env); 56 | $action = Test::MonkeyMock->new($action); 57 | 58 | return $action; 59 | } 60 | 61 | done_testing; 62 | -------------------------------------------------------------------------------- /lib/Threads/Action/ToggleReport.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ToggleReport; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action'; 7 | 8 | use Threads::ObjectACL; 9 | use Threads::DB::User; 10 | use Threads::DB::Reply; 11 | use Threads::DB::Report; 12 | 13 | sub run { 14 | my $self = shift; 15 | 16 | my $reply_id = $self->captures->{id}; 17 | return $self->new_json_response(404) 18 | unless my $reply = Threads::DB::Reply->new(id => $reply_id)->load; 19 | 20 | my $user = $self->scope->user; 21 | 22 | return $self->new_json_response(404) 23 | if Threads::ObjectACL->new->is_author($user, $reply); 24 | 25 | my $report = Threads::DB::Report->find( 26 | first => 1, 27 | where => [ 28 | user_id => $user->id, 29 | reply_id => $reply->id 30 | ] 31 | ); 32 | 33 | my $state; 34 | if ($report) { 35 | $report->delete; 36 | $state = 0; 37 | } 38 | else { 39 | Threads::DB::Report->new( 40 | user_id => $user->id, 41 | reply_id => $reply->id 42 | )->create; 43 | $state = 1; 44 | } 45 | 46 | my $count = 47 | Threads::DB::Report->table->count(where => [reply_id => $reply->id]); 48 | $reply->reports_count($count); 49 | $reply->update; 50 | 51 | return $self->new_json_response(200, {count => $count, state => $state}); 52 | } 53 | 54 | 1; 55 | -------------------------------------------------------------------------------- /templates/include/quick-edit-form.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 17 | 18 |
    19 |
  • 20 | 21 | <%== $helpers->form->textarea('content', default => $reply->{content}) %> 22 | 23 |
  • 24 |
  • 25 |
  • 26 |
27 | 28 | <%= loc('or') %> CTRL+Enter 29 | 30 | %== $helpers->displayer->render('include/markup-help-button'); 31 | 32 |
33 | 34 |
35 | 36 | -------------------------------------------------------------------------------- /t/action/logout.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | use TestDB; 7 | use TestRequest; 8 | 9 | use Threads::DB::Nonce; 10 | use Threads::Action::Logout; 11 | 12 | subtest 'calls logout' => sub { 13 | TestDB->setup; 14 | 15 | my $nonce = Threads::DB::Nonce->new(user_id => 1)->create; 16 | 17 | my $auth = Test::MonkeyMock->new; 18 | $auth->mock(logout => sub { }); 19 | $auth->mock(session => sub { { id => $nonce->id } }); 20 | 21 | my $env = TestRequest->to_env('tu.auth' => $auth); 22 | 23 | my $action = _build_action(env => $env); 24 | 25 | $action->run; 26 | 27 | ok $auth->mocked_called('logout'); 28 | }; 29 | 30 | subtest 'deletes nonce' => sub { 31 | TestDB->setup; 32 | 33 | my $nonce = Threads::DB::Nonce->new(user_id => 1)->create; 34 | 35 | my $auth = Test::MonkeyMock->new; 36 | $auth->mock(logout => sub { }); 37 | $auth->mock(session => sub { { id => $nonce->id } }); 38 | 39 | my $env = TestRequest->to_env('tu.auth' => $auth); 40 | 41 | my $action = _build_action(env => $env); 42 | 43 | $action->run; 44 | 45 | ok !$nonce->load; 46 | }; 47 | 48 | sub _build_action { 49 | my (%params) = @_; 50 | 51 | my $env = $params{env} || TestRequest->to_env(%params); 52 | 53 | my $action = Threads::Action::Logout->new(env => $env); 54 | $action = Test::MonkeyMock->new($action); 55 | 56 | return $action; 57 | } 58 | 59 | done_testing; 60 | -------------------------------------------------------------------------------- /templates/include/pager.apl: -------------------------------------------------------------------------------- 1 | % my $pager = $helpers->pager->build(base_url => $base_url, query_params => var('query_params'), total => $total); 2 | % if (%$pager) { 3 |
4 | % if (my $first_page = $pager->{first_page}) { 5 | 6 | % } else { 7 | 8 | % } 9 | 10 | % if (my $prev_page = $pager->{prev_page}) { 11 | 12 | % } else { 13 | 14 | % } 15 | 16 | % if (my $next_page = $pager->{next_page}) { 17 | 18 | % } else { 19 | 20 | % } 21 | 22 | % if (my $last_page = $pager->{last_page}) { 23 | 24 | % } else { 25 | 26 | % } 27 |
28 | % } 29 | 30 | -------------------------------------------------------------------------------- /lib/Threads/Action/ThankReply.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ThankReply; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action'; 7 | 8 | use Threads::ObjectACL; 9 | use Threads::DB::User; 10 | use Threads::DB::Reply; 11 | use Threads::DB::Thank; 12 | 13 | sub run { 14 | my $self = shift; 15 | 16 | my $reply_id = $self->captures->{id}; 17 | return $self->new_json_response(404) 18 | unless my $reply = Threads::DB::Reply->new(id => $reply_id)->load; 19 | 20 | my $user = $self->scope->user; 21 | 22 | my $count = 23 | Threads::DB::Thank->table->count(where => [reply_id => $reply->id]); 24 | 25 | return $self->new_json_response(404) 26 | if Threads::ObjectACL->new->is_author($user, $reply); 27 | 28 | my $thank = Threads::DB::Thank->find( 29 | first => 1, 30 | where => [ 31 | user_id => $user->id, 32 | reply_id => $reply->id 33 | ] 34 | ); 35 | 36 | my $state; 37 | if ($thank) { 38 | $thank->delete; 39 | 40 | $state = 0; 41 | 42 | $count--; 43 | } 44 | else { 45 | Threads::DB::Thank->new( 46 | user_id => $user->id, 47 | reply_id => $reply->id 48 | )->create; 49 | 50 | $count++; 51 | 52 | $state = 1; 53 | } 54 | 55 | $reply->thanks_count($count); 56 | $reply->update; 57 | 58 | return $self->new_json_response(200, 59 | {count => $count == 0 ? '' : $count, state => $state}); 60 | } 61 | 62 | 1; 63 | -------------------------------------------------------------------------------- /schema/00schema.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `users`; 2 | CREATE TABLE `users` ( 3 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | `email` VARCHAR(255) NOT NULL, 5 | `password` VARCHAR(32) NOT NULL DEFAULT '', 6 | `name` VARCHAR(32) NOT NULL DEFAULT '', 7 | `status` VARCHAR(255) NOT NULL DEFAULT 'new', 8 | `created` integer(4) not null default (strftime('%s','now')), 9 | UNIQUE (`email`) 10 | ); 11 | 12 | DROP TABLE IF EXISTS `confirmations`; 13 | CREATE TABLE `confirmations` ( 14 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 15 | `user_id` INT NOT NULL, 16 | `token` VARCHAR(32) NOT NULL DEFAULT '', 17 | `created` integer(4) not null default (strftime('%s','now')), 18 | UNIQUE (`token`) 19 | ); 20 | 21 | DROP TABLE IF EXISTS `threads`; 22 | CREATE TABLE `threads` ( 23 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 24 | `user_id` INT NOT NULL, 25 | `created` integer(4) not null default (strftime('%s','now')), 26 | `title` VARCHAR(32) NOT NULL DEFAULT '', 27 | `replies_count` INT NOT NULL DEFAULT 0, 28 | `content` TEXT NOT NULL DEFAULT '' 29 | ); 30 | 31 | DROP TABLE IF EXISTS `replies`; 32 | CREATE TABLE `replies` ( 33 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 34 | `user_id` INT NOT NULL, 35 | `thread_id` INT NOT NULL, 36 | `parent_id` INT NOT NULL DEFAULT 0, 37 | `level` integer not null, 38 | `lft` integer not null, 39 | `rgt` integer not null, 40 | `created` integer(4) not null default (strftime('%s','now')), 41 | `content` TEXT NOT NULL DEFAULT '' 42 | ); 43 | -------------------------------------------------------------------------------- /templates/include/reply-meta.apl: -------------------------------------------------------------------------------- 1 |
{unread} %>" data-read-reply="<%= $helpers->url->read_reply(id => $reply->{id}) %>"> 2 |
3 | 4 |
5 | %== $helpers->gravatar->img($reply->{user}); 6 |
7 | <%== $helpers->user->display_name($reply->{user}) %> 8 | % if ($reply->{parent}) { 9 | → <%== $helpers->user->display_name($reply->{parent}->{user}) %> 10 | % } 11 |
12 |
13 | <%= $helpers->date->format($reply->{created}) %> 14 | % if ($helpers->date->is_distant_update($reply)) { 15 | <%= loc('upd.') %> <%= $helpers->date->format($reply->{updated}) %> 16 | % } 17 | # 18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /t/jobs/cleanup_inactive_registrations.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use Test::Fatal; 6 | use TestLib; 7 | use TestDB; 8 | 9 | use Threads::DB::User; 10 | use Threads::Job::CleanupInactiveRegistrations; 11 | 12 | subtest 'not delete active registrations' => sub { 13 | TestDB->setup; 14 | 15 | _create_user(status => 'active'); 16 | 17 | my $job = _build_job(); 18 | $job->run; 19 | 20 | is(Threads::DB::User->table->count, 1); 21 | }; 22 | 23 | subtest 'not delete new not active registrations' => sub { 24 | TestDB->setup; 25 | 26 | _create_user(status => 'new'); 27 | 28 | my $job = _build_job(); 29 | $job->run; 30 | 31 | is(Threads::DB::User->table->count, 1); 32 | }; 33 | 34 | subtest 'delete old not active registrations' => sub { 35 | TestDB->setup; 36 | 37 | _create_user(status => 'new', created => time - 7 * 24 * 3600); 38 | 39 | my $job = _build_job(); 40 | $job->run; 41 | 42 | is(Threads::DB::User->table->count, 0); 43 | }; 44 | 45 | subtest 'do not delete when dry-run' => sub { 46 | TestDB->setup; 47 | 48 | _create_user(status => 'new', created => time - 7 * 24 * 3600); 49 | 50 | my $job = _build_job(dry_run => 1); 51 | $job->run; 52 | 53 | is(Threads::DB::User->table->count, 1); 54 | }; 55 | 56 | sub _create_user { TestDB->create('User', email => int(rand(100)), @_) } 57 | 58 | sub _build_job { 59 | my (%params) = @_; 60 | 61 | return Threads::Job::CleanupInactiveRegistrations->new(%params); 62 | } 63 | 64 | done_testing; 65 | -------------------------------------------------------------------------------- /public/tagsinput/jquery.tagsinput.css: -------------------------------------------------------------------------------- 1 | div.tagsinput { 2 | overflow-y: auto; 3 | 4 | background-color: white; 5 | border: 1px solid; 6 | border-color: #848484 #c1c1c1 #e1e1e1; 7 | color: black; 8 | outline: 0; 9 | margin: 0; 10 | padding: 2px 3px; 11 | text-align: left; 12 | font-size: 13px; 13 | font-family: Arial, "Liberation Sans", FreeSans, sans-serif; 14 | height: 1.8em; 15 | vertical-align: top; 16 | *padding-top: 2px; 17 | *padding-bottom: 1px; 18 | *height: auto; 19 | font-size: 15px; 20 | } 21 | div.tagsinput span.tag { 22 | border: 1px solid #a5d24a; 23 | -moz-border-radius:2px; 24 | -webkit-border-radius:2px; 25 | display: block; 26 | float: left; 27 | text-decoration:none; 28 | background: #cde69c; 29 | color: #638421; 30 | margin-right: 5px; 31 | padding-left:2px; 32 | padding-bottom:0px; 33 | font-size: 13px; 34 | } 35 | div.tagsinput span.tag a { 36 | font-weight: bold; 37 | color: #82ad2b; 38 | text-decoration:none; 39 | font-size: 11px; 40 | } 41 | div.tagsinput input { 42 | -webkit-box-shadow: none; 43 | -moz-box-shadow: none; 44 | box-shadow: none; 45 | z-index:auto; 46 | 47 | width:80px; 48 | margin:0px; 49 | border:1px solid transparent; 50 | background: transparent; 51 | color: #000; 52 | outline:0px; 53 | } 54 | div.tagsinput div { display:block; float: left; } 55 | .tags_clear { clear: both; width: 100%; height: 0px; } 56 | .not_valid {background: #FBD8DB !important; color: #90111A !important;} 57 | -------------------------------------------------------------------------------- /templates/index.apl: -------------------------------------------------------------------------------- 1 | % $helpers->assets->require('/js/quick-reply.js'); 2 | 3 |
4 | 5 |
6 | <%= loc('Sort') %> 7 |
8 | <%== $helpers->form->input('q', type => 'hidden', value => $params->{q}) %> 9 | <%== $helpers->form->select('by', options => [activity => loc('by activity'), popularity => loc('by popularity')]) %> 10 |
11 | 12 | {tag}" : '' %>"> 13 |
14 | 15 | 21 | 22 |
23 | 24 | <%= $helpers->thread->count %> 25 | 26 | 27 | <%= $helpers->reply->count %> 28 | 29 | 30 | <%= $helpers->user->count %> 31 |
32 | 33 |
34 | 35 | % foreach my $thread ($helpers->thread->find) { 36 | %== $helpers->displayer->render('include/thread', thread => $thread); 37 | % } 38 | 39 | %== $helpers->displayer->render('include/pager', base_url => $helpers->url->index, query_params => ['q', 'by', 'user_id', 'tag'], total => $helpers->thread->count); 40 |
41 | -------------------------------------------------------------------------------- /util/run-job.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use FindBin '$RealBin'; 7 | 8 | BEGIN { 9 | unshift @INC, "$RealBin/../lib"; 10 | unshift @INC, "$_/lib" for glob "$RealBin/../contrib/*"; 11 | } 12 | 13 | use Fcntl qw(:flock); 14 | use Getopt::Long; 15 | use String::CamelCase qw(camelize); 16 | use Tu::Config; 17 | use Tu::Loader; 18 | use Threads::DB; 19 | 20 | my $verbose; 21 | my $dry_run; 22 | my $lock; 23 | GetOptions('verbose' => \$verbose, 'dry-run' => \$dry_run, 'lock' => \$lock) 24 | or die("Error in command line arguments\n"); 25 | 26 | my $config = Tu::Config->new(mode => 1)->load("$RealBin/../config/config.yml"); 27 | Threads::DB->init_db(%{$config->{database}}); 28 | 29 | my $loader = Tu::Loader->new; 30 | 31 | foreach my $job_name (@ARGV) { 32 | my $job_class = 'Threads::Job::' . camelize($job_name); 33 | $loader->try_load_class($job_class) or die "Can't find job '$job_name'"; 34 | 35 | my $locked_fh = $lock ? _lock_class($job_class) : undef; 36 | 37 | $job_class->new( 38 | config => $config, 39 | dry_run => $dry_run, 40 | verbose => $verbose 41 | )->run; 42 | 43 | _unlock_fh($locked_fh) if $lock; 44 | } 45 | 46 | sub _lock_class { 47 | my ($job_class) = @_; 48 | 49 | my $job_file = (join '/', split /::/, $job_class) . '.pm'; 50 | 51 | my $file = $INC{$job_file}; 52 | open my $fh, '<', $file or die $!; 53 | flock $fh, LOCK_EX | LOCK_NB or die "Job already running"; 54 | 55 | return $fh; 56 | } 57 | 58 | sub _unlock_fh { 59 | my ($fh) = @_; 60 | 61 | close $fh; 62 | } 63 | -------------------------------------------------------------------------------- /lib/Threads/Action/ResetPassword.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ResetPassword; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action::FormBase'; 7 | 8 | use Threads::DB::User; 9 | use Threads::DB::Confirmation; 10 | 11 | sub build_validator { 12 | my $self = shift; 13 | 14 | my $validator = $self->SUPER::build_validator; 15 | 16 | $validator->add_field('new_password'); 17 | $validator->add_field('new_password_confirmation'); 18 | 19 | $validator->add_group_rule('new_password', 20 | [qw/new_password new_password_confirmation/], 'compare'); 21 | 22 | return $validator; 23 | } 24 | 25 | sub run { 26 | my $self = shift; 27 | 28 | my $token = $self->captures->{token}; 29 | $self->throw_not_found unless $token; 30 | 31 | my $confirmation = 32 | Threads::DB::Confirmation->find_fresh_by_token($token, 'reset_password'); 33 | $self->throw_not_found unless $confirmation; 34 | 35 | my $user = 36 | Threads::DB::User->new(id => $confirmation->user_id)->load; 37 | 38 | $self->throw_not_found unless $user; 39 | 40 | $self->{confirmation} = $confirmation; 41 | $self->{user} = $user; 42 | 43 | return $self->SUPER::run; 44 | } 45 | 46 | sub submit { 47 | my $self = shift; 48 | my ($params) = @_; 49 | 50 | my $user = $self->{user}; 51 | 52 | $user->update_password($params->{new_password}); 53 | 54 | Threads::DB::Confirmation->table->delete( 55 | where => [user_id => $user->id, type => 'reset_password']); 56 | 57 | return $self->render('password_reset_success'); 58 | } 59 | 60 | 1; 61 | -------------------------------------------------------------------------------- /t/db/user.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use utf8; 4 | 5 | use Test::More; 6 | use TestDB; 7 | 8 | use Threads::DB::Nonce; 9 | use Threads::DB::User; 10 | 11 | subtest 'hashes password' => sub { 12 | TestDB->setup; 13 | 14 | isnt(Threads::DB::User->new->hash_password('foo', 'salt'), 'foo'); 15 | }; 16 | 17 | subtest 'hashes unicode password' => sub { 18 | TestDB->setup; 19 | 20 | isnt(Threads::DB::User->new->hash_password('привет', 'salt'), 'привет'); 21 | }; 22 | 23 | subtest 'hashes password on create' => sub { 24 | TestDB->setup; 25 | 26 | my $user = TestDB->create('User', password => 'bar'); 27 | 28 | isnt $user->password, 'bar'; 29 | isnt $user->salt, ''; 30 | }; 31 | 32 | subtest 'checks password' => sub { 33 | TestDB->setup; 34 | 35 | my $user = TestDB->create('User', password => 'bar'); 36 | 37 | ok $user->check_password('bar'); 38 | ok !$user->check_password('baz'); 39 | }; 40 | 41 | subtest 'updates password' => sub { 42 | TestDB->setup; 43 | 44 | my $user = TestDB->create('User', password => 'old'); 45 | 46 | $user->update_password('new'); 47 | 48 | ok !$user->check_password('old'); 49 | ok $user->check_password('new'); 50 | }; 51 | 52 | subtest 'changes salt on update' => sub { 53 | TestDB->setup; 54 | 55 | my $user = TestDB->create('User', password => 'old'); 56 | 57 | my $old_salt = $user->salt; 58 | $user->update_password('new'); 59 | my $new_salt = $user->salt; 60 | 61 | isnt $old_salt, $new_salt; 62 | }; 63 | 64 | sub _build_user { 65 | Threads::DB::User->new(@_); 66 | } 67 | 68 | done_testing; 69 | -------------------------------------------------------------------------------- /templates/include/quick-reply-form.apl: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 17 | 18 |
    19 |
  • 20 | 21 | % if (my $reply = var('reply')) { 22 | 23 | % } 24 | 25 | <%== $helpers->form->textarea('content') %> 26 | 27 |
  • 28 |
  • 29 |
  • 30 |
31 | 32 | <%= loc('or') %> CTRL+Enter 33 | 34 | %== $helpers->displayer->render('include/markup-help-button'); 35 | 36 |
37 | 38 |
39 | 40 | -------------------------------------------------------------------------------- /t/action/delete_notifications.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use Test::Fatal; 6 | use TestLib; 7 | use TestDB; 8 | use TestRequest; 9 | 10 | use JSON qw(decode_json); 11 | use HTTP::Request::Common; 12 | use Threads::DB::User; 13 | use Threads::DB::Notification; 14 | use Threads::Action::DeleteNotifications; 15 | 16 | subtest 'deletes all notifications' => sub { 17 | TestDB->setup; 18 | 19 | my $user = Threads::DB::User->new(email => 'foo@bar.com', password => 'silly')->create; 20 | Threads::DB::Notification->new(user_id => $user->id, reply_id => 1)->create; 21 | Threads::DB::Notification->new(user_id => 123, reply_id => 1)->create; 22 | 23 | my $action = _build_action(req => POST('/' => {}), captures => {}, 'tu.user' => $user); 24 | 25 | $action->run; 26 | 27 | is(Threads::DB::Notification->table->count, 1); 28 | }; 29 | 30 | subtest 'returns redirect' => sub { 31 | TestDB->setup; 32 | 33 | my $user = Threads::DB::User->new(email => 'foo@bar.com', password => 'silly')->create; 34 | Threads::DB::Notification->new(user_id => $user->id, reply_id => 1)->create; 35 | 36 | my $action = _build_action(req => POST('/' => {}), captures => {}, 'tu.user' => $user); 37 | 38 | my $res = $action->run; 39 | 40 | ok decode_json($res->body)->{redirect}; 41 | }; 42 | 43 | sub _build_action { 44 | my (%params) = @_; 45 | 46 | my $env = $params{env} || TestRequest->to_env(%params); 47 | 48 | my $action = Threads::Action::DeleteNotifications->new(env => $env); 49 | $action = Test::MonkeyMock->new($action); 50 | 51 | return $action; 52 | } 53 | 54 | done_testing; 55 | -------------------------------------------------------------------------------- /t/action/delete_subscriptions.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use Test::Fatal; 6 | use TestLib; 7 | use TestDB; 8 | use TestRequest; 9 | 10 | use JSON qw(decode_json); 11 | use HTTP::Request::Common; 12 | use Threads::DB::User; 13 | use Threads::DB::Subscription; 14 | use Threads::Action::DeleteSubscriptions; 15 | 16 | subtest 'deletes all subscriptions' => sub { 17 | TestDB->setup; 18 | 19 | my $user = Threads::DB::User->new(email => 'foo@bar.com', password => 'silly')->create; 20 | Threads::DB::Subscription->new(user_id => $user->id, thread_id => 1)->create; 21 | Threads::DB::Subscription->new(user_id => 123, thread_id => 1)->create; 22 | 23 | my $action = _build_action(req => POST('/' => {}), captures => {}, 'tu.user' => $user); 24 | 25 | $action->run; 26 | 27 | is(Threads::DB::Subscription->table->count, 1); 28 | }; 29 | 30 | subtest 'returns redirect' => sub { 31 | TestDB->setup; 32 | 33 | my $user = Threads::DB::User->new(email => 'foo@bar.com', password => 'silly')->create; 34 | Threads::DB::Subscription->new(user_id => $user->id, thread_id => 1)->create; 35 | 36 | my $action = _build_action(req => POST('/' => {}), captures => {}, 'tu.user' => $user); 37 | 38 | my $res = $action->run; 39 | 40 | ok decode_json($res->body)->{redirect}; 41 | }; 42 | 43 | sub _build_action { 44 | my (%params) = @_; 45 | 46 | my $env = $params{env} || TestRequest->to_env(%params); 47 | 48 | my $action = Threads::Action::DeleteSubscriptions->new(env => $env); 49 | $action = Test::MonkeyMock->new($action); 50 | 51 | return $action; 52 | } 53 | 54 | done_testing; 55 | -------------------------------------------------------------------------------- /public/jquery-ui/jquery-ui.structure.min.css: -------------------------------------------------------------------------------- 1 | /*! jQuery UI - v1.11.2 - 2015-01-12 2 | * http://jqueryui.com 3 | * Copyright 2015 jQuery Foundation and other contributors; Licensed MIT */ 4 | 5 | .ui-helper-hidden{display:none}.ui-helper-hidden-accessible{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.ui-helper-reset{margin:0;padding:0;border:0;outline:0;line-height:1.3;text-decoration:none;font-size:100%;list-style:none}.ui-helper-clearfix:before,.ui-helper-clearfix:after{content:"";display:table;border-collapse:collapse}.ui-helper-clearfix:after{clear:both}.ui-helper-clearfix{min-height:0}.ui-helper-zfix{width:100%;height:100%;top:0;left:0;position:absolute;opacity:0;filter:Alpha(Opacity=0)}.ui-front{z-index:100}.ui-state-disabled{cursor:default!important}.ui-icon{display:block;text-indent:-99999px;overflow:hidden;background-repeat:no-repeat}.ui-widget-overlay{position:fixed;top:0;left:0;width:100%;height:100%}.ui-autocomplete{position:absolute;top:0;left:0;cursor:default}.ui-menu{list-style:none;padding:0;margin:0;display:block;outline:none}.ui-menu .ui-menu{position:absolute}.ui-menu .ui-menu-item{position:relative;margin:0;padding:3px 1em 3px .4em;cursor:pointer;min-height:0;list-style-image:url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")}.ui-menu .ui-menu-divider{margin:5px 0;height:0;font-size:0;line-height:0;border-width:1px 0 0 0}.ui-menu .ui-state-focus,.ui-menu .ui-state-active{margin:-1px}.ui-menu-icons{position:relative}.ui-menu-icons .ui-menu-item{padding-left:2em}.ui-menu .ui-icon{position:absolute;top:0;bottom:0;left:.2em;margin:auto 0}.ui-menu .ui-menu-icon{left:auto;right:0} -------------------------------------------------------------------------------- /public/js/actions.js: -------------------------------------------------------------------------------- 1 | (function(global){ 2 | 3 | function NoCountAction() { 4 | return this; 5 | }; 6 | NoCountAction.prototype.get = function() { 7 | var counter = $('.notification-count'); 8 | if (counter.length) 9 | return +counter.text(); 10 | else 11 | return 0; 12 | }; 13 | NoCountAction.prototype.update = function(count) { 14 | if (count) { 15 | var counter = $('.notification-count'); 16 | if (!counter.length) { 17 | $('.notification-count-outer').append(''); 18 | } 19 | 20 | $('.notification-count').text(count); 21 | } 22 | else { 23 | $('.notification-count').remove(); 24 | } 25 | }; 26 | 27 | function NoCountTitleAction() { 28 | return this; 29 | }; 30 | NoCountTitleAction.prototype.get = function() { 31 | var title = document.title; 32 | 33 | var re = /^\((\d+)\)\s+/; 34 | var match = re.exec(title); 35 | if (match && match.length) { 36 | return +match[1]; 37 | } 38 | 39 | return 0; 40 | }; 41 | 42 | NoCountTitleAction.prototype.update = function(count) { 43 | var old_title = document.title; 44 | var new_title = old_title.replace(/^\(\d+\)\s+/, ''); 45 | 46 | if (count) { 47 | new_title = '(' + count + ') ' + new_title; 48 | } 49 | 50 | document.title = new_title; 51 | }; 52 | 53 | global.NoCountAction = NoCountAction; 54 | global.NoCountTitleAction = NoCountTitleAction; 55 | })(this); 56 | -------------------------------------------------------------------------------- /t/helper/gravatar.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | 7 | use Threads::Helper::Gravatar; 8 | 9 | subtest 'returns gravatar when in production' => sub { 10 | my $helper = _build_helper(); 11 | 12 | local $ENV{PLACK_ENV} = 'production'; 13 | is $helper->img({email => 'foo@bar.com', status => 'active'}), 14 | ''; 15 | }; 16 | 17 | subtest 'returns default gravatar when in development' => sub { 18 | my $helper = _build_helper(); 19 | 20 | is $helper->img({email => 'foo@bar.com', status => 'active'}), 21 | ''; 22 | }; 23 | 24 | subtest 'accepts size in development' => sub { 25 | my $helper = _build_helper(); 26 | 27 | is $helper->img({email => 'foo@bar.com', status => 'active'}, 20), 28 | ''; 29 | }; 30 | 31 | subtest 'accepts size in production' => sub { 32 | my $helper = _build_helper(); 33 | 34 | local $ENV{PLACK_ENV} = 'production'; 35 | is $helper->img({email => 'foo@bar.com', status => 'active'}, 20), 36 | ''; 37 | }; 38 | 39 | subtest 'returns default gravatar when user deleted' => sub { 40 | my $helper = _build_helper(); 41 | 42 | local $ENV{PLACK_ENV} = 'production'; 43 | is $helper->img({email => 'foo@bar.com', status => 'deleted'}), 44 | ''; 45 | }; 46 | 47 | my $env = {}; 48 | 49 | sub _build_helper { 50 | Threads::Helper::Gravatar->new(env => $env); 51 | } 52 | 53 | done_testing; 54 | -------------------------------------------------------------------------------- /t/functional/replies.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use Test::WWW::Mechanize::PSGI; 6 | use TestLib; 7 | use TestDB; 8 | use TestFunctional; 9 | 10 | use Threads; 11 | use Threads::DB::User; 12 | 13 | subtest 'shows 404 when unknown thread' => sub { 14 | TestDB->setup; 15 | 16 | my $ua = _build_loggedin_ua(); 17 | 18 | my $res = $ua->get('/threads/123/reply'); 19 | 20 | is $res->code, 404; 21 | }; 22 | 23 | #subtest 'shows validation errors' => sub { 24 | # TestDB->setup; 25 | # 26 | # my $ua = _build_loggedin_ua(); 27 | # 28 | # $ua->follow_link(text_regex => qr/Create thread/); 29 | # $ua->submit_form(fields => {title => 'foo', content => 'bar'}); 30 | # 31 | # $ua->submit_form(fields => {}); 32 | # 33 | # like $ua->content, qr/Required/; 34 | #}; 35 | # 36 | #subtest 'redirects after creation' => sub { 37 | # TestDB->setup; 38 | # 39 | # my $ua = _build_loggedin_ua(); 40 | # 41 | # $ua->follow_link(text_regex => qr/Create thread/); 42 | # $ua->submit_form(fields => {title => 'foo', content => 'bar'}); 43 | # 44 | # $ua->submit_form(fields => {content => 'my reply'}, button => 'reply'); 45 | # 46 | # like $ua->content, qr/my reply/; 47 | #}; 48 | 49 | sub _build_loggedin_ua { 50 | Threads::DB::User->new( 51 | email => 'foo@bar.com', 52 | password => 'silly', 53 | status => 'active' 54 | )->create; 55 | 56 | my $ua = _build_ua(); 57 | 58 | $ua->get('/'); 59 | $ua->follow_link(text => 'Login'); 60 | 61 | $ua->submit_form(fields => {email => 'foo@bar.com', password => 'silly'}); 62 | 63 | return $ua; 64 | } 65 | 66 | sub _build_ua { TestFunctional->build_ua } 67 | 68 | done_testing; 69 | -------------------------------------------------------------------------------- /templates/include/reply-controls.apl: -------------------------------------------------------------------------------- 1 |
2 | % if ($helpers->acl->is_user) { 3 | 4 | % } 5 | 6 | % if ($helpers->acl->is_allowed($reply, 'update_reply')) { 7 | 8 | % } 9 | % if ($helpers->acl->is_allowed($reply, 'delete_reply')) { 10 |
11 | 12 |
13 | % } 14 | 15 | % if (0) { 16 | % if ($helpers->acl->is_user && !$helpers->acl->is_author($reply)) { 17 | % my $is_flagged = $helpers->reply->is_flagged($reply); 18 | % my $current_class = $is_flagged ? 'fa-flag' : 'fa-flag-o'; 19 | 20 |
21 | 22 | 23 |
24 | % } 25 | % } 26 | 27 | %== $helpers->displayer->render('include/quick-reply-form', thread => $thread, reply => var('reply')); 28 | %== $helpers->displayer->render('include/quick-edit-form', reply => var('reply')); 29 |
30 | 31 | -------------------------------------------------------------------------------- /tjs/actions/no_count.js: -------------------------------------------------------------------------------- 1 | QUnit.module('actions/no_count'); 2 | QUnit.test("return 0 when no element", function(assert) { 3 | var action = new NoCountAction(); 4 | 5 | var count = action.get(); 6 | 7 | assert.ok(count === 0); 8 | assert.equal(count, 0); 9 | }); 10 | 11 | QUnit.test("return current value", function(assert) { 12 | var action = new NoCountAction(); 13 | 14 | $('' 15 | + '6' 16 | + '').appendTo('#qunit-fixture'); 17 | 18 | var count = action.get(); 19 | 20 | assert.ok(count === 6); 21 | assert.equal(count, 6); 22 | }); 23 | 24 | QUnit.test("insert new value", function(assert) { 25 | var action = new NoCountAction(); 26 | 27 | $('' 28 | + '').appendTo('#qunit-fixture'); 29 | 30 | action.update(10); 31 | 32 | assert.ok($('.notification-count').length); 33 | assert.equal(action.get(), 10); 34 | }); 35 | 36 | QUnit.test("update to new value", function(assert) { 37 | var action = new NoCountAction(); 38 | 39 | $('' 40 | + '6' 41 | + '').appendTo('#qunit-fixture'); 42 | 43 | action.update(10); 44 | 45 | assert.equal(action.get(), 10); 46 | }); 47 | 48 | QUnit.test("remove when zero", function(assert) { 49 | var action = new NoCountAction(); 50 | 51 | $('' 52 | + '6' 53 | + '').appendTo('#qunit-fixture'); 54 | 55 | action.update(0); 56 | 57 | var el = $('.notification-count'); 58 | 59 | assert.equal(el.length, 0); 60 | }); 61 | -------------------------------------------------------------------------------- /t/db/confirmation.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestDB; 6 | 7 | use Threads::DB::Confirmation; 8 | use Threads::Util qw(to_hex); 9 | 10 | subtest 'creates with token' => sub { 11 | TestDB->setup; 12 | 13 | my $confirmation = TestDB->create('Confirmation', user_id => 1); 14 | 15 | isnt $confirmation->token, ''; 16 | }; 17 | 18 | subtest 'finds fresh token' => sub { 19 | TestDB->setup; 20 | 21 | my $confirmation = 22 | TestDB->create('Confirmation', user_id => 1, type => 'register'); 23 | 24 | ok $confirmation->find_fresh_by_token(to_hex $confirmation->token, 25 | 'register'); 26 | }; 27 | 28 | subtest 'not finds old token' => sub { 29 | TestDB->setup; 30 | 31 | my $confirmation = TestDB->create( 32 | 'Confirmation', 33 | user_id => 1, 34 | created => 123, 35 | type => 'register' 36 | ); 37 | 38 | ok !$confirmation->find_fresh_by_token($confirmation->token, 39 | 'register'); 40 | }; 41 | 42 | subtest 'finds fresh token by user id' => sub { 43 | TestDB->setup; 44 | 45 | my $confirmation = 46 | TestDB->create('Confirmation', user_id => 1, type => 'register'); 47 | 48 | ok $confirmation->find_fresh_by_user_id(1, 'register'); 49 | }; 50 | 51 | subtest 'checks if confirmation is expired' => sub { 52 | TestDB->setup; 53 | 54 | my $confirmation = 55 | TestDB->create('Confirmation', user_id => 1, type => 'register'); 56 | 57 | $confirmation = $confirmation->find_by_token(to_hex $confirmation->token, 'register'); 58 | 59 | ok !$confirmation->is_expired; 60 | }; 61 | 62 | subtest 'not finds unknown token' => sub { 63 | TestDB->setup; 64 | 65 | ok !Threads::DB::Confirmation->find_fresh_by_token(123, 'type'); 66 | }; 67 | 68 | done_testing; 69 | -------------------------------------------------------------------------------- /lib/Threads/UserLoader.pm: -------------------------------------------------------------------------------- 1 | package Threads::UserLoader; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Threads::DB::User; 7 | use Threads::DB::Nonce; 8 | 9 | sub new { 10 | my $class = shift; 11 | my (%params) = @_; 12 | 13 | my $self = {}; 14 | bless $self, $class; 15 | 16 | $self->{finalize} = $params{finalize}; 17 | $self->{finalize} = 1 unless defined $self->{finalize}; 18 | 19 | return $self; 20 | } 21 | 22 | sub load { 23 | my $self = shift; 24 | my ($options) = @_; 25 | 26 | return 27 | unless my $nonce = Threads::DB::Nonce->new(id => $options->{id})->load; 28 | 29 | my $latest_nonce = Threads::DB::Nonce->find( 30 | first => 1, 31 | where => [user_id => $nonce->user_id], 32 | order_by => [id => 'DESC'] 33 | ); 34 | 35 | if (time - $nonce->created > 2 && $nonce->id ne $latest_nonce->id) { 36 | return; 37 | } 38 | 39 | my $user = Threads::DB::User->new(id => $nonce->user_id)->load; 40 | return unless $user && $user->status eq 'active'; 41 | 42 | return $user; 43 | } 44 | 45 | sub finalize { 46 | my $self = shift; 47 | my ($options) = @_; 48 | 49 | return unless $self->{finalize}; 50 | 51 | my $nonce = Threads::DB::Nonce->new(id => $options->{id})->load; 52 | return unless $nonce; 53 | 54 | Threads::DB::Nonce->table->delete( 55 | where => [ 56 | user_id => $nonce->user_id, 57 | id => {'!=' => $nonce->id}, 58 | created => {'<' => time - 2} 59 | ] 60 | ); 61 | 62 | if (time - $nonce->created > 2) { 63 | my $user_id = $nonce->user_id; 64 | $nonce->delete; 65 | 66 | my $new_nonce = Threads::DB::Nonce->new(user_id => $user_id)->create; 67 | $options->{id} = $new_nonce->id; 68 | } 69 | 70 | return; 71 | } 72 | 73 | 1; 74 | -------------------------------------------------------------------------------- /public/js/models.js: -------------------------------------------------------------------------------- 1 | (function(global){ 2 | 3 | function ValueObject(attrs) { 4 | this.attrs = attrs || {}; 5 | return this; 6 | }; 7 | 8 | ValueObject.prototype.set = function(key, value) { 9 | this.attrs[key] = value; 10 | }; 11 | 12 | ValueObject.prototype.get = function(key) { 13 | return this.attrs[key]; 14 | }; 15 | 16 | function ValueObjectObservable() { 17 | this.observers = {}; 18 | ValueObject.call(this); 19 | return this; 20 | }; 21 | ValueObjectObservable.prototype = Object.create(ValueObject.prototype); 22 | ValueObjectObservable.prototype.constructor = ValueObjectObservable; 23 | 24 | ValueObjectObservable.prototype.set = function(key, value) { 25 | var old = this.get(key); 26 | 27 | ValueObject.prototype.set.apply(this, arguments); 28 | 29 | if (old != value) { 30 | this.notify(key, value); 31 | } 32 | }; 33 | 34 | ValueObjectObservable.prototype.get = function(key) { 35 | return ValueObject.prototype.get.apply(this, arguments); 36 | }; 37 | 38 | ValueObjectObservable.prototype.onchange = function(key, fn) { 39 | if (typeof this.observers[key] === 'undefined') 40 | this.observers[key] = []; 41 | this.observers[key].push(fn); 42 | }; 43 | 44 | ValueObjectObservable.prototype.notify = function(key) { 45 | if (this.observers.hasOwnProperty(key)) { 46 | var observers = this.observers[key]; 47 | 48 | var args = Array.prototype.slice.call(arguments, 1); 49 | for (var i = 0; i < observers.length; i++) { 50 | observers[i].apply(null, args); 51 | } 52 | } 53 | }; 54 | 55 | global.ValueObject = ValueObject; 56 | global.ValueObjectObservable = ValueObjectObservable; 57 | })(this); 58 | -------------------------------------------------------------------------------- /templates/threads_rss.apl: -------------------------------------------------------------------------------- 1 | % my $config = $helpers->config->config; 2 | % my $base_url = $config->{base_url}; 3 | % my @threads = $helpers->thread->find; 4 | % my $pub_date = @threads ? $threads[0]->{created} : 0; 5 | 6 | 7 | 8 | <%= loc('Threads') %> | <%= $config->{meta}->{title} %> 9 | <%= $base_url . $helpers->url->threads_rss %> 10 | 11 | <%= $config->{meta}->{description} %> 12 | <%= $helpers->date->format_rss($pub_date) %> 13 | threads 14 | % foreach my $thread (@threads) { 15 | 16 | <%= $thread->{title} %> 17 | <%= $helpers->user->display_name($thread->{user}) %> 18 | <%= $base_url . $helpers->url->view_thread(id => $thread->{id}, slug => $thread->{slug}) %> 19 | markup->render($thread->{content}); 21 | <%== $helpers->truncate->truncate($content) %> 22 | ]]> 23 | % foreach my $tag (@{$thread->{tags}}) { 24 | <%= $tag->{title} %> 25 | % } 26 | <%== $helpers->date->format_rss($thread->{created}) %> 27 | <%= $base_url . $helpers->url->view_thread(id => $thread->{id}, slug => $thread->{slug}) %> 28 | <%= $base_url . $helpers->url->view_thread(id => $thread->{id}, slug => $thread->{slug}) %> 29 | 30 | % } 31 | 32 | 33 | -------------------------------------------------------------------------------- /templates/list_notifications.apl: -------------------------------------------------------------------------------- 1 | % $helpers->assets->require('/js/quick-reply.js'); 2 | % $helpers->meta->set(title => loc('Notifications')); 3 | 4 |
5 | 6 |

<%= loc('Notifications') %> (<%= $helpers->notification->count %>)

7 | 8 | % my @notifications = $helpers->notification->find; 9 | 10 | % if (@notifications) { 11 |

12 |

13 | 14 |
15 |

16 | % } 17 | 18 | 19 | % foreach my $notification (@notifications) { 20 | % my $reply = $notification->{reply}; 21 | % my $thread = $reply->{thread}; 22 | 23 | 24 | 30 | 46 | 47 | % } 48 |
25 |
26 | 27 | 28 |
29 |
31 | 32 | 33 | <%= $thread->{title} %> 34 | 35 | 36 | 37 |
38 |
39 | 40 | %== $helpers->displayer->render('include/reply-meta', reply => $reply, thread => $thread); 41 | 42 |
43 | <%== $helpers->markup->render($reply->{content}) %> 44 |
45 |
49 | 50 | %== $helpers->displayer->render('include/pager', base_url => $helpers->url->list_notifications, total => $helpers->notification->count); 51 |
52 | -------------------------------------------------------------------------------- /lib/Threads/DB/User.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::User; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::DB'; 7 | 8 | use Encode (); 9 | use Carp qw(croak); 10 | use Digest::SHA (); 11 | use Threads::DB::Nonce; 12 | use Threads::Util qw(gentoken); 13 | 14 | __PACKAGE__->meta( 15 | table => 'users', 16 | columns => [ 17 | qw/ 18 | id 19 | email 20 | password 21 | salt 22 | name 23 | status 24 | created 25 | email_notifications 26 | role 27 | / 28 | ], 29 | primary_key => 'id', 30 | auto_increment => 'id', 31 | unique_keys => ['name', 'email'], 32 | generate_columns_methods => 1, 33 | ); 34 | 35 | sub hash_password { 36 | my $self = shift; 37 | my ($password, $salt) = @_; 38 | 39 | croak 'password required' unless defined $password; 40 | croak 'salt required' unless defined $salt; 41 | 42 | $password = Encode::encode('UTF-8', $password); 43 | 44 | $password = Digest::SHA::sha256_hex($password . $salt); 45 | 46 | return Encode::decode('UTF-8', $password); 47 | } 48 | 49 | sub check_password { 50 | my $self = shift; 51 | my ($password) = @_; 52 | 53 | my $salt = $self->salt; 54 | 55 | return $self->password eq 56 | $self->hash_password($password, $salt); 57 | } 58 | 59 | sub create { 60 | my $self = shift; 61 | 62 | my $salt = gentoken(64); 63 | my $hashed_password = $self->hash_password($self->password, $salt); 64 | 65 | $self->password($hashed_password); 66 | $self->salt($salt); 67 | 68 | return $self->SUPER::create; 69 | } 70 | 71 | sub update_password { 72 | my $self = shift; 73 | my ($new_password) = @_; 74 | 75 | my $salt = gentoken(64); 76 | $self->password($self->hash_password($new_password, $salt)); 77 | $self->salt($salt); 78 | 79 | return $self->save; 80 | } 81 | 82 | 1; 83 | -------------------------------------------------------------------------------- /lib/Threads/Action/ViewThread.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::ViewThread; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Action'; 7 | 8 | use Digest::MD5 (); 9 | use Time::Moment; 10 | use Threads::DB::Thread; 11 | use Threads::DB::View; 12 | 13 | sub run { 14 | my $self = shift; 15 | 16 | my $thread_id = $self->captures->{id}; 17 | 18 | return $self->throw_not_found 19 | unless my $thread = 20 | Threads::DB::Thread->new(id => $thread_id) 21 | ->load(with => ['user', 'editor']); 22 | 23 | my $user = $self->scope->user; 24 | 25 | my $view; 26 | 27 | my $today = Time::Moment->now_utc->strftime('%Y-%m-%d'); 28 | 29 | if ($user) { 30 | $view = Threads::DB::View->find( 31 | first => 1, 32 | where => [ 33 | thread_id => $thread_id, 34 | user_id => $user->id, 35 | \"strftime('%Y-%m-%d', datetime(created,'unixepoch')) = '$today'" 36 | ] 37 | ); 38 | } 39 | 40 | my $hash = Digest::MD5::md5_hex(($self->req->remote_host || '') . ':' 41 | . ($self->req->header('User-Agent') || '')); 42 | 43 | $view ||= Threads::DB::View->find( 44 | first => 1, 45 | where => [ 46 | thread_id => $thread_id, 47 | hash => $hash, 48 | \"strftime('%Y-%m-%d', datetime(created,'unixepoch')) = '$today'" 49 | ] 50 | ); 51 | 52 | if (!$view) { 53 | $view = Threads::DB::View->new( 54 | thread_id => $thread_id, 55 | $user 56 | ? (user_id => $user->id) 57 | : (), 58 | hash => $hash 59 | )->create; 60 | } 61 | 62 | $thread->views_count( 63 | Threads::DB::View->table->count(where => [thread_id => $thread_id])); 64 | $thread->update; 65 | 66 | $thread->related('tags'); 67 | 68 | $self->set_var(thread => $thread->to_hash); 69 | 70 | return; 71 | } 72 | 73 | 1; 74 | -------------------------------------------------------------------------------- /lib/Threads/Action/UpdateReply.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::UpdateReply; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action::FormBase'; 7 | 8 | use Threads::ObjectACL; 9 | use Threads::DB::Reply; 10 | use Threads::Notificator; 11 | 12 | sub build_validator { 13 | my $self = shift; 14 | 15 | my $validator = $self->SUPER::build_validator; 16 | 17 | $validator->add_field('content'); 18 | 19 | $validator->add_rule('content', 'Readable'); 20 | $validator->add_rule('content', 'MaxLength', 1024); 21 | 22 | return $validator; 23 | } 24 | 25 | sub show_errors { 26 | my $self = shift; 27 | 28 | my $errors = $self->vars->{errors}; 29 | 30 | return $self->new_json_response(200, {errors => $errors}); 31 | } 32 | 33 | sub run { 34 | my $self = shift; 35 | 36 | my $reply_id = $self->captures->{id}; 37 | 38 | return $self->new_json_response(404) 39 | unless my $reply = Threads::DB::Reply->new(id => $reply_id)->load; 40 | 41 | my $user = $self->scope->user; 42 | 43 | return $self->new_json_response(404) 44 | unless Threads::ObjectACL->new->is_allowed($user, $reply, 'update_reply'); 45 | 46 | $self->{reply} = $reply; 47 | 48 | $self->set_var(reply => $reply->to_hash); 49 | 50 | return $self->SUPER::run; 51 | } 52 | 53 | sub submit { 54 | my $self = shift; 55 | my ($params) = @_; 56 | 57 | my $reply = $self->{reply}; 58 | $reply->set_columns(%$params, updated => time); 59 | $reply->update; 60 | 61 | Threads::Notificator->new->notify_mentioned_users($reply->related('user'), 62 | $reply); 63 | 64 | my $thread = $reply->related('thread'); 65 | 66 | my $url = $self->url_for( 67 | 'view_thread', 68 | id => $thread->id, 69 | slug => $thread->slug 70 | ); 71 | $url->query_form(t => time); 72 | $url->fragment('reply-' . $reply->id); 73 | 74 | return $self->new_json_response(200, {redirect => "$url"}); 75 | } 76 | 77 | 1; 78 | -------------------------------------------------------------------------------- /t/helper/thread.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use TestLib; 6 | use TestRequest; 7 | use TestDB; 8 | 9 | use Threads::Helper::Thread; 10 | 11 | subtest 'finds similar threads' => sub { 12 | TestDB->setup; 13 | 14 | my $thread = TestDB->create( 15 | 'Thread', 16 | user_id => 1, 17 | tags => [{title => 'foo'}, {title => 'bar'}] 18 | ); 19 | TestDB->create( 20 | 'Thread', 21 | user_id => 1, 22 | tags => [{title => 'foo'}, {title => 'baz'}] 23 | ); 24 | TestDB->create( 25 | 'Thread', 26 | user_id => 1, 27 | tags => [{title => 'foo'}, {title => 'qux'}] 28 | ); 29 | 30 | my $helper = _build_helper(); 31 | 32 | my @similar = $helper->similar({id => $thread->id}); 33 | 34 | is @similar, 2; 35 | }; 36 | 37 | subtest 'returns empty when no tags' => sub { 38 | TestDB->setup; 39 | 40 | my $thread = TestDB->create('Thread', user_id => 1); 41 | 42 | my $helper = _build_helper(); 43 | 44 | my @similar = $helper->similar({id => $thread->id}); 45 | 46 | is @similar, 0; 47 | }; 48 | 49 | subtest 'finds by query' => sub { 50 | TestDB->setup; 51 | 52 | my $thread = TestDB->create( 53 | 'Thread', 54 | user_id => 1, 55 | title => 'some foo other' 56 | ); 57 | TestDB->create( 58 | 'Thread', 59 | user_id => 1, 60 | content => 'foo' 61 | ); 62 | TestDB->create( 63 | 'Thread', 64 | user_id => 1, 65 | title => 'bar' 66 | ); 67 | 68 | my $helper = _build_helper(params => {q => 'foo'}); 69 | 70 | my @threads = $helper->find; 71 | 72 | is @threads, 2; 73 | }; 74 | 75 | my $env; 76 | 77 | sub _build_helper { 78 | my (%params) = @_; 79 | 80 | $env = $params{env} || TestRequest->to_env(%params); 81 | $env->{'tu.displayer.vars'} = {params => $params{params} || {}}; 82 | 83 | Threads::Helper::Thread->new(env => $env); 84 | } 85 | 86 | done_testing; 87 | -------------------------------------------------------------------------------- /templates/create_thread.apl: -------------------------------------------------------------------------------- 1 | % $helpers->assets->require('/autosize/jquery.autosize.min.js'); 2 | % $helpers->assets->require('/js/autosize.js'); 3 | % $helpers->assets->require('/js/quick-reply.js'); 4 | % $helpers->assets->require('/jquery-ui/jquery-ui.css'); 5 | % $helpers->assets->require('/jquery-ui/jquery-ui.min.js'); 6 | % $helpers->assets->require('/tagsinput/jquery.tagsinput.css'); 7 | % $helpers->assets->require('/tagsinput/jquery.tagsinput.js'); 8 | % $helpers->assets->require('/js/tags.js'); 9 | % $helpers->meta->set(title => loc('Create thread')); 10 | 11 |
12 | 13 |

<%= loc('Create thread') %>

14 | 15 |
16 | 17 | <%== $helpers->form->input('title', label => loc('Title'), class => 'input_xlarge') %> 18 | <%== $helpers->form->input('tags', label => loc('Tags'), help => loc('Comma separated')) %> 19 | 20 |
21 | 33 | 34 |
    35 |
  • 36 | <%== $helpers->form->textarea('content') %> 37 |
  • 38 |
  • 39 |
  • 40 |
41 | 42 |
43 | 44 |
45 | 46 | %== $helpers->displayer->render('include/markup-help-button'); 47 |
48 | 49 |
50 | 51 | %== $helpers->displayer->render('include/markup-help'); 52 | 53 |
54 | -------------------------------------------------------------------------------- /lib/Threads/MarkupRenderer.pm: -------------------------------------------------------------------------------- 1 | package Threads::MarkupRenderer; 2 | 3 | use strict; 4 | use warnings; 5 | use attrs; 6 | 7 | use Encode (); 8 | use Digest::MD5 qw(md5_hex); 9 | 10 | sub translate { 11 | my $self = shift; 12 | my ($text) = @_; 13 | 14 | my %parts; 15 | 16 | my $save = sub { 17 | my ($capture, $tag) = @_; 18 | my $key = md5_hex(Encode::encode('UTF-8', $capture)); 19 | $parts{$key} = $tag; 20 | "--#$key#--"; 21 | }; 22 | 23 | $text =~ 24 | s{<(https?://[^<"&\s]+)>}{$save->($1, qq{$1})}eg; 25 | 26 | $text =~ s{&}{&}g; 27 | $text =~ s{>}{>}g; 28 | $text =~ s{<}{<}g; 29 | $text =~ s{"}{"}g; 30 | 31 | $text =~ s{^```([a-z]+)?\s+(.*?)\s*^```} 32 | {my $lang = $1 || 'perl'; $save->("$lang:$2", qq{
$2
})}emsg; 33 | $text =~ s{`(.*?)`}{$save->($1, "$1")}eg; 34 | 35 | $text =~ s{author:([A-Z]{3,9})}{[$1](http://metacpan.org/author/$1)}g; 36 | $text =~ s{module:([[:alnum:]\:_]+)}{[$1](http://metacpan.org/module/$1)}g; 37 | $text =~ s{release:([[:alnum:]\:_]+)} 38 | {[$1](http://metacpan.org/release/$1)}g; 39 | 40 | $text =~ s{\[(.*?)\]\((.*?)\)} 41 | {$save->("$1:$2", qq{$1})}eg; 42 | 43 | $text =~ s{_(.*?)_}{$save->($1, "$1")}eg; 44 | $text =~ s{\*\*(.*?)\*\*}{$save->($1, "$1")}eg; 45 | 46 | $text =~ s#(?:\r?\n){2,}#

#g; 47 | 48 | return { 49 | text => $text, 50 | parts => \%parts 51 | }; 52 | } 53 | 54 | sub render { 55 | my $self = shift; 56 | my ($markup) = @_; 57 | 58 | my $translated = $self->translate($markup); 59 | 60 | my $text = $translated->{text}; 61 | 62 | $text =~ s{(@[a-z0-9_-]{1,32})}{$1}ig; 63 | 64 | for my $key (keys %{$translated->{parts}}) { 65 | $text =~ s{--#$key#--}{$translated->{parts}->{$key}}g; 66 | } 67 | 68 | return '

' . $text . '

'; 69 | } 70 | 71 | 1; 72 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Reply.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Reply; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use Threads::DB::Reply; 9 | use Threads::DB::Thank; 10 | use Threads::DB::Notification; 11 | 12 | sub find_by_thread { 13 | my $self = shift; 14 | my ($thread) = @_; 15 | 16 | my @replies = Threads::DB::Reply->find( 17 | where => [thread_id => $thread->{id}], 18 | order_by => [lft => 'ASC'], 19 | with => [qw/user parent.user/] 20 | ); 21 | 22 | @replies = map { $_->to_hash } @replies; 23 | 24 | if (my $user = $self->scope->user) { 25 | my @notifications = Threads::DB::Notification->find( 26 | where => [ 27 | user_id => $user->id, 28 | 'reply.thread_id' => $thread->{id} 29 | ] 30 | ); 31 | 32 | my %ids = map { $_->reply_id => 1 } @notifications; 33 | foreach my $reply (@replies) { 34 | $reply->{unread} = 1 if exists $ids{$reply->{id}}; 35 | } 36 | } 37 | 38 | return @replies; 39 | } 40 | 41 | sub is_thanked { 42 | my $self = shift; 43 | my ($reply) = @_; 44 | 45 | my $user = $self->scope->user; 46 | 47 | return 48 | $reply->{thanks_count} > 0 49 | && $user 50 | && Threads::DB::Thank->find( 51 | first => 1, 52 | where => [ 53 | reply_id => $reply->{id}, 54 | user_id => $user->id, 55 | ] 56 | ) ? 1 : 0; 57 | } 58 | 59 | sub is_flagged { 60 | my $self = shift; 61 | my ($reply) = @_; 62 | 63 | my $user = $self->scope->user; 64 | 65 | return 66 | $reply->{reports_count} > 0 67 | && $user 68 | && Threads::DB::Report->find( 69 | first => 1, 70 | where => [ 71 | reply_id => $reply->{id}, 72 | user_id => $user->id, 73 | ] 74 | ); 75 | } 76 | 77 | sub count { 78 | my $self = shift; 79 | my ($thread) = @_; 80 | 81 | return Threads::DB::Reply->table->count; 82 | } 83 | 84 | 1; 85 | -------------------------------------------------------------------------------- /lib/Threads/Action/CreateThread.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::CreateThread; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action::FormBase'; 7 | 8 | use Threads::LimitChecker; 9 | use Threads::DB::User; 10 | use Threads::DB::Thread; 11 | use Threads::DB::Subscription; 12 | 13 | sub build_validator { 14 | my $self = shift; 15 | 16 | my $validator = $self->SUPER::build_validator; 17 | 18 | $validator->add_field('title'); 19 | $validator->add_field('content'); 20 | $validator->add_optional_field('tags'); 21 | 22 | $validator->add_rule('title', 'Readable'); 23 | $validator->add_rule('title', 'MaxLength', 255); 24 | $validator->add_rule('content', 'MaxLength', 5 * 1024); 25 | $validator->add_rule('tags', 'Tags'); 26 | 27 | return $validator; 28 | } 29 | 30 | sub validate { 31 | my $self = shift; 32 | my ($validator, $params) = @_; 33 | 34 | my $config = $self->service('config'); 35 | my $user = $self->scope->user; 36 | 37 | my $limits_reached = 38 | Threads::LimitChecker->new->check($config->{limits}->{threads}, 39 | $user, Threads::DB::Thread->new); 40 | if ($limits_reached) { 41 | $validator->add_error( 42 | title => $self->loc('Creating threads too often')); 43 | return 0; 44 | } 45 | 46 | return 1; 47 | } 48 | 49 | sub submit { 50 | my $self = shift; 51 | my ($params) = @_; 52 | 53 | my $user = $self->scope->user; 54 | 55 | my $thread = 56 | Threads::DB::Thread->new(%$params, user_id => $user->id) 57 | ->create; 58 | 59 | Threads::DB::Subscription->new( 60 | user_id => $user->id, 61 | thread_id => $thread->id 62 | )->create; 63 | 64 | if ($params->{tags}) { 65 | my @tags = grep { $_ ne '' && /\w/ } split /\s*,\s*/, $params->{tags}; 66 | 67 | $thread->create_related('tags', title => $_) for @tags; 68 | } 69 | 70 | return $self->redirect( 71 | 'view_thread', 72 | id => $thread->id, 73 | slug => $thread->slug 74 | ); 75 | } 76 | 77 | 1; 78 | -------------------------------------------------------------------------------- /templates/update_thread.apl: -------------------------------------------------------------------------------- 1 | % $helpers->assets->require('/autosize/jquery.autosize.min.js'); 2 | % $helpers->assets->require('/js/autosize.js'); 3 | % $helpers->assets->require('/js/quick-reply.js'); 4 | % $helpers->assets->require('/jquery-ui/jquery-ui.css'); 5 | % $helpers->assets->require('/jquery-ui/jquery-ui.min.js'); 6 | % $helpers->assets->require('/tagsinput/jquery.tagsinput.css'); 7 | % $helpers->assets->require('/tagsinput/jquery.tagsinput.js'); 8 | % $helpers->assets->require('/js/tags.js'); 9 | % $helpers->meta->set(title => loc('Update thread')); 10 | 11 |
12 | 13 |

<%= loc('Update thread') %>

14 | 15 |
16 | 17 | <%== $helpers->form->input('title', label => loc('Title'), default => $thread->{title}, class => 'input_xlarge') %> 18 | <%== $helpers->form->input('tags', label => loc('Tags'), help => loc('Comma separated'), default => $thread->{tags_list}) %> 19 | 20 |
21 | 33 |
    34 |
  • 35 | <%== $helpers->form->textarea('content', default => $thread->{content}) %> 36 |
  • 37 |
  • 38 |
  • 39 |
40 | 41 |
42 | 43 | 44 |
45 | 46 | %== $helpers->displayer->render('include/markup-help-button'); 47 |
48 | 49 |
50 | 51 | %== $helpers->displayer->render('include/markup-help'); 52 | 53 |
54 | -------------------------------------------------------------------------------- /public/formalize/js/jquery.formalize.min.js: -------------------------------------------------------------------------------- 1 | var FORMALIZE=function(e,t,n,r){function i(e){var t=n.createElement("b");return t.innerHTML="",!!t.getElementsByTagName("br").length}var s="placeholder"in n.createElement("input"),o="autofocus"in n.createElement("input"),u=i(6),a=i(7);return{go:function(){var e,t=this.init;for(e in t)t.hasOwnProperty(e)&&t[e]()},init:{disable_link_button:function(){e(n.documentElement).on("click","a.button_disabled",function(){return!1})},full_input_size:function(){if(!a||!e("textarea, input.input_full").length)return;e("textarea, input.input_full").wrap('')},ie6_skin_inputs:function(){if(!u||!e("input, select, textarea").length)return;var t=/button|submit|reset/,n=/date|datetime|datetime-local|email|month|number|password|range|search|tel|text|time|url|week/;e("input").each(function(){var r=e(this);this.getAttribute("type").match(t)?(r.addClass("ie6_button"),this.disabled&&r.addClass("ie6_button_disabled")):this.getAttribute("type").match(n)&&(r.addClass("ie6_input"),this.disabled&&r.addClass("ie6_input_disabled"))}),e("textarea, select").each(function(){this.disabled&&e(this).addClass("ie6_input_disabled")})},autofocus:function(){if(o||!e(":input[autofocus]").length)return;var t=e("[autofocus]")[0];t.disabled||t.focus()},placeholder:function(){if(s||!e(":input[placeholder]").length)return;FORMALIZE.misc.add_placeholder(),e(":input[placeholder]").each(function(){if(this.type==="password")return;var t=e(this),n=t.attr("placeholder");t.focus(function(){t.val()===n&&t.val("").removeClass("placeholder_text")}).blur(function(){FORMALIZE.misc.add_placeholder()}),t.closest("form").submit(function(){t.val()===n&&t.val("").removeClass("placeholder_text")}).on("reset",function(){setTimeout(FORMALIZE.misc.add_placeholder,50)})})}},misc:{add_placeholder:function(){if(s||!e(":input[placeholder]").length)return;e(":input[placeholder]").each(function(){if(this.type==="password")return;var t=e(this),n=t.attr("placeholder");(!t.val()||t.val()===n)&&t.val(n).addClass("placeholder_text")})}}}}(jQuery,this,this.document);jQuery(document).ready(function(){FORMALIZE.go()}); 2 | -------------------------------------------------------------------------------- /lib/Threads/Action/UpdateThread.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::UpdateThread; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action::FormBase'; 7 | 8 | use Threads::ObjectACL; 9 | use Threads::DB::Thread; 10 | 11 | sub build_validator { 12 | my $self = shift; 13 | 14 | my $validator = $self->SUPER::build_validator; 15 | 16 | $validator->add_field('title'); 17 | $validator->add_field('content'); 18 | $validator->add_optional_field('tags'); 19 | 20 | $validator->add_rule('title', 'Readable'); 21 | $validator->add_rule('title', 'MaxLength', 255); 22 | 23 | $validator->add_rule('content', 'MaxLength', 5 * 1024); 24 | 25 | $validator->add_rule('tags', 'Tags'); 26 | 27 | return $validator; 28 | } 29 | 30 | sub run { 31 | my $self = shift; 32 | 33 | my $thread_id = $self->captures->{id}; 34 | 35 | return $self->throw_not_found 36 | unless my $thread = Threads::DB::Thread->new(id => $thread_id)->load; 37 | 38 | my $user = $self->scope->user; 39 | 40 | return $self->throw_not_found 41 | unless Threads::ObjectACL->new->is_allowed($user, $thread, 42 | 'update_thread'); 43 | 44 | $self->{thread} = $thread; 45 | 46 | $thread->related('tags'); 47 | 48 | $self->set_var(thread => $thread->to_hash); 49 | 50 | return $self->SUPER::run; 51 | } 52 | 53 | sub submit { 54 | my $self = shift; 55 | my ($params) = @_; 56 | 57 | my $user = $self->scope->user; 58 | 59 | my $thread = $self->{thread}; 60 | $thread->set_columns(%$params); 61 | $thread->updated(time); 62 | $thread->last_activity(time); 63 | $thread->editor_id($user->id); 64 | $thread->update; 65 | 66 | if ($params->{tags}) { 67 | my @tags = grep { $_ ne '' && /\w/ } split /\s*,\s*/, $params->{tags}; 68 | 69 | if (@tags) { 70 | $thread->delete_related('tags'); 71 | $thread->create_related('tags', title => $_) for @tags; 72 | } 73 | } 74 | 75 | return $self->redirect( 76 | 'view_thread', 77 | id => $thread->id, 78 | slug => $thread->slug 79 | ); 80 | } 81 | 82 | 1; 83 | -------------------------------------------------------------------------------- /lib/Threads/Action/RequestPasswordReset.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::RequestPasswordReset; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action::FormBase'; 7 | 8 | use Threads::DB::User; 9 | use Threads::DB::Confirmation; 10 | use Threads::Util qw(to_hex); 11 | 12 | sub build_validator { 13 | my $self = shift; 14 | 15 | my $validator = $self->SUPER::build_validator; 16 | 17 | $validator->add_field('email'); 18 | $validator->add_rule('email', 'Email'); 19 | 20 | return $validator; 21 | } 22 | 23 | sub validate { 24 | my $self = shift; 25 | my ($validator, $params) = @_; 26 | 27 | my $user = Threads::DB::User->new(email => $params->{email})->load; 28 | 29 | if (!$user) { 30 | $validator->add_error(email => $self->loc('User does not exist')); 31 | return; 32 | } 33 | 34 | if ($user->status ne 'active') { 35 | $validator->add_error(email => $self->loc('Account not activated')); 36 | return; 37 | } 38 | 39 | $self->{user} = $user; 40 | 41 | return 1; 42 | } 43 | 44 | sub submit { 45 | my $self = shift; 46 | my ($params) = @_; 47 | 48 | my $user = $self->{user}; 49 | 50 | Threads::DB::Confirmation->table->delete( 51 | where => [user_id => $user->id]); 52 | 53 | my $confirmation = Threads::DB::Confirmation->new( 54 | user_id => $user->id, 55 | type => 'reset_password' 56 | )->create; 57 | 58 | my $email = $self->render( 59 | 'email/password_reset', 60 | layout => undef, 61 | vars => { 62 | email => $params->{email}, 63 | token => to_hex $confirmation->token 64 | } 65 | ); 66 | 67 | $self->mailer->send( 68 | headers => [ 69 | To => $params->{email}, 70 | Subject => $self->loc('Password reset') 71 | ], 72 | body => $email 73 | ); 74 | 75 | return $self->render( 76 | 'password_reset_confirmation_needed', 77 | vars => {email => $params->{email}} 78 | ); 79 | } 80 | 81 | sub mailer { 82 | my $self = shift; 83 | 84 | return $self->service('mailer'); 85 | } 86 | 87 | 1; 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Threads 2 | 3 | This is a modern fresh looking forum, primarily written for the Perl 4 | magazine (in Russian). 5 | 6 | ## Live 7 | 8 | Live English version can be found at . 9 | 10 | ## How to 11 | 12 | How to start it locally. 13 | 14 | ### Configuration 15 | 16 | Copy `config/config.yml.example` to `config/config.yml` and adjust to fit your needs. 17 | 18 | ### Database setup 19 | 20 | ``` 21 | cat schema/*.sql | sqlite db.db 22 | ``` 23 | 24 | Or you can use a migration tool. See below. 25 | 26 | ### Dependencies installation 27 | 28 | 1. Fetch submodules: 29 | 30 | ``` 31 | git submodule update --init 32 | ``` 33 | 34 | 2. Install modules from CPAN 35 | 36 | With `carton`: 37 | 38 | ``` 39 | carton install 40 | ``` 41 | 42 | With `cpanm`: 43 | 44 | ``` 45 | cpanm -L perl5 --installdeps . 46 | ``` 47 | 48 | ### Jobs 49 | 50 | There are several jobs that need to be run periodically to keep the database 51 | clean and notifications working. 52 | 53 | Email notifications: 54 | 55 | ``` 56 | perl util/run-job.pl send_email_notifications 57 | ``` 58 | 59 | Inactive registrations: 60 | 61 | ``` 62 | perl util/run-job.pl cleanup_inactive_registrations 63 | ``` 64 | 65 | Other system stuff: 66 | 67 | ``` 68 | perl util/run-job.pl cleanup_thread_views 69 | ``` 70 | 71 | ### Starting 72 | 73 | With `carton`: 74 | 75 | ``` 76 | carton exec -- plackup 77 | ``` 78 | 79 | With `local::lib` (if install with `cpanm`): 80 | 81 | ``` 82 | perl -Mlocal::lib=perl5 perl5/bin/plackup 83 | ``` 84 | 85 | ### Upgrading 86 | 87 | After doing `git pull` you can notice new files in `schema` directory. You can 88 | either manually run new migrations or use a migration tool. For example 89 | [mimi](http://github.com/vti/app-mimi): 90 | 91 | Setup migration table: 92 | 93 | ``` 94 | mimi setup --dsn 'dbi:SQLite:db.db' 95 | ``` 96 | 97 | Set latest migration (not needed if you used it from the start): 98 | 99 | ``` 100 | mimi set --dsn 'dbi:SQLite:db.db' 101 | ``` 102 | -------------------------------------------------------------------------------- /lib/Threads/Job/SendEmailNotifications.pm: -------------------------------------------------------------------------------- 1 | package Threads::Job::SendEmailNotifications; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Job::Base'; 7 | 8 | use Threads; 9 | use Threads::DB::User; 10 | use Threads::DB::Notification; 11 | 12 | sub run { 13 | my $self = shift; 14 | 15 | my $app = $self->_build_app; 16 | 17 | my @users = 18 | Threads::DB::User->find( 19 | where => [status => 'active', email_notifications => 1]); 20 | 21 | return unless @users; 22 | 23 | my $i18n = $app->service('i18n'); 24 | my $i18n_handle = 25 | $i18n->handle($self->_config->{i18n}->{default_language}); 26 | my $mailer = $app->service('mailer'); 27 | my $displayer = $app->service('displayer'); 28 | 29 | foreach my $user (@users) { 30 | my $total_notifications = 31 | Threads::DB::Notification->table->count( 32 | where => [user_id => $user->id]); 33 | my @not_sent_notifications = 34 | Threads::DB::Notification->find( 35 | where => [user_id => $user->id, is_sent => 0]); 36 | 37 | if (@not_sent_notifications) { 38 | my $email = $displayer->render( 39 | 'email/notifications_digest', 40 | layout => undef, 41 | vars => { 42 | loc => sub { $i18n_handle->loc(@_) }, 43 | url => $self->_config->{base_url} 44 | . $app->service('routes') 45 | ->build_path('list_notifications') 46 | } 47 | ); 48 | 49 | $mailer->send( 50 | headers => [ 51 | To => $user->email, 52 | Subject => $i18n_handle->loc('Unread notifications: ') 53 | . $total_notifications 54 | ], 55 | body => $email 56 | ); 57 | 58 | Threads::DB::Notification->table->update( 59 | where => [user_id => $user->id, is_sent => 0], 60 | set => [is_sent => 1] 61 | ); 62 | } 63 | } 64 | 65 | return $self; 66 | } 67 | 68 | sub _build_app { Threads->new } 69 | 70 | 1; 71 | -------------------------------------------------------------------------------- /lib/Threads/Helper/Pager.pm: -------------------------------------------------------------------------------- 1 | package Threads::Helper::Pager; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Tu::Helper'; 7 | 8 | use URI::Escape (); 9 | 10 | sub build { 11 | my $self = shift; 12 | my (%params) = @_; 13 | 14 | my $query_params = $params{query_params}; 15 | my $base_url = $params{base_url}; 16 | my $total = $params{total}; 17 | my $page_size = $self->param('page_size') || 10; 18 | my $current_page = $self->param('page') || 1; 19 | 20 | return {} if $total <= $page_size; 21 | 22 | my $first_page = $current_page == 1 ? 0 : 1; 23 | my $prev_page = $current_page == 1 ? 0 : $current_page - 1; 24 | 25 | my $last_page = $total / $page_size; 26 | if ($last_page != int($last_page)) { 27 | $last_page = int($last_page) + 1; 28 | } 29 | my $next_page = $current_page + 1; 30 | $next_page = 0 if $next_page > $last_page; 31 | $last_page = 0 if $last_page <= $current_page; 32 | 33 | my @query; 34 | foreach my $query_param (@$query_params) { 35 | next unless defined $self->param($query_param); 36 | 37 | push @query, 38 | URI::Escape::uri_escape($query_param) . '=' 39 | . URI::Escape::uri_escape($self->param($query_param)); 40 | } 41 | my $query = '&' . join('&', @query); 42 | 43 | return { 44 | first_page => $first_page, 45 | $base_url 46 | ? (first_page_url => $self->_build_url($base_url, $first_page, $query)) 47 | : (), 48 | prev_page => $prev_page, 49 | $base_url 50 | ? (prev_page_url => $self->_build_url($base_url, $prev_page, $query)) 51 | : (), 52 | next_page => $next_page, 53 | $base_url 54 | ? (next_page_url => $self->_build_url($base_url, $next_page, $query)) 55 | : (), 56 | last_page => $last_page, 57 | $base_url 58 | ? (last_page_url => $self->_build_url($base_url, $last_page, $query)) 59 | : (), 60 | }; 61 | } 62 | 63 | sub _build_url { 64 | my $self = shift; 65 | my ($base_url, $page, $query) = @_; 66 | 67 | return '' unless $page; 68 | return $base_url . '?page=' . $page . $query; 69 | } 70 | 71 | 1; 72 | -------------------------------------------------------------------------------- /t/action/autocomplete_tags.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use Test::Fatal; 6 | use TestLib; 7 | use TestRequest; 8 | use TestDB; 9 | 10 | use JSON qw(decode_json); 11 | use HTTP::Request::Common; 12 | use Threads::DB::Thread; 13 | use Threads::Action::AutocompleteTags; 14 | 15 | subtest 'returns empty array when no tags' => sub { 16 | my $action = _build_action(req => GET('/')); 17 | 18 | my $res = $action->run; 19 | 20 | is_deeply decode_json $res->body, []; 21 | }; 22 | 23 | subtest 'returns actions sorted by popularity' => sub { 24 | TestDB->setup; 25 | 26 | Threads::DB::Thread->new( 27 | user_id => 1, 28 | title => 'foo', 29 | tags => [{title => 'z-popular'}, {title => 'rare'}] 30 | )->create; 31 | Threads::DB::Thread->new( 32 | user_id => 1, 33 | title => 'foo', 34 | tags => [{title => 'z-popular'}] 35 | )->create; 36 | Threads::DB::Thread->new( 37 | user_id => 1, 38 | title => 'foo', 39 | tags => [{title => 'z-popular'}] 40 | )->create; 41 | Threads::DB::Thread->new( 42 | user_id => 1, 43 | title => 'foo', 44 | tags => [{title => 'rare'}] 45 | )->create; 46 | 47 | my $action = _build_action(req => GET('/?term=r')); 48 | 49 | my $res = $action->run; 50 | 51 | is_deeply decode_json $res->body, [qw/z-popular rare/]; 52 | }; 53 | 54 | subtest 'not includes zero tags' => sub { 55 | TestDB->setup; 56 | 57 | Threads::DB::Tag->new(title => 'zero')->create; 58 | 59 | Threads::DB::Thread->new( 60 | user_id => 1, 61 | title => 'foo', 62 | tags => [{title => 'foo'}, {title => 'bar'}] 63 | )->create; 64 | 65 | my $action = _build_action(req => GET('/?term=r')); 66 | 67 | my $res = $action->run; 68 | 69 | is_deeply decode_json $res->body, [qw/bar/]; 70 | }; 71 | 72 | sub _build_action { 73 | my (%params) = @_; 74 | 75 | my $env = $params{env} || TestRequest->to_env(%params); 76 | 77 | my $action = Threads::Action::AutocompleteTags->new(env => $env); 78 | $action = Test::MonkeyMock->new($action); 79 | 80 | return $action; 81 | } 82 | 83 | done_testing; 84 | -------------------------------------------------------------------------------- /t/helper/url.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use Test::MonkeyMock; 6 | use TestLib; 7 | 8 | use Threads::Helper::Url; 9 | 10 | subtest 'returns url' => sub { 11 | my $helper = _build_helper(); 12 | 13 | is($helper->root, '/'); 14 | }; 15 | 16 | subtest 'builds route' => sub { 17 | my $routes = _mock_routes(); 18 | my $helper = _build_helper(routes => $routes); 19 | 20 | $helper->root(foo => 'bar'); 21 | 22 | my ($name, %params) = $routes->mocked_call_args('build_path'); 23 | is $name, 'root'; 24 | is_deeply \%params, {foo => 'bar'}; 25 | }; 26 | 27 | subtest 'escapes uri' => sub { 28 | my $routes = _mock_routes(path => 'must be escaped'); 29 | my $helper = _build_helper(routes => $routes); 30 | 31 | is $helper->root(foo => 'bar'), 'must%20be%20escaped'; 32 | }; 33 | 34 | subtest 'builds route without language when default' => sub { 35 | my $routes = _mock_routes(); 36 | my $helper = _build_helper( 37 | routes => $routes, 38 | env => {'tu.i18n.language' => 'en'} 39 | ); 40 | 41 | my $url = $helper->root(foo => 'bar'); 42 | 43 | is $url, '/'; 44 | }; 45 | 46 | subtest 'builds route with language' => sub { 47 | my $routes = _mock_routes(); 48 | my $helper = _build_helper( 49 | routes => $routes, 50 | env => {'plack.i18n.language' => 'ru'} 51 | ); 52 | 53 | my $url = $helper->root(foo => 'bar'); 54 | 55 | is $url, '/ru/'; 56 | }; 57 | 58 | sub _mock_i18n { 59 | my $i18n = Test::MonkeyMock->new; 60 | $i18n->mock(default_language => sub { 'en' }); 61 | return $i18n; 62 | } 63 | 64 | sub _mock_routes { 65 | my (%params) = @_; 66 | 67 | my $routes = Test::MonkeyMock->new; 68 | $routes->mock(build_path => sub { $params{path} || '/' }); 69 | return $routes; 70 | } 71 | 72 | sub _build_helper { 73 | my (%params) = @_; 74 | 75 | $params{routes} ||= _mock_routes(); 76 | $params{i18n} ||= _mock_i18n(); 77 | 78 | my $services = Test::MonkeyMock->new; 79 | $services->mock(service => sub { $params{$_[1]} }); 80 | 81 | my $env = $params{env} || {}; 82 | Threads::Helper::Url->new(env => $env, services => $services); 83 | } 84 | 85 | done_testing; 86 | -------------------------------------------------------------------------------- /lib/Threads/Action/Login.pm: -------------------------------------------------------------------------------- 1 | package Threads::Action::Login; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::Action::FormBase'; 7 | 8 | use Threads::Origin; 9 | use Threads::DB::User; 10 | use Threads::DB::Nonce; 11 | use Threads::DB::Confirmation; 12 | 13 | sub build_validator { 14 | my $self = shift; 15 | 16 | my $validator = $self->SUPER::build_validator; 17 | 18 | $validator->add_field('email'); 19 | $validator->add_field('password'); 20 | 21 | $validator->add_rule('email', 'Email'); 22 | 23 | return $validator; 24 | } 25 | 26 | sub validate { 27 | my $self = shift; 28 | my ($validator, $params) = @_; 29 | 30 | my $user = Threads::DB::User->new(email => $params->{email})->load; 31 | 32 | if (!$user) { 33 | $validator->add_error(email => $self->loc('Unknown credentials')); 34 | return; 35 | } 36 | 37 | if (!$user->check_password($params->{password})) { 38 | $validator->add_error(email => $self->loc('Unknown credentials')); 39 | return; 40 | } 41 | 42 | if ($user->status eq 'new') { 43 | $validator->add_error(email => $self->loc('Account not activated')); 44 | return; 45 | } 46 | 47 | if ($user->status eq 'blocked') { 48 | $validator->add_error(email => $self->loc('Account blocked')); 49 | return; 50 | } 51 | 52 | if ($user->status ne 'active') { 53 | $validator->add_error(email => $self->loc('Account not active')); 54 | return; 55 | } 56 | 57 | $self->{user} = $user; 58 | 59 | return 1; 60 | } 61 | 62 | sub submit { 63 | my $self = shift; 64 | my ($params) = @_; 65 | 66 | my $user = $self->{user}; 67 | 68 | my $nonce = Threads::DB::Nonce->new(user_id => $user->id)->create; 69 | 70 | $self->scope->auth->login($self->env, {id => $nonce->id}); 71 | 72 | Threads::DB::Confirmation->table->delete( 73 | where => [user_id => $user->id, type => 'reset_password']); 74 | 75 | my $next = Threads::Origin->new( 76 | env => $self->env, 77 | user => $user, 78 | services => $self->{services} 79 | )->origin; 80 | $next ||= 'index'; 81 | 82 | return $self->redirect($next); 83 | } 84 | 85 | 1; 86 | -------------------------------------------------------------------------------- /lib/Threads/DB/Confirmation.pm: -------------------------------------------------------------------------------- 1 | package Threads::DB::Confirmation; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use parent 'Threads::DB'; 7 | 8 | use Carp qw(croak); 9 | use Threads::Util qw(gentoken from_hex); 10 | 11 | __PACKAGE__->meta( 12 | table => 'confirmations', 13 | columns => [ 14 | qw/ 15 | id 16 | user_id 17 | token 18 | type 19 | created 20 | / 21 | ], 22 | primary_key => 'id', 23 | auto_increment => 'id', 24 | unique_keys => ['token'], 25 | generate_columns_methods => 1, 26 | ); 27 | 28 | sub find_fresh_by_token { 29 | my $self = shift; 30 | my ($token, $type) = @_; 31 | 32 | croak 'token required' unless $token; 33 | croak 'type required' unless $type; 34 | 35 | return Threads::DB::Confirmation->find( 36 | first => 1, 37 | where => [ 38 | token => from_hex $token, 39 | type => $type, 40 | created => {'>=' => time - $self->_expiration_timeout} 41 | ] 42 | ); 43 | } 44 | 45 | sub is_expired { 46 | my $self = shift; 47 | 48 | return $self->created < time - $self->_expiration_timeout; 49 | } 50 | 51 | sub find_by_token { 52 | my $self = shift; 53 | my ($token, $type) = @_; 54 | 55 | croak 'token required' unless $token; 56 | croak 'type required' unless $type; 57 | 58 | return Threads::DB::Confirmation->find( 59 | first => 1, 60 | where => [ 61 | token => from_hex $token, 62 | type => $type, 63 | ] 64 | ); 65 | } 66 | 67 | sub find_fresh_by_user_id { 68 | my $self = shift; 69 | my ($user_id, $type) = @_; 70 | 71 | croak 'user_id required' unless $user_id; 72 | croak 'type required' unless $type; 73 | 74 | return Threads::DB::Confirmation->find( 75 | first => 1, 76 | where => [ 77 | user_id => $user_id, 78 | type => $type, 79 | created => {'>=' => time - 15 * 60} 80 | ] 81 | ); 82 | } 83 | 84 | sub create { 85 | my $self = shift; 86 | 87 | if (!$self->token) { 88 | $self->token(gentoken(16)); 89 | } 90 | 91 | return $self->SUPER::create; 92 | } 93 | 94 | sub _expiration_timeout { 45 * 60 } 95 | 96 | 1; 97 | -------------------------------------------------------------------------------- /public/unsemantic/js/html5.js: -------------------------------------------------------------------------------- 1 | /* 2 | HTML5 Shiv v3.6.2pre | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed 3 | */ 4 | (function(a,b){function l(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function m(){var a=s.elements;return"string"==typeof a?a.split(" "):a}function n(a){var b=j[a[h]];return b||(b={},i++,a[h]=i,j[i]=b),b}function o(a,c,d){if(c||(c=b),k)return c.createElement(a);d||(d=n(c));var g;return g=d.cache[a]?d.cache[a].cloneNode():f.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),g.canHaveChildren&&!e.test(a)?d.frag.appendChild(g):g}function p(a,c){if(a||(a=b),k)return a.createDocumentFragment();c=c||n(a);for(var d=c.frag.cloneNode(),e=0,f=m(),g=f.length;g>e;e++)d.createElement(f[e]);return d}function q(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return s.shivMethods?o(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+m().join().replace(/\w+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(s,b.frag)}function r(a){a||(a=b);var c=n(a);return!s.shivCSS||g||c.hasCSS||(c.hasCSS=!!l(a,"article,aside,figcaption,figure,footer,header,hgroup,nav,section{display:block}mark{background:#FF0;color:#000}")),k||q(a,c),a}var g,k,c="3.6.2pre",d=a.html5||{},e=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,f=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,h="_html5shiv",i=0,j={};(function(){try{var a=b.createElement("a");a.innerHTML="",g="hidden"in a,k=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return a.cloneNode===void 0||a.createDocumentFragment===void 0||a.createElement===void 0}()}catch(c){g=!0,k=!0}})();var s={elements:d.elements||"abbr article aside audio bdi canvas data datalist details figcaption figure footer header hgroup main mark meter nav output progress section summary time video",version:c,shivCSS:d.shivCSS!==!1,supportsUnknownElements:k,shivMethods:d.shivMethods!==!1,type:"default",shivDocument:r,createElement:o,createDocumentFragment:p};a.html5=s,r(b)})(this,document); -------------------------------------------------------------------------------- /t/action/change_password.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | 4 | use Test::More; 5 | use Test::Fatal; 6 | use TestLib; 7 | use TestDB; 8 | use TestRequest; 9 | use HTTP::Request::Common; 10 | 11 | use Threads::DB::User; 12 | use Threads::Action::ChangePassword; 13 | 14 | subtest 'validation error when wrong old password' => sub { 15 | TestDB->setup; 16 | 17 | my $user = TestDB->create('User'); 18 | 19 | my $action = _build_action( 20 | req => POST( 21 | '/' => { 22 | old_password => 'foo', 23 | new_password => 'bar', 24 | new_password_confirmation => 'bar', 25 | } 26 | ), 27 | 'tu.user' => $user 28 | ); 29 | 30 | $action->run; 31 | 32 | is_deeply $action->vars->{errors}, {old_password => 'Invalid password'}; 33 | }; 34 | 35 | subtest 'validation error when new passwords do not match' => sub { 36 | TestDB->setup; 37 | 38 | my $user = TestDB->create('User'); 39 | my $action = _build_action( 40 | req => POST( 41 | '/' => { 42 | old_password => 'silly', 43 | new_password => 'bar', 44 | new_password_confirmation => 'baz', 45 | } 46 | ), 47 | 'tu.user' => $user 48 | ); 49 | 50 | $action->run; 51 | 52 | is_deeply $action->vars->{errors}, {new_password => 'Password mismatch'}; 53 | }; 54 | 55 | subtest 'change user password' => sub { 56 | TestDB->setup; 57 | 58 | my $user = TestDB->create('User'); 59 | my $action = _build_action( 60 | req => POST( 61 | '/' => { 62 | old_password => 'silly', 63 | new_password => 'bar', 64 | new_password_confirmation => 'bar', 65 | } 66 | ), 67 | 'tu.user' => $user 68 | ); 69 | 70 | $action->run; 71 | 72 | $user->load; 73 | 74 | ok $user->check_password('bar'); 75 | }; 76 | 77 | sub _build_action { 78 | my (%params) = @_; 79 | 80 | my $env = TestRequest->to_env(%params); 81 | 82 | my $action = Threads::Action::ChangePassword->new(env => $env); 83 | $action = Test::MonkeyMock->new($action); 84 | $action->mock(render => sub { '' }); 85 | 86 | return $action; 87 | } 88 | 89 | done_testing; 90 | -------------------------------------------------------------------------------- /public/unsemantic/css/reset.css: -------------------------------------------------------------------------------- 1 | a, 2 | abbr, 3 | acronym, 4 | address, 5 | applet, 6 | article, 7 | aside, 8 | audio, 9 | b, 10 | big, 11 | blockquote, 12 | body, 13 | canvas, 14 | caption, 15 | center, 16 | cite, 17 | code, 18 | dd, 19 | del, 20 | details, 21 | dfn, 22 | dialog, 23 | div, 24 | dl, 25 | dt, 26 | em, 27 | embed, 28 | fieldset, 29 | figcaption, 30 | figure, 31 | font, 32 | footer, 33 | form, 34 | h1, 35 | h2, 36 | h3, 37 | h4, 38 | h5, 39 | h6, 40 | header, 41 | hgroup, 42 | hr, 43 | html, 44 | i, 45 | iframe, 46 | img, 47 | ins, 48 | kbd, 49 | label, 50 | legend, 51 | li, 52 | main, 53 | mark, 54 | menu, 55 | meter, 56 | nav, 57 | object, 58 | ol, 59 | output, 60 | p, 61 | pre, 62 | progress, 63 | q, 64 | rp, 65 | rt, 66 | ruby, 67 | s, 68 | samp, 69 | section, 70 | small, 71 | span, 72 | strike, 73 | strong, 74 | sub, 75 | summary, 76 | sup, 77 | table, 78 | tbody, 79 | td, 80 | tfoot, 81 | th, 82 | thead, 83 | time, 84 | tr, 85 | tt, 86 | u, 87 | ul, 88 | var, 89 | video, 90 | xmp { 91 | border: 0; 92 | margin: 0; 93 | padding: 0; 94 | font-size: 100%; 95 | } 96 | 97 | html, 98 | body { 99 | height: 100%; 100 | } 101 | 102 | article, 103 | aside, 104 | details, 105 | figcaption, 106 | figure, 107 | footer, 108 | header, 109 | hgroup, 110 | main, 111 | menu, 112 | nav, 113 | section { 114 | display: block; 115 | } 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | img { 123 | color: transparent; 124 | font-size: 0; 125 | vertical-align: middle; 126 | -ms-interpolation-mode: bicubic; 127 | } 128 | 129 | ul, 130 | ol { 131 | list-style: none; 132 | } 133 | 134 | li { 135 | display: list-item; 136 | } 137 | 138 | table { 139 | border-collapse: collapse; 140 | border-spacing: 0; 141 | } 142 | 143 | th, 144 | td, 145 | caption { 146 | font-weight: normal; 147 | vertical-align: top; 148 | text-align: left; 149 | } 150 | 151 | q { 152 | quotes: none; 153 | } 154 | 155 | q:before, 156 | q:after { 157 | content: ""; 158 | content: none; 159 | } 160 | 161 | sub, 162 | sup, 163 | small { 164 | font-size: 75%; 165 | } 166 | 167 | sub, 168 | sup { 169 | line-height: 0; 170 | position: relative; 171 | vertical-align: baseline; 172 | } 173 | 174 | sub { 175 | bottom: -0.25em; 176 | } 177 | 178 | sup { 179 | top: -0.5em; 180 | } 181 | 182 | svg { 183 | overflow: hidden; 184 | } 185 | -------------------------------------------------------------------------------- /public/unsemantic/css/reset-rtl.css: -------------------------------------------------------------------------------- 1 | a, 2 | abbr, 3 | acronym, 4 | address, 5 | applet, 6 | article, 7 | aside, 8 | audio, 9 | b, 10 | big, 11 | blockquote, 12 | body, 13 | canvas, 14 | caption, 15 | center, 16 | cite, 17 | code, 18 | dd, 19 | del, 20 | details, 21 | dfn, 22 | dialog, 23 | div, 24 | dl, 25 | dt, 26 | em, 27 | embed, 28 | fieldset, 29 | figcaption, 30 | figure, 31 | font, 32 | footer, 33 | form, 34 | h1, 35 | h2, 36 | h3, 37 | h4, 38 | h5, 39 | h6, 40 | header, 41 | hgroup, 42 | hr, 43 | html, 44 | i, 45 | iframe, 46 | img, 47 | ins, 48 | kbd, 49 | label, 50 | legend, 51 | li, 52 | main, 53 | mark, 54 | menu, 55 | meter, 56 | nav, 57 | object, 58 | ol, 59 | output, 60 | p, 61 | pre, 62 | progress, 63 | q, 64 | rp, 65 | rt, 66 | ruby, 67 | s, 68 | samp, 69 | section, 70 | small, 71 | span, 72 | strike, 73 | strong, 74 | sub, 75 | summary, 76 | sup, 77 | table, 78 | tbody, 79 | td, 80 | tfoot, 81 | th, 82 | thead, 83 | time, 84 | tr, 85 | tt, 86 | u, 87 | ul, 88 | var, 89 | video, 90 | xmp { 91 | border: 0; 92 | margin: 0; 93 | padding: 0; 94 | font-size: 100%; 95 | } 96 | 97 | html, 98 | body { 99 | height: 100%; 100 | } 101 | 102 | article, 103 | aside, 104 | details, 105 | figcaption, 106 | figure, 107 | footer, 108 | header, 109 | hgroup, 110 | main, 111 | menu, 112 | nav, 113 | section { 114 | display: block; 115 | } 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | img { 123 | color: transparent; 124 | font-size: 0; 125 | vertical-align: middle; 126 | -ms-interpolation-mode: bicubic; 127 | } 128 | 129 | ul, 130 | ol { 131 | list-style: none; 132 | } 133 | 134 | li { 135 | display: list-item; 136 | } 137 | 138 | table { 139 | border-collapse: collapse; 140 | border-spacing: 0; 141 | } 142 | 143 | th, 144 | td, 145 | caption { 146 | font-weight: normal; 147 | vertical-align: top; 148 | text-align: right; 149 | } 150 | 151 | q { 152 | quotes: none; 153 | } 154 | 155 | q:before, 156 | q:after { 157 | content: ""; 158 | content: none; 159 | } 160 | 161 | sub, 162 | sup, 163 | small { 164 | font-size: 75%; 165 | } 166 | 167 | sub, 168 | sup { 169 | line-height: 0; 170 | position: relative; 171 | vertical-align: baseline; 172 | } 173 | 174 | sub { 175 | bottom: -0.25em; 176 | } 177 | 178 | sup { 179 | top: -0.5em; 180 | } 181 | 182 | svg { 183 | overflow: hidden; 184 | } 185 | --------------------------------------------------------------------------------