package Convos; =head1 NAME Convos - Multiuser IRC proxy with web interface =head1 VERSION 0.84 =head1 DESCRIPTION Convos is to a multi-user IRC Proxy, that also provides a easy to use Web interface. Feature list: =over 4 =item * Always online The backend server will keep you logged in and logs all the activity in your archive. =item * Archive All chats will be logged and indexed, which allow you to search in earlier conversations. =item * Avatars The chat contains profile pictures which can be retrieved from Facebook or from gravatar.com. =item * Include external resources Links to images and video will be displayed inline. No need to click on the link to view the data. =back =head2 Architecture principles =over 4 =item * Keep the JS simple and manageable =item * Use Redis to manage state / publish subscribe =item * Bootstrap-based user interface =back =head1 RUNNING CONVOS Convos has sane defaults so after installing L<Convos> you should be able to just run it: # Install $ cpanm Convos # Run it $ convos backend & $ convos daemon The above works, but if you have a lot of users you probably want to use L<hypnotoad|Mojo::Server::Hypnotoad> instead of C<daemon>: $ hypnotoad $(which convos) The command above will start a full featured, UNIX optimized, preforking non-blocking webserver. Run the same command again, and the webserver will L<hot reload|Mojo::Server::Hypnotoad/USR2> the source code without loosing any connections. Convos can be configured using L<Convos::Manual::Environment> and L<Convos::Manual::HttpHeaders>. =head1 CUSTOM TEMPLATES Some parts of the Convos templates can include custom content. Example: # Create a directory where you can store the templates $ mkdir -p custom-convos/vendor # Edit the template you want to customize $ $EDITOR custom-convos/vendor/login_footer.html.ep # Start convos with CONVOS_TEMPLATES set. Without /vendor at the end $ CONVOS_TEMPLATES=$PWD/custom-convos convos daemon --listen http://*:5000 Any changes to the templates require the server to restart. The templates that can be customized are: =over 4 =item * vendor/login_footer.html.ep This template will be included below the form on the C</login> page. =item * vendor/register_footer.html.ep This template will be included below the form on the C</register> page. =item * vendor/wizard.html.ep This template will be included below the form on the C</wizard> page that a new visitor sees after registering. =back =head1 RESOURCES =over 4 =item * Homepage: L<http://convos.by> =item * Project page: L<https://github.com/Nordaaker/convos> =item * Icon: L<https://raw.github.com/Nordaaker/convos/master/public/image/icon.svg> =item * Logo: L<https://raw.github.com/Nordaaker/convos/master/public/image/logo.svg> =back =head1 SEE ALSO =over 4 =item * L<Convos::Controller::Client> Mojolicious controller for IRC chat. =item * L<Convos::Controller::User> Mojolicious controller for user data. =item * L<Convos::Core> Backend functionality. =back =cut use Mojo::Base 'Mojolicious'; use Mojo::Redis; use Mojo::Util qw( md5_sum ); use File::Spec::Functions qw( catdir catfile tmpdir ); use File::Basename qw( dirname ); use Convos::Core; use Convos::Core::Util (); use Convos::Upgrader; our $VERSION = '0.84'; $ENV{CONVOS_DEFAULT_CONNECTION} //= 'irc.perl.org:7062'; =head1 ATTRIBUTES =head2 core Holds a L<Convos::Core> object . =head2 upgrader Holds a L<Convos::Upgrader> object. =cut has core => sub { my $self = shift; my $core = Convos::Core->new(redis => $self->redis); $core->log($self->log); $core->archive->log_dir($ENV{CONVOS_ARCHIVE_DIR} || $self->home->rel_dir('irc_logs')); $core; }; has upgrader => sub { Convos::Upgrader->new(redis => shift->redis); }; =head1 METHODS =head2 startup This method will run once at server start =cut sub startup { my $self = shift; my $config; $self->{convos_executable_path} = $0; # required to work from within toadfarm $self->_from_cpan; $config = $self->_config; if (my $log = $config->{log}) { $self->log->level($log->{level}) if $log->{level}; $self->log->path($log->{file}) if $log->{file} ||= $log->{path}; delete $self->log->{handle}; # make sure it's fresh to file } $self->ua->max_redirects(2); # support getting facebook pictures $self->plugin('Convos::Plugin::Helpers'); $self->plugin('surveil') if $ENV{CONVOS_SURVEIL}; $self->secrets([time]); # will be replaced by _set_secrets() $self->sessions->default_expiration(86400 * 30); $self->sessions->secure(1) if $ENV{CONVOS_SECURE_COOKIES}; $self->_assets; $self->_public_routes; $self->_private_routes; $self->_redis_url; if (!$ENV{CONVOS_INVITE_CODE} and $config->{invite_code}) { $self->log->warn( "invite_code from config file will be deprecated. Set the CONVOS_INVITE_CODE env variable instead."); $ENV{CONVOS_INVITE_CODE} = $config->{invite_code}; } if ($ENV{CONVOS_TEMPLATES}) { # Using push() since I don't think it's a good idea for allowing the user # to customize every template, at least not when the application is still # unstable. push @{$self->renderer->paths}, $ENV{CONVOS_TEMPLATES}; } $self->defaults(full_page => 1, organization_name => $self->config('name')); $self->defaults(full_page => 1); $self->hook(before_dispatch => \&_before_dispatch); Mojo::IOLoop->timer(5 => sub { $ENV{CONVOS_MANUAL_BACKEND} or $self->_start_backend; }); Mojo::IOLoop->timer(0 => sub { $ENV{CONVOS_SKIP_VERSION_CHECK} or $self->_check_version; }); Mojo::IOLoop->timer(0 => sub { $self->_set_secrets }); } sub _assets { my $self = shift; $self->plugin('AssetPack'); $self->plugin('FontAwesome4', css => []); $self->asset('c.css' => qw( /scss/font-awesome.scss /sass/convos.scss )); $self->asset( 'c.js' => qw( /js/globals.js /js/jquery.js /js/ws-reconnecting.js /js/jquery.hotkeys.js /js/jquery.finger.js /js/jquery.pjax.js /js/jquery.notify.js /js/jquery.disableouterscroll.js /js/selectize.js /js/convos.events.js /js/convos.sidebar.js /js/convos.socket.js /js/convos.input.js /js/convos.conversations.js /js/convos.nicks.js /js/convos.goto-anything.js /js/convos.chat.js ) ); } sub _before_dispatch { my $c = shift; $c->stash(full_page => !($c->req->is_xhr || $c->param('_pjax'))); if (my $base = $c->req->headers->header('X-Request-Base')) { $c->req->url->base(Mojo::URL->new($base)); } if (!$c->app->config->{hostname_is_set}++) { $c->redis->set('convos:frontend:url' => $c->req->url->base->to_abs->to_string); } } sub _check_version { my $self = shift; my $log = $self->log; $self->upgrader->running_latest( sub { my ($upgrader, $latest) = @_; $latest and return; $log->error( "The database schema has changed.\nIt must be updated before we can start!\n\nRun '$self->{convos_executable_path} upgrade', then try again.\n\n" ); exit; }, ); } sub _config { my $self = shift; my $config = $ENV{MOJO_CONFIG} ? $self->plugin('Config') : $self->config; $config->{hypnotoad}{listen} ||= [split /,/, $ENV{MOJO_LISTEN} || 'http://*:8080']; $config->{name} = $ENV{CONVOS_ORGANIZATION_NAME} if $ENV{CONVOS_ORGANIZATION_NAME}; $config->{name} ||= 'Nordaaker'; $config; } sub _from_cpan { my $self = shift; my $home = catdir dirname(__FILE__), 'Convos'; return if -d $self->home->rel_dir('templates'); $self->home->parse($home); $self->static->paths->[0] = $self->home->rel_dir('public'); $self->renderer->paths->[0] = $self->home->rel_dir('templates'); } sub _private_routes { my $self = shift; my $r = $self->routes->under('/')->to('user#auth', layout => 'view'); $self->plugin('LinkEmbedder'); $r->websocket('/socket')->to('chat#socket')->name('socket'); $r->get('/chat/command-history')->to('client#command_history'); $r->get('/chat/notifications')->to('client#notifications', layout => undef)->name('notification.list'); $r->post('/chat/notifications/clear')->to('client#clear_notifications', layout => undef)->name('notifications.clear'); $r->any('/connection/add')->to('connection#add_connection')->name('connection.add'); $r->any('/connection/:name/control')->to('connection#control')->name('connection.control'); $r->any('/connection/:name/edit')->to('connection#edit_connection')->name('connection.edit'); $r->get('/connection/:name/delete')->to(template => 'connection/delete_connection', layout => 'tactile'); $r->post('/connection/:name/delete')->to('connection#delete_connection')->name('connection.delete'); $r->get('/oembed')->to('oembed#generate', layout => undef)->name('oembed'); $r->any('/profile')->to('user#edit')->name('user.edit'); $r->post('/profile/timezone/offset')->to('user#tz_offset'); $r->get('/wizard')->to('connection#wizard')->name('wizard'); my $network_r = $r->any('/:network'); $network_r->get('/*target' => [target => qr/[\#\&][^\x07\x2C\s]{1,50}/])->to('client#conversation', is_channel => 1) ->name('view'); $network_r->get('/*target' => [target => qr/[A-Za-z_\-\[\]\\\^\{\}\|\`][A-Za-z0-9_\-\[\]\\\^\{\}\|\`]{1,15}/]) ->to('client#conversation', is_channel => 0)->name('view'); $network_r->get('/')->to('client#conversation')->name('view.network'); } sub _public_routes { my $self = shift; my $r = $self->routes->any->to(layout => 'tactile'); $r->get('/')->to('client#route')->name('index'); $r->get('/avatar')->to('user#avatar')->name('avatar'); $r->get('/login')->to('user#login')->name('login'); $r->post('/login')->to('user#login'); $r->get('/register/:invite', {invite => ''})->to('user#register')->name('register'); $r->post('/register/:invite', {invite => ''})->to('user#register'); $r->get('/logout')->to('user#logout')->name('logout'); $r; } sub _redis_url { my $self = shift; my $url; for my $k (qw( CONVOS_REDIS_URL REDISTOGO_URL REDISCLOUD_URL DOTCLOUD_DATA_REDIS_URL )) { $url = $ENV{$k} or next; $self->log->debug("Using $k environment variable as Redis connection URL."); last; } unless ($url) { if ($self->config('redis')) { $self->log->warn("'redis' url from config file will be deprecated. Run 'perldoc Convos' for alternative setup."); $url = $self->config('redis'); } elsif ($self->mode eq 'production') { $self->log->debug("Using default Redis connection URL redis://127.0.0.1:6379/1"); $url = 'redis://127.0.0.1:6379/1'; } else { $self->log->debug("Could not find CONVOS_REDIS_URL value."); return; } } $url = Mojo::URL->new($url); $url->path($ENV{CONVOS_REDIS_INDEX}) if $ENV{CONVOS_REDIS_INDEX} and !$url->path->[0]; $ENV{CONVOS_REDIS_URL} = $url->to_string; } sub _set_secrets { my $self = shift; my $redis = $self->redis; $self->delay( sub { my ($delay) = @_; $redis->lrange('convos:secrets', 0, -1, $delay->begin); $redis->getset('convos:secrets:lock' => 1, $delay->begin); $redis->expire('convos:secrets:lock' => 5); }, sub { my ($delay, $secrets, $locked) = @_; $secrets ||= $self->config->{secrets}; return $self->app->secrets($secrets) if $secrets and @$secrets; return $self->_set_secrets if $locked; $secrets = [md5_sum rand . $$ . time]; $self->app->secrets($secrets); $redis->lpush('convos:secrets', $secrets->[0]); $redis->del('convos:secrets:lock'); }, ); } sub _start_backend { my $self = shift; my $redis = $self->redis; $self->delay( sub { my ($delay) = @_; $redis->getset('convos:backend:lock' => 1, $delay->begin); $redis->get('convos:backend:pid', $delay->begin); $redis->expire('convos:backend:lock' => 5); }, sub { my ($delay, $locked, $pid) = @_; if ($pid and kill 0, $pid) { $self->log->debug("Backend $pid is running."); } elsif ($locked) { $self->log->debug('Another process is starting the backend.'); } elsif ($SIG{USR2}) { # hypnotoad $self->_start_backend_as_external_app; } elsif ($ENV{CONVOS_BACKEND_EMBEDDED} or !$SIG{QUIT}) { # forced or ./script/convos daemon $self->log->debug('Starting embedded backend.'); $redis->set('convos:backend:pid' => $$); $redis->del('convos:backend:lock'); $self->core->start; } else { # morbo $self->core->reset; $self->log->warn( 'Set CONVOS_BACKEND_EMBEDDED=1 to automatically start the backend from morbo. (The backend is not running)'); } }, ); } sub _start_backend_as_external_app { my $self = shift; local $0 = $self->{convos_executable_path}; if (!-x $0) { $self->log->error("Cannot execute $0: Not executable"); return; } if (my $pid = fork) { $self->log->debug("Starting $0 backend with double fork"); wait; # wait for "fork and exit" below $self->log->info("Detached $0 backend ($pid=$?)"); return $pid; # parent process returns } elsif (!defined $pid) { $self->log->error("Can't start external backend, fork failed: $!"); return; } # make sure the backend does not listen to the hypnotoad socket Mojo::IOLoop->reset; # start detaching new process from hypnotoad if (!POSIX::setsid) { $self->log->error("Can't start a new session for backend: $!"); exit $!; } # detach child from hypnotoad or die trying defined(fork and exit) or die; # replace fork with "convos backend" process delete $ENV{MOJO_CONFIG} if $ENV{TOADFARM_APPLICATION_CLASS}; $ENV{CONVOS_BACKEND_EMBEDDED} = 1; $self->log->debug("Replacing current process with $0 backend"); { exec $0 => 'backend' } $self->log->error("Failed to replace current process: $!"); exit; } =head1 COPYRIGHT AND LICENSE Copyright (C) 2012-2013, Nordaaker. This program is free software, you can redistribute it and/or modify it under the terms of the Artistic License version 2.0. =head1 AUTHOR Jan Henning Thorsen - C<jhthorsen@cpan.org> Marcus Ramberg - C<marcus@nordaaker.com> =cut 1;