diff --git a/vroom.pl b/vroom.pl deleted file mode 100755 index 72ef438..0000000 --- a/vroom.pl +++ /dev/null @@ -1,2408 +0,0 @@ -#!/usr/bin/env perl - -# This file is part of the VROOM project -# Released under the MIT licence -# Copyright 2014-2015 Daniel Berteaud - -use lib 'lib'; -use Mojolicious::Lite; -use Mojolicious::Plugin::Mail; -use Mojolicious::Plugin::Database; -use Mojolicious::Plugin::StaticCompressor; -use Mojo::Redis2; -use Vroom::Constants; -use Vroom::Conf; -use Crypt::SaltedHash; -use Digest::HMAC_SHA1 qw(hmac_sha1); -use MIME::Base64; -use File::stat; -use File::Basename; -use Session::Token; -use Email::Valid; -use Protocol::SocketIO::Handshake; -use Protocol::SocketIO::Message; -use File::Path qw(make_path); -use File::Basename; -use DateTime; -use Array::Diff; -use Data::Dumper; - -app->log->level('info'); - -our $config = Vroom::Conf::get_conf(); - -# Try to create the directories we need -foreach my $dir (qw/assets/){ - if (!-d $config->{'directories.cache'} . '/' . $dir){ - make_path($config->{'directories.cache'} . '/' . $dir, { mode => 0770 }); - } - elsif (!-w $config->{'directories.cache'} . '/' . $dir){ - die $config->{'directories.cache'} . '/' . "$dir is not writable"; - } -} - -# Optional features -our $optf = {}; - -# Create etherpad api client if enabled -if ($config->{'etherpad.uri'} =~ m/https?:\/\/.*/ && $config->{'etherpad.api_key'} ne ''){ - my $etherpad = eval { require Etherpad }; - if ($etherpad){ - import Etherpad; - $optf->{etherpad} = Etherpad->new({ - url => $config->{'etherpad.uri'}, - apikey => $config->{'etherpad.api_key'} - }); - if (!$optf->{etherpad}->check_token){ - app->log->info("Can't connect to Etherpad-Lite API, check your API key and uri"); - $optf->{etherpad} = undef; - } - } - else{ - app->log->info("Etherpad perl module not found, disabling Etherpad-Lite support"); - } -} - -# Check if Excel export is available -my $excel = eval { - require File::Temp; - require Excel::Writer::XLSX; - require Mojolicious::Plugin::RenderFile; -}; -if ($excel){ - import File::Temp; - import Excel::Writer::XLSX; - import Mojolicious::Plugin::RenderFile; - $optf->{excel} = 1; -} - -# Global error check -our $error = undef; - -our $listeners = {}; - -# Initialize localization -plugin I18N => { - namespace => 'Vroom::I18N', -}; - -# Connect to the database -# Only MySQL supported for now -plugin database => { - dsn => $config->{'database.dsn'}, - username => $config->{'database.user'}, - password => $config->{'database.password'}, - options => { - mysql_enable_utf8 => 1, - mysql_auto_reconnect => 1, - RaiseError => 1, - PrintError => 0 - } -}; - -# Load mail plugin with its default values -plugin mail => { - from => $config->{'email.from'}, - type => 'text/html', -}; - -# Static resources compressor -plugin StaticCompressor => { - url_path_prefix => 'assets', - file_cache_path => $config->{'directories.cache'} . '/assets/', - disable_on_devmode => 1 -}; - -# Stream files -plugin 'RenderFile'; - -########################## -# Validation helpers # -########################## - -# Take a string as argument and check if it's a valid room name -helper valid_room_name => sub { - my $self = shift; - my $name = shift; - my $ret = {}; - # A few names are reserved - my @reserved = qw(about help feedback feedback_thanks goodbye admin locales api - missing dies kicked invitation js css img fonts snd documentation); - if (!$name || $name !~ m/^[\w\-]{1,49}$/ || grep { $name eq $_ } @reserved){ - return 0; - } - return 1; -}; - -# Check arg is a valid ID number -helper valid_id => sub { - my $self = shift; - my $id = shift; - if (!$id || $id !~ m/^\d+$/){ - return 0; - } - return 1; -}; - -# Check email address format -helper valid_email => sub { - my $self = shift; - my $email = shift; - return Email::Valid->address($email); -}; - -# Validate a date in YYYY-MM-DD format -# Also accepts YYYY-MM-DD hh:mm:ss -helper valid_date => sub { - my $self = shift; - my $date = shift; - if ($date !~ m/^\d{4}\-\d{1,2}\-\d{1,2}(\s+\d{1,2}:\d{1,2}:\d{1,2})?$/){ - $self->app->log->debug("$date is not a valid date"); - return 0; - } - return 1; -}; - -########################## -# Various helpers # -########################## - -# Check if the database schema is the one we expect -helper check_db_version => sub { - my $self = shift; - my $sth = eval { - $self->db->prepare('SELECT `value` - FROM `config` - WHERE `key`=\'schema_version\''); - }; - $sth->execute; - my $ver = undef; - $sth->bind_columns(\$ver); - $sth->fetch; - return ($ver eq Vroom::Constants::DB_VERSION) ? '1' : '0'; -}; - -# Helper to access redis objects -helper redis => sub { - my $self = shift; - state $redis = Mojo::Redis2->new(url => $config->{'database.redis'}); -}; - -# Get optional features -helper get_opt_features => sub { - my $self = shift; - return $optf; -}; - -# Log an event -helper log_event => sub { - my $self = shift; - my $event = shift; - if (!$event->{event} || !$event->{msg}){ - $self->app->log->debug("Oops, invalid event received"); - return 0; - } - my $addr = $self->tx->remote_address || '127.0.0.1'; - my $user = $self->get_name || 'VROOM daemon'; - my $sth = eval { - $self->db->prepare('INSERT INTO `audit` (`date`,`event`,`from_ip`,`user`,`message`) - VALUES (CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\'),?,?,?,?)'); - }; - $sth->execute( - $event->{event}, - $addr, - $user, - $event->{msg} - ); - $self->app->log->info('[' . $addr . '] [' . $user . '] [' . $event->{event} . '] ' . $event->{msg}); - return 1; -}; - -# Return peers from redis -helper get_peers => sub { - my $self = shift; - my $peers = {}; - foreach my $id (@{$self->redis->hkeys('peers')}){ - my $peer = $self->get_peer($id); - $peers->{$id} = $peer if $peer; - } - return $peers; -}; - -# Return a single peer -helper get_peer => sub { - my $self = shift; - my $peer = shift; - my $p = $self->redis->hget('peers', $peer); - if ($p){ - return Mojo::JSON::from_json($p); - } - return 0; -}; - -# Store peers in redis -helper add_peer => sub { - my $self = shift; - my $id = shift; - my $peer = shift; - return $self->redis->hset('peers', $id, Mojo::JSON::to_json($peer)); -}; - -# Remove a peer -helper del_peer => sub { - my $self = shift; - my $id = shift; - return $self->redis->hdel('peers', $id); -}; - -# Return a list of event between 2 dates -helper get_event_list => sub { - my $self = shift; - my $start = shift; - my $end = shift; - # Check both start and end dates seems valid - if (!$self->valid_date($start) || !$self->valid_date($end)){ - $self->app->log->debug("Invalid date submitted while looking for events"); - return 0; - } - my $sth; - $sth = eval { - $self->db->prepare('SELECT * FROM `audit` - WHERE `date`>=? - AND `date`<=?'); - }; - # We want both dates to be inclusive, as the default time is 00:00:00 - # if not given, append 23:59:59 to the end date - $end .= ' 23:59:59' if ($end !~ /\s+\d{1,2}:\d{1,2}:\d{1,2}$/); - $sth->execute($start, $end); - # Everything went fine, return the list of event as a hashref - return $sth->fetchall_hashref('id'); -}; - -# Generate and manage rotation of session keys -# used to sign cookies -helper update_session_keys => sub { - my $self = shift; - # First, delete obsolete session keys - my $sth = eval { - $self->db->prepare('DELETE FROM `session_keys` - WHERE `date` < DATE_SUB(CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\'), INTERVAL 72 HOUR)'); - }; - $sth->execute; - # Now, retrieve all remaining keys, to check if we have enough of them - $sth = eval { - $self->db->prepare('SELECT `key` FROM `session_keys` - ORDER BY `date` DESC'); - }; - $sth->execute; - my $keys = $sth->fetchall_hashref('key'); - my @keys = keys %$keys; - # Now, check how many keys are less than 24 hours old - $sth = eval { - $self->db->prepare('SELECT COUNT(`key`) FROM `session_keys` - WHERE `date` > DATE_SUB(CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\'), INTERVAL 24 HOUR)'); - }; - $sth->execute; - my $recent_keys = $sth->fetchrow; - if ($recent_keys < 1){ - $self->app->log->debug("Generating a new key to sign session cookies"); - my $new_key = Session::Token->new( - alphabet => ['a'..'z', 'A'..'Z', '0'..'9', '.:;,/!%$#~{([-_)]}=+*|'], - entropy => 512 - )->get; - unshift @keys, $new_key; - $sth = eval { - $self->db->prepare('INSERT INTO `session_keys` (`key`,`date`) - VALUES (?,CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\'))'); - }; - $sth->execute($new_key); - } - $self->app->secrets(\@keys); - return 1; -}; - -# Return human readable username if it exists, or just the session ID -helper get_name => sub { - my $self = shift; - if ($ENV{'REMOTE_USER'} && $ENV{'REMOTE_USER'} ne ''){ - return $ENV{'REMOTE_USER'}; - } - return $self->session('id'); -}; - -# Create a cookie based session -# And a new API key -helper login => sub { - my $self = shift; - if ($self->session('id') && $self->session('id') ne ''){ - return 1; - } - my $id = $self->get_random(256); - my $key = $self->get_random(256); - my $sth = eval { - $self->db->prepare('INSERT INTO `api_keys` - (`token`,`not_after`) - VALUES (?,DATE_ADD(CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\'), INTERVAL 24 HOUR))'); - }; - $sth->execute($key); - $self->session( - id => $id, - key => $key - ); - $self->log_event({ - event => 'session_create', - msg => 'User logged in' - }); - return 1; -}; - -# Force the session cookie to expire on logout -helper logout => sub { - my $self = shift; - my $room = shift; - # Logout from etherpad - if ($optf->{etherpad} && $self->session($room) && $self->session($room)->{etherpadSessionId}){ - $optf->{etherpad}->delete_session($self->session($room)->{etherpadSessionId}); - } - my $sth = eval { - $self->db->prepare('DELETE FROM `api_keys` - WHERE `token`=?'); - }; - $sth->execute($self->session('key')); - $self->session( expires => 1 ); - $self->log_event({ - event => 'session_destroy', - msg => 'User logged out' - }); - return 1; -}; - -# Create a new room in the DB -# Requires one arg: the name of the room -helper create_room => sub { - my $self = shift; - my $name = shift; - # Convert room names to lowercase - if ($name ne lc $name){ - $name = lc $name; - } - # Check if the name is valid - if (!$self->valid_room_name($name)){ - return 0; - } - # Fail if the room already exists - if ($self->get_room_by_name($name)){ - return 0; - } - my $sth = eval { - $self->db->prepare('INSERT INTO `rooms` - (`name`, - `create_date`, - `last_activity`) - VALUES (?, - CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\'), - CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\') - )'); - }; - $sth->execute($name); - $self->log_event({ - event => 'room_create', - msg => "Room $name created" - }); - # Create a pad if enabled - if ($optf->{etherpad}){ - $self->create_pad($name); - } - return 1; -}; - -# Takse a string as argument -# Return a room object if a room with that name is found -# Else return undef -helper get_room_by_name => sub { - my $self = shift; - my $name = shift; - if (!$self->valid_room_name($name)){ - return 0; - } - my $sth = eval { - $self->db->prepare('SELECT * - FROM `rooms` - WHERE `name`=?'); - }; - $sth->execute($name); - return $sth->fetchall_hashref('name')->{$name} -}; - -# Same as get_room_by_name, but take a room ID as argument -helper get_room_by_id => sub { - my $self = shift; - my $id = shift; - if (!$self->valid_id($id)){ - return 0; - } - my $sth = eval { - $self->db->prepare('SELECT * - FROM `rooms` - WHERE `id`=?'); - }; - $sth->execute($id); - return $sth->fetchall_hashref('id')->{$id}; -}; - -# Update a room, take a room object as argument (hashref) -helper modify_room => sub { - my $self = shift; - my $room = shift; - if (!$self->valid_id($room->{id}) || !$self->valid_room_name($room->{name})){ - return 0; - } - my $old_room = $self->get_room_by_id($room->{id}); - if (!$old_room){ - return 0; - } - if (!$room->{max_members} || - ($room->{max_members} > $config->{'rooms.max_members'} && $config->{'rooms.max_members'} > 0)){ - $room->{max_members} = 0; - } - if (($room->{locked} && $room->{locked} !~ m/^0|1$/) || - ($room->{ask_for_name} && $room->{ask_for_name} !~ m/^0|1$/) || - ($room->{persistent} && $room->{persistent} !~ m/^0|1$/) || - $room->{max_members} !~ m/^\d+$/){ - return 0; - } - # Merge old and new params - $room = { %$old_room, %$room }; - my $sth = eval { - $self->db->prepare('UPDATE `rooms` - SET `locked`=?, - `ask_for_name`=?, - `join_password`=?, - `owner_password`=?, - `persistent`=?, - `max_members`=? - WHERE `id`=?'); - }; - $sth->execute( - $room->{locked}, - $room->{ask_for_name}, - $room->{join_password}, - $room->{owner_password}, - $room->{persistent}, - $room->{max_members}, - $room->{id} - ); - my $msg = "Room " . $room->{name} ." modified"; - my $mods = ''; - # Now, log which fields have been modified - foreach my $field (keys %$room){ - if (($old_room->{$field} // '' ) ne ($room->{$field} // '')){ - # Just hide passwords - if ($field =~ m/_password$/){ - $old_room->{$field} = ($old_room->{$field}) ? '' : ''; - $room->{$field} = ($room->{$field}) ? '' : ''; - } - $mods .= $field . ": " . $old_room->{$field} . ' -> ' . $room->{$field} . "\n"; - } - } - if ($mods ne ''){ - chomp($mods); - $msg .= "\nModified fields:\n$mods"; - $self->log_event({ - event => 'room_modify', - msg => $msg - }); - } - return 1; -}; - -# Set the role of a peer -helper set_peer_role => sub { - my $self = shift; - my $data = shift; - # Check the peer exists and is already in the room - if (!$data->{peer_id}){ - return 0; - } - my $peer = $self->get_peer($data->{peer_id}); - if (!$peer){ - return 0; - } - $peer->{role} = $data->{role}; - $self->log_event({ - event => 'peer_role', - msg => "Peer " . $data->{peer_id} . " has now the " . - $data->{role} . " role in room " . $peer->{room} - }); - return $self->add_peer($data->{peer_id}, $peer); -}; - -# Return the role of a peer, take a peer object as arg ($data = { peer_id => XYZ }) -helper get_peer_role => sub { - my $self = shift; - my $peer_id = shift; - return $self->get_peer($peer_id)->{role}; -}; - -# Promote a peer to owner -helper promote_peer => sub { - my $self = shift; - my $peer_id = shift; - return $self->set_peer_role({ - peer_id => $peer_id, - role => 'owner' - }); -}; - -# Purge api keys -helper purge_api_keys => sub { - my $self = shift; - $self->app->log->debug('Removing expired API keys'); - my $sth = eval { - $self->db->prepare('DELETE FROM `api_keys` - WHERE `not_after` < CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\')'); - }; - $sth->execute; - return 1; -}; - -# Purge unused rooms -helper purge_rooms => sub { - my $self = shift; - $self->app->log->debug('Removing unused rooms'); - my $sth = eval { - $self->db->prepare('SELECT `name`,`etherpad_group` - FROM `rooms` - WHERE `last_activity` < DATE_SUB(CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\'), - INTERVAL ' . $config->{'rooms.inactivity_timeout'} . ' MINUTE) - AND `persistent`=\'0\' AND `owner_password` IS NULL'); - }; - $sth->execute; - my $toDelete = {}; - while (my ($room,$ether_group) = $sth->fetchrow_array){ - $toDelete->{$room} = $ether_group; - } - if ($config->{'rooms.reserved_inactivity_timeout'} > 0){ - $sth = eval { - $self->db->prepare('SELECT `name`,`etherpad_group` - FROM `rooms` - WHERE `last_activity` < DATE_SUB(CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\'), - INTERVAL ' . $config->{'rooms.reserved_inactivity_timeout'} . ' MINUTE) - AND `persistent`=\'0\' AND `owner_password` IS NOT NULL') - }; - $sth->execute; - while (my ($room, $ether_group) = $sth->fetchrow_array){ - $toDelete->{$room} = $ether_group; - } - } - foreach my $room (keys %{$toDelete}){ - $self->log_event({ - event => 'room_expire', - msg => "Deleting room $room after inactivity timeout" - }); - # Remove Etherpad group - if ($optf->{etherpad}){ - $optf->{etherpad}->delete_pad($toDelete->{$room} . '$' . $room); - $optf->{etherpad}->delete_group($toDelete->{$room}); - } - } - # Now remove rooms - if (keys %{$toDelete} > 0){ - $sth = eval { - $self->db->prepare("DELETE FROM `rooms` - WHERE `name` IN (" . join( ",", map { "?" } keys %{$toDelete} ) . ")"); - }; - $sth->execute(keys %{$toDelete}); - } - return 1; -}; - -# delete just a specific room, by name -helper delete_room => sub { - my $self = shift; - my $room = shift; - $self->app->log->debug("Removing room $room"); - my $data = $self->get_room_by_name($room); - if (!$data){ - $self->app->log->debug("Error: room $room doesn't exist"); - return 0; - } - if ($optf->{etherpad} && $data->{etherpad_group}){ - $optf->{etherpad}->delete_pad($data->{etherpad_group} . '$' . $room); - $optf->{etherpad}->delete_group($data->{etherpad_group}); - } - my $sth = eval { - $self->db->prepare('DELETE FROM `rooms` - WHERE `name`=?'); - }; - $sth->execute($room); - $self->log_event({ - event => 'room_delete', - msg => "Deleting room $room" - }); - return 1; -}; - -# Retrieve the list of rooms -helper get_room_list => sub { - my $self = shift; - my $sth = eval { - $self->db->prepare('SELECT * - FROM `rooms`'); - }; - $sth->execute; - return $sth->fetchall_hashref('name'); -}; - -# Just update the activity timestamp -# so we can detect unused rooms -helper update_room_last_activity => sub { - my $self = shift; - my $name = shift; - my $data = $self->get_room_by_name($name); - if (!$data){ - return 0; - } - my $sth = eval { - $self->db->prepare('UPDATE `rooms` - SET `last_activity`=CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\') - WHERE `id`=?'); - }; - $sth->execute($data->{id}); - return 1; -}; - -# Return an array of supported languages -helper get_supported_lang => sub { - my $self = shift; - return map { basename(s/\.po$//r) } glob('lib/Vroom/I18N/*.po'); -}; - -# Generate a random token -helper get_random => sub { - my $self = shift; - my $entropy = shift; - return Session::Token->new(entropy => $entropy)->get; -}; - -# Generate a random name -helper get_random_name => sub { - my $self = shift; - my $name = lc $self->get_random(64); - # Get another one if already taken - while ($self->get_room_by_name($name)){ - $name = $self->get_random_name(); - } - return $name; -}; - -# Add an email address to the list of notifications -helper add_notification => sub { - my $self = shift; - my $room = shift; - my $email = shift; - my $data = $self->get_room_by_name($room); - if (!$data || !$self->valid_email($email)){ - return 0; - } - my $sth = eval { - $self->db->prepare('INSERT INTO `email_notifications` - (`room_id`,`email`) - VALUES (?,?)'); - }; - $sth->execute( - $data->{id}, - $email - ); - return 1; -}; - -# Update the list of notified email for a room in one go -# Take the room and an array ref of emails -helper update_email_notifications => sub { - my $self = shift; - my $room = shift; - my $emails = shift; - my $data = $self->get_room_by_name($room); - if (!$data){ - return 0; - } - my $old = $self->get_email_notifications($room); - my @old = sort map { $old->{$_}->{email} } keys $old; - my @new = sort @$emails; - # Remove empty email - @new = grep { $_ ne '' } @new; - my $diff = Array::Diff->diff(\@old, \@new); - # Are we changing the list of email ? - if ($diff->count > 0){ - my $msg = "Notification list for room $room has changed\n"; - if (scalar @{$diff->deleted} > 0){ - $msg .= "Emails being removed: " . join (', ', @{$diff->deleted}) . "\n"; - } - if (scalar @{$diff->added} > 0){ - $msg .= "Emails being added: " . join (', ', @{$diff->added}) . "\n"; - } - $self->log_event({ - event => 'email_notification_change', - msg => $msg - }); - } - # First, drop all existing notifications - my $sth = eval { - $self->db->prepare('DELETE FROM `email_notifications` - WHERE `room_id`=?'); - }; - $sth->execute( - $data->{id}, - ); - # Now, insert new emails - foreach my $email (@new){ - $self->add_notification($room,$email) || return 0; - } - return 1; -}; - -# Return the list of email addresses -helper get_email_notifications => sub { - my $self = shift; - my $room = shift; - $room = $self->get_room_by_name($room); - return 0 if (!$room); - my $sth = eval { - $self->db->prepare('SELECT `id`,`email` - FROM `email_notifications` - WHERE `room_id`=?'); - }; - $sth->execute($room->{id}); - return $sth->fetchall_hashref('id'); -}; - -# Randomly choose a music on hold -helper choose_moh => sub { - my $self = shift; - my @files = (); - return basename($files[rand @files]); -}; - -# Add a invitation -helper add_invitation => sub { - my $self = shift; - my $room = shift; - my $email = shift; - my $data = $self->get_room_by_name($room); - return 0 if (!$data); - my $token = $self->get_random(256); - my $sth = eval { - $self->db->prepare('INSERT INTO `email_invitations` - (`room_id`,`from`,`token`,`email`,`date`) - VALUES (?,?,?,?,CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\'))'); - }; - $sth->execute( - $data->{id}, - $self->session('id'), - $token, - $email - ); - $self->log_event({ - event => 'send_invitation', - msg => "Invitation to join room $room sent to $email" - }); - return $token; -}; - -# return a hash with all the invitation param -# just like get_room -helper get_invitation_by_token => sub { - my $self = shift; - my $token = shift; - my $sth = eval { - $self->db->prepare('SELECT * - FROM `email_invitations` - WHERE `token`=? - AND `processed`=\'0\''); - }; - $sth->execute($token); - return $sth->fetchall_hashref('token')->{$token}; -}; - -# Find invitations which have a unprocessed repsponse -helper get_invitation_list => sub { - my $self = shift; - my $session = shift; - my $sth = eval { - $self->db->prepare('SELECT * - FROM `email_invitations` - WHERE `from`=? - AND `response` IS NOT NULL - AND `processed`=\'0\''); - }; - $sth->execute($session); - return $sth->fetchall_hashref('id'); -}; - -# Got a response from invitation. Store the message in the DB -# so the organizer can get it -helper respond_to_invitation => sub { - my $self = shift; - my $token = shift; - my $response = shift; - my $message = shift; - my $sth = eval { - $self->db->prepare('UPDATE `email_invitations` - SET `response`=?, - `message`=? - WHERE `token`=?'); - }; - $sth->execute( - $response, - $message, - $token - ); - $self->log_event({ - event => 'invitation_response', - msg => "Invitation ID $token received a reply" - }); - return 1; -}; - -# Mark a invitation response as processed -helper mark_invitation_processed => sub { - my $self = shift; - my $token = shift; - my $sth = eval { - $self->db->prepare('UPDATE `email_invitations` - SET `processed`=\'1\' - WHERE `token`=?'); - }; - $sth->execute($token); - $self->log_event({ - event => 'invalidate_invitation', - msg => "Marking invitation $token as processed, it won't be usable anymore" - }); - return 1; -}; - -# Purge expired invitation links -# Invitations older than 2 hours really doesn't make a lot of sens -helper purge_invitations => sub { - my $self = shift; - $self->app->log->debug('Removing expired invitations'); - my $sth = eval { - $self->db->prepare('DELETE FROM `email_invitations` - WHERE `date` < DATE_SUB(CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\'), INTERVAL 2 HOUR)'); - }; - $sth->execute; - return 1; -}; - -# Check an invitation token is valid -helper check_invite_token => sub { - my $self = shift; - my $room = shift; - my $token = shift; - # Expire invitations before checking if it's valid - $self->purge_invitations; - my $ret = 0; - my $data = $self->get_room_by_name($room); - return 0 if (!$data || !$token); - $self->app->log->debug("Checking if invitation with token $token is valid for room $room"); - my $sth = eval { - $self->db->prepare('SELECT COUNT(`id`) - FROM `email_invitations` - WHERE `room_id`=? - AND `token`=? - AND (`response` IS NULL - OR `response`=\'later\')'); - }; - $sth->execute( - $data->{id}, - $token - ); - my $num; - $sth->bind_columns(\$num); - $sth->fetch; - if ($num != 1){ - $self->app->log->debug("Invitation is invalid"); - return 0; - } - $self->app->log->debug("Invitation is valid"); - return 1; -}; - -# Create a pad (and the group if needed) -helper create_pad => sub { - my $self = shift; - my $room = shift; - my $data = $self->get_room_by_name($room); - return 0 if (!$optf->{etherpad} || !$data); - # Create the etherpad group if not already done - # and register it in the DB - if (!$data->{etherpad_group} || $data->{etherpad_group} eq ''){ - $data->{etherpad_group} = $optf->{etherpad}->create_group(); - if (!$data->{etherpad_group}){ - return 0; - } - my $sth = eval { - $self->db->prepare('UPDATE `rooms` - SET `etherpad_group`=? - WHERE `id`=?'); - }; - $sth->execute( - $data->{etherpad_group}, - $data->{id} - ); - } - $optf->{etherpad}->create_group_pad($data->{etherpad_group}, $room); - $self->log_event({ - event => 'pad_create', - msg => "Creating group pad " . $data->{etherpad_group} . " for room $room" - }); - return 1; -}; - -# Create an etherpad session for a user -helper create_etherpad_session => sub { - my $self = shift; - my $room = shift; - my $data = $self->get_room_by_name($room); - if (!$optf->{etherpad} || !$data || !$data->{etherpad_group}){ - return 0; - } - my $id = $optf->{etherpad}->create_author_if_not_exists_for($self->get_name); - $self->session($room)->{etherpadAuthorId} = $id; - my $etherpadSession = $optf->{etherpad}->create_session( - $data->{etherpad_group}, - $id, - time + 86400 - ); - $self->session($room)->{etherpadSessionId} = $etherpadSession; - my $etherpadCookieParam = {}; - if ($config->{'etherpad.base_domain'} && $config->{'etherpad.base_domain'} ne ''){ - $etherpadCookieParam->{domain} = $config->{'etherpad.base_domain'}; - } - $self->cookie(sessionID => $etherpadSession, $etherpadCookieParam); - return 1; -}; - -# Get an API key by token -# just used to check if the key exists -helper get_key_by_token => sub { - my $self = shift; - my $token = shift; - if (!$token || $token eq ''){ - return 0; - } - my $sth = eval { - $self->db->prepare('SELECT * - FROM `api_keys` - WHERE `token`=? - AND `not_after` > CONVERT_TZ(NOW(), @@session.time_zone, \'+00:00\') - LIMIT 1'); - }; - $sth->execute($token); - return $sth->fetchall_hashref('token')->{$token}; -}; - -# Associate an API key to a room, and set the corresponding role -helper associate_key_to_room => sub { - my $self = shift; - my $data = shift; - my $room = $self->get_room_by_name($data->{room}); - my $key = $self->get_key_by_token($data->{key}); - return 0 if (!$room || !$key); - my $sth = eval { - $self->db->prepare('INSERT INTO `room_keys` - (`room_id`,`key_id`,`role`) - VALUES (?,?,?) - ON DUPLICATE KEY UPDATE `role`=?'); - }; - $sth->execute( - $room->{id}, - $key->{id}, - $data->{role}, - $data->{role} - ); - return 1; -}; - -# Make an API key admin of every rooms -helper make_key_admin => sub { - my $self = shift; - my $token = shift; - my $key = $self->get_key_by_token($token); - return 0 if (!$key); - my $sth = eval { - $self->db->prepare('UPDATE `api_keys` - SET `admin`=\'1\' - WHERE `id`=?'); - }; - $sth->execute($key->{id}); - $self->log_event({ - event => 'admin_key', - msg => "Granting API key $token admin privileges" - }); - return 1; -}; - -# Get the role of an API key for a room -helper get_key_role => sub { - my $self = shift; - my $token = shift; - my $room = shift; - my $key = $self->get_key_by_token($token); - if (!$key){ - $self->app->log->debug("Invalid API key"); - return 0; - } - # An admin key is considered owner of any room - if ($key->{admin}){ - return 'admin'; - } - # Now, lookup the DB the role of this key for this room - my $sth = eval { - $self->db->prepare('SELECT `role` - FROM `room_keys` - LEFT JOIN `rooms` ON `room_keys`.`room_id`=`rooms`.`id` - WHERE `room_keys`.`key_id`=? - AND `rooms`.`name`=? - LIMIT 1'); - }; - $sth->execute($key->{id},$room); - $sth->bind_columns(\$key->{role}); - $sth->fetch; - if ($key->{role}){ - $self->app->log->debug("Key $token has role:" . $key->{role} . " in room $room"); - } - return $key->{role}; -}; - -# Check if a key can perform an action against a room -helper key_can_do_this => sub { - my $self = shift; - my $data = shift; - my $actions = API_ACTIONS; - return 0 if (!$data->{action}); - # Anonymous actions - if ($actions->{anonymous}->{$data->{action}}){ - return 1; - } - my $role = $self->get_key_role($data->{token}, $data->{param}->{room}); - if (!$role){ - return 0; - } - # API key is an admin one ? - if ($role eq 'admin'){ - return 1; - } - # Global actions can only be performed by admin keys - if (!$data->{param}->{room}){ - return 0; - } - - # If this key has owner privileges on this room, allow both owner and partitipant actions - if ($role eq 'owner' && ($actions->{owner}->{$data->{action}} || $actions->{participant}->{$data->{action}})){ - return 1; - } - # If this key has simple participant priv in this room, only allow participant actions - elsif ($role eq 'participant' && $actions->{participant}->{$data->{action}}){ - return 1; - } - return 0; -}; - -# Get the list of members of a room -helper get_room_members => sub { - my $self = shift; - my $room = shift; - return 0 if (!$self->get_room_by_name($room)); - my @p; - my $peers = $self->get_peers; - foreach my $peer (keys %$peers){ - if ($peers->{$peer}->{room} && - $peers->{$peer}->{room} eq $room){ - push @p, $peer; - } - } - return @p; -}; - -# Broadcast a SocketIO message to all the members of a room -helper signal_broadcast_room => sub { - my $self = shift; - my $data = shift; - - # Send a message to all members of the same room as the sender - # except the sender himself - my $peers = $self->get_peers; - foreach my $peer (keys %$peers){ - next if $peer eq $data->{from}; - next if !$peers->{$data->{from}}->{room}; - next if !$peers->{$peer}->{room}; - next if $peers->{$peer}->{room} ne $peers->{$data->{from}}->{room}; - $self->redis->publish('signaling:peer:' . $peer, Mojo::JSON::to_json(Protocol::SocketIO::Message->new(%{$data->{msg}}))); - } - return 1; -}; - -# Get the member limit for a room -helper get_member_limit => sub { - my $self = shift; - my $name = shift; - my $room = $self->get_room_by_name($name); - if ($room->{max_members} > 0 && $room->{max_members} < $config->{'rooms.max_members'}){ - return $room->{max_members}; - } - elsif ($config->{'rooms.max_members'} > 0){ - return $config->{'rooms.max_members'}; - } - return 0; -}; - - -# Get credentials for the turn servers. Return an array (username,password) -helper get_turn_creds => sub { - my $self = shift; - my $room = $self->get_room_by_name(shift); - if (!$room){ - return (undef,undef); - } - elsif ($config->{'turn.credentials'} eq 'static'){ - return ($config->{'turn.turn_user'}, $config->{'turn.turn_password'}); - } - elsif ($config->{'turn.credentials'} eq 'rest'){ - my $expire = time + 300; - my $user = $expire . ':' . $room->{name}; - my $pass = encode_base64(hmac_sha1($user, $config->{'turn.secret_key'})); - chomp $pass; - return ($user, $pass); - } - return (undef, undef); -}; - -# Format room config as a hash to be sent in JSON response -helper get_room_conf => sub { - my $self = shift; - my $room = shift; - return { - owner_auth => ($room->{owner_password}) ? Mojo::JSON::true : Mojo::JSON::false, - join_auth => ($room->{join_password}) ? Mojo::JSON::true : Mojo::JSON::false, - locked => ($room->{locked}) ? Mojo::JSON::true : Mojo::JSON::false, - ask_for_name => ($room->{ask_for_name}) ? Mojo::JSON::true : Mojo::JSON::false, - persistent => ($room->{persistent}) ? Mojo::JSON::true : Mojo::JSON::false, - max_members => $room->{max_members}, - notif => $self->get_email_notifications($room->{name}) - }; -}; - -# Export events in XLSX -helper export_events_xlsx => sub { - my $self = shift; - my $from = shift; - my $to = shift; - my $tmp = File::Temp->new( DIR => $config->{'directories.tmp'}, SUFFIX => '.xlsx' )->filename; - my $events = $self->get_event_list($from, $to); - return 0 if (!$events); - my $xlsx = Excel::Writer::XLSX->new($tmp); - my $sheet = $xlsx->add_worksheet; - my @headers = qw(id date from_ip user event message); - $sheet->set_column(1, 1, 30); - $sheet->set_column(2, 2, 20); - $sheet->set_column(3, 3, 60); - $sheet->set_column(4, 4, 20); - $sheet->set_column(5, 5, 100); - # Write header - $sheet->write(0, 0, \@headers); - my $row = 1; - foreach my $e (sort {$a <=> $b } keys %$events){ - my @details = ( - $events->{$e}->{id}, - $events->{$e}->{date}, - $events->{$e}->{from_ip}, - $events->{$e}->{user}, - $events->{$e}->{event}, - $events->{$e}->{message} - ); - $sheet->write($row, 0, \@details); - # Adapt row heigh depending on the number of new lines - # in the message - my $cr = scalar(split("\n", $events->{$e}->{message})); - if ($cr > 1){ - $sheet->set_row($row, $cr*12); - } - $row++; - } - return $tmp; -}; - -# Disconnect a peer from the signaling channel -helper disconnect_peer => sub { - my $self = shift; - my $id = shift; - my $peers = $self->get_peers; - return 0 if (!$id || !$peers->{$id}); - if ($id && $peers->{$id} && $peers->{$id}->{room}){ - $self->log_event({ - event => 'room_leave', - msg => "Peer $id closed websocket connection, leaving room " . $peers->{$id}->{room} - }); - } - $self->signal_broadcast_room({ - from => $id, - msg => { - type => 'event', - data => { - name => 'remove', - args => [{ id => $id, type => 'video' }] - } - } - }); - $self->update_room_last_activity($peers->{$id}->{room}); - $self->del_peer($id); -}; - -# Socket.IO handshake -get '/socket.io/:ver' => sub { - my $self = shift; - my $sid = $self->get_random(256); - $self->session( peer_id => $sid ); - my $handshake = Protocol::SocketIO::Handshake->new( - session_id => $sid, - heartbeat_timeout => 20, - close_timeout => 40, - transports => [qw/websocket/] - ); - return $self->render(text => $handshake->to_bytes); -}; - -# WebSocket transport for the Socket.IO channel -websocket '/socket.io/:ver/websocket/:id' => sub { - my $self = shift; - my $id = $self->stash('id'); - my $loop = undef; - my $cb = undef; - - $self->inactivity_timeout(65); - - # the ID must match the one stored in our session - if ($id ne $self->session('peer_id')){ - $self->log_event({ - event => 'peer_id_mismatch', - msg => 'Something is wrong, peer ID is ' . $id . ' but should be ' . $self->session('peer_id') - }); - return $self->tx->send('Bad session id'); - } - - my $key = $self->session('key'); - - # Add the peer on redis - $self->add_peer($id, - { - last => time, - id => $self->session('id'), - check_invitations => 1 - } - ); - - $self->redis->subscribe(['signaling:peer:' . $id]); - - $cb = $self->redis->on(message => sub { - my ($redis, $message, $channel) = @_; - $self->tx->send(Protocol::SocketIO::Message->new->parse(Mojo::JSON::from_json($message))) - if ($channel eq 'signaling:peer:' . $id); - }); - - # When we recive a message, lets parse it as e Socket.IO one - $self->on('message' => sub { - my $self = shift; - my $peer = $self->get_peer($id); - my $peers = $self->get_peers; - my $msg = Protocol::SocketIO::Message->new->parse(shift); - - if ($msg->type eq 'event'){ - # Here's a client joining a room - if ($msg->{data}->{name} eq 'join'){ - my $room = $msg->{data}->{args}[0]; - my $role = $self->get_key_role($key, $room); - $peer->{role} = $role; - # Is this peer allowed to join the room ? - if (!$self->get_room_by_name($room) || - !$role || - $role !~ m/^(owner)|(participant)|(admin)$/){ - $self->log_event({ - event => 'no_role', - msg => "Failed to connect to the signaling channel, " . $self->get_name . " has no role in room $room" - }); - $self->send( Protocol::SocketIO::Message->new( type => 'disconnect' ) ); - $self->finish; - return; - } - # Are we under the limit of members ? - my $limit = $self->get_member_limit($room); - if ($limit > 0 && scalar $self->get_room_members($room) >= $limit){ - $self->log_event({ - event => 'member_off_limit', - msg => "Failed to connect to the signaling channel, members limit (" . $config->{'rooms.max_members'} . - ") is reached" - }); - $self->send( Protocol::SocketIO::Message->new( type => 'disconnect' ) ); - $self->finish; - return; - } - # Lets build the list of the other peers in the room to send to this new one - my $others = {}; - foreach my $peer (keys %$peers){ - next if $peer eq $id; - next if !$peers->{$peer}->{room}; - next if $peers->{$peer}->{room} ne $room; - $others->{$peer}->{screen} = ($peers->{$peer}->{details}->{screen} eq Mojo::JSON::true) ? \1 : \0; - $others->{$peer}->{video} = ($peers->{$peer}->{details}->{video} eq Mojo::JSON::true) ? \1 : \0; - $others->{$peer}->{audio} = ($peers->{$peer}->{details}->{audio} eq Mojo::JSON::true) ? \1 : \0; - } - $peer->{details} = { - screen => \0, - video => \1, - audio => \0 - }; - $peer->{room} = $room; - # Lets send the list of peers in our ack message - # Not sure why the null arg is needed, got it by looking at how it works with SignalMaster - $self->send( - Protocol::SocketIO::Message->new( - type => 'ack', - message_id => $msg->{id}, - args => [ - undef, - { - clients => $others - } - ] - ) - ); - $self->log_event({ - event => 'room_join', - msg => "Peer $id has joined room $room" - }); - # Update room last activity - $self->update_room_last_activity($room); - } - # We have a message from a peer - elsif ($msg->{data}->{name} eq 'message'){ - $msg->{data}->{args}[0]->{from} = $id; - my $to = $msg->{data}->{args}[0]->{to}; - # Unicast message ? Check if the dest is in the same room - # and send - if ($to && - $peers->{$to} && - $peers->{$to}->{room} && - $peers->{$to}->{room} eq $peers->{$id}->{room}){ - $self->redis->publish('signaling:peer:' . $to, Mojo::JSON::to_json($msg)); - } - # No dest, multicast this to every members of the room - else{ - $self->signal_broadcast_room({ - from => $id, - msg => $msg - }); - } - } - # When a peer shares its screen - elsif ($msg->{data}->{name} eq 'shareScreen'){ - $peer->{details}->{screen} = \1; - } - # Or unshares it - elsif ($msg->{data}->{name} eq 'unshareScreen'){ - $peer->{details}->{screen} = \0; - $self->signal_broadcast_room({ - from => $id, - msg => { - type => 'event', - data => { - name => 'remove', - args => [{ id => $id, type => 'screen' }] - } - } - }); - } - elsif ($msg->{data}->{name} =~ m/^leave|disconnect$/){ - $self->finish; - } - else{ - $self->app->log->debug("Unhandled SocketIO message\n" . Dumper $msg); - } - } - # Heartbeat reply, update timestamp - elsif ($msg->type eq 'heartbeat'){ - $peer->{last} = time; - # Update room last activity ~ every 40 heartbeats, so about every 2 minutes - if ((int (rand 200)) <= 5){ - $self->update_room_last_activity($peer->{room}); - } - } - $self->add_peer($id, $peer); - }); - - # Triggerred when a websocket connection ends - $self->on(finish => sub { - my $self = shift; - $self->disconnect_peer($id); - $self->redis->unsubscribe(['signaling:peer:' . $id], $cb) - if $cb; - Mojo::IOLoop->remove($loop) if $loop; - }); - - # Start a loop to send heartbeats every 3 sec - $loop = Mojo::IOLoop->recurring(3 => sub { - my $peer = $self->get_peer($id); - # Should we check invitations ? - if ($peer->{check_invitations}) { - my $invitations = $self->app->get_invitation_list($peer->{id}); - foreach my $invit (keys %{$invitations}){ - my $msg = ''; - $msg .= sprintf($self->l('INVITE_REPONSE_FROM_s'), $invitations->{$invit}->{email}) . "\n" ; - if ($invitations->{$invit}->{response} && $invitations->{$invit}->{response} eq 'later'){ - $msg .= $self->l('HE_WILL_TRY_TO_JOIN_LATER'); - } - else{ - $msg .= $self->l('HE_WONT_JOIN'); - } - if ($invitations->{$invit}->{message} && $invitations->{$invit}->{message} ne ''){ - $msg .= "\n" . $self->l('MESSAGE') . ":\n" . $invitations->{$invit}->{message} . "\n"; - } - app->mark_invitation_processed($invitations->{$invit}->{token}); - $self->send( - Protocol::SocketIO::Message->new( - type => 'event', - data => { - name => 'notification', - args => [{ - payload => {msg => $msg, class => 'info'} - }] - } - ) - ); - } - if (keys %{$invitations} > 0){ - delete $peer->{check_invitations}; - $self->add_peer($id, $peer); - } - } - $self->send(Protocol::SocketIO::Message->new( type => 'heartbeat' )); - }); - - # This is just the end of the initial handshake, we indicate the client we're ready - $self->send(Protocol::SocketIO::Message->new( type => 'connect' )); - -}; - -# Maintenance loop -# purge old stuff from the database -Mojo::IOLoop->recurring( 3600 => sub { - app->purge_rooms; - app->purge_invitations; - app->update_session_keys; -}); - -# Route / to the index page -get '/' => sub { - my $self = shift; - $self->login; - $self->stash( - page => 'index' - ); -} => 'index'; - -# Route for the about page -get '/about' => sub { - my $self = shift; - $self->stash( - page => 'about', - components => COMPONENTS, - musics => MOH - ); -} => 'about'; - -# Documentation -get '/documentation' => sub { - my $self = shift; - $self->stash( - page => 'documentation' - ); -} => 'documentation'; - -# Route for feedback form -any [ qw(GET POST) ] => '/feedback' => sub { - my $self = shift; - if ($self->req->method eq 'GET'){ - return $self->render('feedback', - page => 'feedback' - ); - } - my $email = $self->param('email'); - if ($email && $email ne '' && !$self->valid_email($email)){ - return $self->render('error', - err => 'ERROR_MAIL_INVALID', - msg => $self->l('ERROR_MAIL_INVALID'), - room => '' - ); - } - my $comment = $self->param('comment'); - my $sent = $self->mail( - to => $config->{'email.contact'}, - subject => $self->l("FEEDBACK_FROM_VROOM"), - data => $self->render_mail('feedback', - email => $email, - comment => $comment - ) - ); - return $self->render('feedback_thanks'); -}; - -# Route for the goodbye page, displayed when someone leaves a room -get '/goodbye/(:room)' => sub { - my $self = shift; - my $room = $self->stash('room'); - if (!$self->get_room_by_name($room)){ - return $self->render('error', - err => 'ERROR_ROOM_s_DOESNT_EXIST', - msg => sprintf ($self->l("ERROR_ROOM_s_DOESNT_EXIST"), $room), - room => $room - ); - } - $self->logout($room); -} => 'goodbye'; - -# Route for the kicked page -# Should be merged with the goodby route -get '/kicked/(:room)' => sub { - my $self = shift; - my $room = $self->stash('room'); - if (!$self->get_room_by_name($room)){ - return $self->render('error', - err => 'ERROR_ROOM_s_DOESNT_EXIST', - msg => sprintf ($self->l("ERROR_ROOM_s_DOESNT_EXIST"), $room), - room => $room - ); - } - $self->logout($room); -} => 'kicked'; - -# Route for invitition response -any [ qw(GET POST) ] => '/invitation/:token' => { token => '' } => sub { - my $self = shift; - my $token = $self->stash('token'); - # Delete expired invitation now - $self->purge_invitations; - my $invite = $self->get_invitation_by_token($token); - my $room = $self->get_room_by_id($invite->{room_id}); - if (!$invite || !$room){ - return $self->render('error', - err => 'ERROR_INVITATION_INVALID', - msg => $self->l('ERROR_INVITATION_INVALID'), - room => $room - ); - } - if ($self->req->method eq 'GET'){ - return $self->render('invitation', - token => $token, - room => $room->{name}, - ); - } - my $response = $self->param('response') || 'decline'; - my $message = $self->param('message') || ''; - if ($response !~ m/^(later|decline)$/ || - !$self->respond_to_invitation($token, $response, $message)){ - return $self->render('error', - err => 'ERROR_INVITATION_INVALID', - msg => $self->l('ERROR_INVITATION_INVALID'), - room => $room - ); - } - return $self->render('invitation_thanks'); -}; - -# Create a json script which contains localization -get '/locales/(:lang).js' => sub { - my $self = shift; - my $usr_lang = $self->languages; - my $req_lang = $self->stash('lang'); - # Force en if requested lang is not supported - $req_lang = 'en' unless grep { $_ eq $req_lang } $self->get_supported_lang; - # Temporarily switch to the requested locale - # eg, we can be in en and ask for /locales/fr.js - $self->languages($req_lang); - my $strings = {}; - foreach my $string (keys %Vroom::I18N::fr::Lexicon){ - next if $string eq ''; - # If the string is available in the requested locale, use it - if ($self->l($string) ne ''){ - $strings->{$string} = $self->l($string); - } - # Else, fallback to en - elsif ($req_lang ne 'en'){ - $self->languages('en'); - $strings->{$string} = $self->l($string); - $self->languages($req_lang); - } - # NO localization available - else{ - $strings->{$string} = $string; - } - } - # Set the user locale back - $self->languages($usr_lang); - # And send the response - return $self->render( - text => 'locale = ' . Mojo::JSON::to_json($strings) . ';', - format => 'application/javascript;charset=UTF-8' - ); -}; - -# API requests handler -any '/api' => sub { - my $self = shift; - $self->purge_api_keys; - my $token = $self->req->headers->header('X-VROOM-API-Key'); - my $req = Mojo::JSON::decode_json($self->param('req')); - my $room; - # action and param are required for every API call - if (!$req->{action} || !$req->{param}){ - return $self->render( - json => { - msg => $self->l('ERROR_OCCURRED'), - err => 'ERROR_OCCURRED' - }, - status => 503 - ); - } - # Handle requests authorized for anonymous users righ now - if ($req->{action} eq 'switch_lang'){ - if (!grep { $req->{param}->{language} eq $_ } $self->get_supported_lang()){ - return $self->render( - json => { - msg => $self->l('UNSUPPORTED_LANG'), - err => 'UNSUPPORTED_LANG' - }, - status => 400 - ); - } - $self->session(language => $req->{param}->{language}); - return $self->render( - json => {} - ); - } - - # Now, lets check if the API key can do the requested action - my $res = $self->key_can_do_this({ - token => $token, - action => $req->{action}, - param => $req->{param} - }); - - # This action isn't possible with the privs associated to the API Key - if (!$res){ - $self->log_event({ - event => 'api_action_denied', - msg => "Key $token called $req->{action} but has been denied" - }); - return $self->render( - json => { - msg => $self->l('NOT_ALLOWED'), - err => 'NOT_ALLOWED' - }, - status => '401' - ); - } - - # Now we know the action is allowed, but a few might not need to be logged - # so skip them now - if (!grep { $_ eq $req->{action} } API_NO_LOG){ - $self->log_event({ - event => 'api_action_allowed', - msg => "Key $token called $req->{action}" - }); - } - - # Here are methods not tied to a room - if ($req->{action} eq 'get_room_list'){ - my $rooms = $self->get_room_list; - foreach my $r (keys %{$rooms}){ - # Blank out a few param we don't need - foreach my $p (qw/join_password owner_password owner etherpad_group/){ - delete $rooms->{$r}->{$p}; - } - # Count active users - $rooms->{$r}->{members} = scalar $self->get_room_members($r); - } - return $self->render( - json => { - rooms => $rooms - } - ); - } - elsif ($req->{action} eq 'get_event_list'){ - my $start = $req->{param}->{start}; - my $end = $req->{param}->{end}; - $start = DateTime->now->ymd if $start eq ''; - $end = DateTime->now->ymd if $end eq ''; - # Validate input - if (!$self->valid_date($start) || !$self->valid_date($end)){ - return $self->render( - json => { - err => 'ERROR_INPUT_INVALID', - msg => $self->l('ERROR_INPUT_INVALID'), - status => 'error' - }, - ); - } - my $events = $self->get_event_list($start,$end); - foreach my $event (keys %{$events}){ - # Init NULL values to empty strings - foreach (qw(date from_ip event user message)){ - $events->{$event}->{$_} = '' unless $events->{$event}->{$_}; - } - } - # And send the list of event as a json object - return $self->render( - json => { - events => $events - } - ); - } - - # And here anonymous method, which do not require an API Key - elsif ($req->{action} eq 'create_room'){ - $req->{param}->{room} ||= $self->get_random_name(); - $req->{param}->{room} = lc $req->{param}->{room}; - my $json = { - err => 'ERROR_OCCURRED', - msg => $self->l('ERROR_OCCURRED'), - room => $req->{param}->{room} - }; - $self->login; - # Cleanup unused rooms before trying to create it - $self->purge_rooms; - if (!$self->valid_room_name($req->{param}->{room})){ - $json->{err} = 'ERROR_NAME_INVALID'; - $json->{msg} = $self->l('ERROR_NAME_INVALID'); - return $self->render(json => $json, status => 400); - } - elsif ($self->get_room_by_name($req->{param}->{room})){ - $json->{err} = 'ERROR_NAME_CONFLICT'; - $json->{msg} = $self->l('ERROR_NAME_CONFLICT'); - return $self->render(json => $json, status => 409); - } - if (!$self->create_room($req->{param}->{room})){ - $json->{err} = 'ERROR_OCCURRED'; - $json->{msg} = $self->l('ERROR_OCCURRED'); - return $self->render(json => $json, status => 500); - } - $json->{err} = ''; - # The creator of the room is owner - $self->associate_key_to_room({ - room => $req->{param}->{room}, - key => $token, - role => 'owner' - }); - return $self->render(json => $json); - } - - # Ok now, every other API calls need a room name - if (!$req->{param}->{room}){ - return $self->render( - json => { - msg => $self->l('ERROR_ROOM_NAME_MISSING'), - err => 'ERROR_ROOM_NAME_MISSING' - }, - status => '400' - ); - } - # And it must be a valid room name - $room = $self->get_room_by_name($req->{param}->{room}); - if (!$room){ - return $self->render( - json => { - msg => sprintf($self->l('ERROR_ROOM_s_DOESNT_EXIST'), $req->{param}->{room}), - err => 'ERROR_ROOM_DOESNT_EXIST' - }, - status => '400' - ); - } - - # Now, we don't have to bother with authorization anymore - # key_can_do_this already checked this - if ($req->{action} eq 'authenticate'){ - my $pass = $req->{param}->{password}; - my $role = $self->get_key_role($token, $room->{name}); - my $reason; - my $code = 401; - # Is he owner pasword provided ? - if ($room->{owner_password} && Crypt::SaltedHash->validate($room->{owner_password}, $pass)){ - $role = 'owner'; - } - # Or the participant pasword ? - elsif (!$role && $room->{join_password} && Crypt::SaltedHash->validate($room->{join_password}, $pass)){ - $role = 'participant'; - } - # User has no role yet, but room is not protected, so grant him the participant role - elsif (!$role && !$room->{join_password} && !$room->{locked}){ - $role = 'participant'; - } - if ($role){ - if (!$self->session($room->{name})){ - $self->session($room->{name} => {}); - } - if ($optf->{etherpad} && !$self->session($room->{name})->{etherpadSession}){ - $self->create_etherpad_session($room->{name}); - } - if ($self->session('peer_id')){ - $self->set_peer_role({ peer_id => $self->session('peer_id'), role => $role }); - } - $self->associate_key_to_room({ - room => $room->{name}, - key => $token, - role => $role - }); - return $self->render( - json => { - msg => $self->l('AUTH_SUCCESS'), - role => $role, - } - ); - } - # If no role, give the user a reason - elsif ($room->{locked} && $room->{owner_password}){ - # When room is locked, but an owner password is set - # we can enter with this password - $reason = $self->l('ROOM_LOCKED_ENTER_OWNER_PASSWORD'); - } - elsif ($room->{locked}){ - # When room is locked, without owner passwod, there's nothing to do - $reason = sprintf($self->l('ERROR_ROOM_s_LOCKED'), $room->{name}); - $code = 403; - } - elsif ((!$pass || $pass eq '') && $room->{join_password}){ - # password not given, and acces require one - $reason = $self->l('A_PASSWORD_IS_NEEDED_TO_JOIN') - } - elsif ($room->{join_password}){ - $reason = $self->l('WRONG_PASSWORD'); - } - return $self->render( - json => { - msg => $reason - }, - status => $code - ); - } - elsif ($req->{action} eq 'invite_email'){ - my $rcpts = $req->{param}->{rcpts}; - @$rcpts = grep { $_ ne '' } @$rcpts; - foreach my $addr (@$rcpts){ - if (!$self->valid_email($addr)){ - return $self->render( - json => { - msg => $self->l('ERROR_MAIL_INVALID'), - err => 'ERROR_MAIL_INVALID' - }, - status => 400 - ); - } - } - foreach my $addr (@$rcpts){ - my $token = $self->add_invitation( - $req->{param}->{room}, - $addr - ); - my $sent = $self->mail( - to => $addr, - subject => $self->l("EMAIL_INVITATION"), - data => $self->render_mail('invite', - room => $req->{param}->{room}, - message => $req->{param}->{message}, - token => $token, - joinPass => ($room->{join_password}) ? 'yes' : 'no' - ) - ); - if (!$token || !$sent){ - return $self->render( - json => { - msg => $self->l('ERROR_OCCURRED'), - err => 'ERROR_OCCURRED' - }, - status => 400 - ); - } - $self->app->log->info("Email invitation to join room " . $req->{param}->{room} . " sent to " . $addr); - } - # Mark the inviter as waiting for a reply - if ($self->session('peer_id')){ - my $peer = $self->get_peer($self->session('peer_id')); - $peer->{check_invitations} = 1; - $self->add_peer($self->session('peer_id'), $peer); - } - return $self->render( - json => { - msg => sprintf($self->l('INVITE_SENT_TO_s'), join("\n", @$rcpts)), - } - ); - } - # Update room configuration - elsif ($req->{action} eq 'update_room_conf'){ - # Cannot set an owner pass for some rooms - if ($req->{param}->{owner_password} && - grep { $_ eq $room->{name} } (split /[,;]/, $config->{'rooms.common_names'})){ - return $self->render( - json => { - msg => $self->l('ERROR_COMMON_ROOM_NAME'), - err => 'ERROR_COMMON_ROOM_NAME' - }, - status => 406 - ); - } - $room->{locked} = ($req->{param}->{locked}) ? '1' : '0'; - $room->{ask_for_name} = ($req->{param}->{ask_for_name}) ? '1' : '0'; - $room->{max_members} = $req->{param}->{max_members}; - # Room persistence can only be set by admins - if ($req->{param}->{persistent} ne '' && - $self->key_can_do_this({token => $token, action => 'set_persistent'})){ - $room->{persistent} = ($req->{param}->{persistent} eq Mojo::JSON::true) ? '1' : '0'; - } - foreach my $pass (qw/join_password owner_password/){ - if ($req->{param}->{$pass} eq Mojo::JSON::false){ - $room->{$pass} = undef; - } - elsif ($req->{param}->{$pass} ne ''){ - $room->{$pass} = Crypt::SaltedHash->new(algorithm => 'SHA-256')->add($req->{param}->{$pass})->generate; - } - } - if ($self->modify_room($room) && - $self->update_email_notifications($room->{name}, $req->{param}->{emails})){ - return $self->render( - json => { - msg => $self->l('ROOM_CONFIG_UPDATED') - } - ); - } - return $self->render( - json => { - msg => $self->l('ERROR_OCCURRED'), - err => 'ERROR_OCCURRED' - }, - staus => 503 - ); - } - # Return configuration for SimpleWebRTC - elsif ($req->{action} eq 'get_rtc_conf'){ - # Build a SimpleWebRTC configuration object - my $resp = { - url => Mojo::URL->new($self->url_for('/')->to_abs)->scheme('https'), - peerConnectionConfig => { - iceServers => [] - }, - autoRequestMedia => Mojo::JSON::true, - enableDataChannels => Mojo::JSON::true, - debug => Mojo::JSON::false, - detectSpeakingEvents => Mojo::JSON::true, - adjustPeerVolume => Mojo::JSON::false, - autoAdjustMic => Mojo::JSON::false, - harkOptions => { - interval => 300, - threshold => -20 - }, - media => { - audio => Mojo::JSON::true, - video => { - mandatory => { - maxFrameRate => $config->{'video.frame_rate'} - } - } - }, - localVideo => { - autoplay => Mojo::JSON::true, - mirror => Mojo::JSON::false, - muted => Mojo::JSON::true - } - }; - # Stun and turn server can be a simple url or an array - if ($config->{'turn.stun_server'}){ - if (ref $config->{'turn.stun_server'} ne 'ARRAY'){ - $config->{'turn.stun_server'} = [ $config->{'turn.stun_server'} ]; - } - foreach my $s (@{$config->{'turn.stun_server'}}){ - push @{$resp->{peerConnectionConfig}->{iceServers}}, { url => $s }; - } - } - if ($config->{'turn.turn_server'}){ - if (ref $config->{'turn.turn_server'} ne 'ARRAY'){ - $config->{'turn.turn_server'} = [ $config->{'turn.turn_server'} ]; - } - foreach my $t (@{$config->{'turn.turn_server'}}){ - my $turn = { url => $t }; - ($turn->{username}, $turn->{credential}) = $self->get_turn_creds($room->{name}); - push @{$resp->{peerConnectionConfig}->{iceServers}}, $turn; - } - } - return $self->render( - json => { - config => $resp - } - ); - } - # Return room config - elsif ($req->{action} eq 'get_room_conf'){ - my $resp = $self->get_room_conf($room); - my $role = $self->get_key_role($token,$room->{name}); - if (!$role || $role !~ m/^admin|owner$/){ - $self->app->log->debug("API Key $token is not admin, nor owner of room " . - $room->{name} . ", blanking out sensible data"); - $resp->{notif} = {}; - } - return $self->render( - json => $resp - ); - } - # Return the role of a peer - elsif ($req->{action} eq 'get_peer_role'){ - my $peer_id = $req->{param}->{peer_id}; - if (!$peer_id){ - return $self->render( - json => { - msg => $self->l('ERROR_PEER_ID_MISSING'), - err => 'ERROR_PEER_ID_MISSING' - }, - status => 400 - ); - } - if ($self->session('peer_id') && $self->session('peer_id') eq $peer_id){ - my $api_role = $self->get_key_role($token, $room->{name}); - # If we just have been promoted to owner - if ($api_role ne 'owner' && - $self->get_peer_role($peer_id) && - $self->get_peer_role($peer_id) eq 'owner'){ - $self->associate_key_to_room({ - room => $room->{name}, - key => $token, - role => 'owner' - }); - if (!$res){ - return $self->render( - json => { - msg => $self->l('ERROR_OCCURRED'), - err => 'ERROR_OCCURRED' - }, - status => 503 - ); - } - } - } - my $role = $self->get_peer_role($peer_id); - # In a room, an admin is just equivalent to an owner - $role = ($role eq 'admin') ? 'owner' : $role; - if (!$role){ - return $self->render( - json => { - msg => $self->l('ERROR_PEER_NOT_FOUND'), - err => 'ERROR_PEER_NOT_FOUND' - }, - status => 400 - ); - } - return $self->render( - json => { - role => $role, - } - ); - } - # Notify the backend when we join a room - elsif ($req->{action} eq 'join'){ - my $name = $req->{param}->{name} || ''; - my $peer_id = $req->{param}->{peer_id}; - my $subj = sprintf($self->l('s_JOINED_ROOM_s'), ($name eq '') ? - $self->l('SOMEONE') : $name, $room->{name}); - # Send notifications - my $recipients = $self->get_email_notifications($room->{name}); - foreach my $rcpt (keys %{$recipients}){ - $self->log_event( - event => 'join_notification', - msg => 'Sending an email to ' . $recipients->{$rcpt}->{email} . - ' to inform that someone joined room ' . $room->{name} - ); - my $sent = $self->mail( - to => $recipients->{$rcpt}->{email}, - subject => $subj, - data => $self->render_mail('notification', - room => $room->{name}, - name => $name - ) - ); - } - return $self->render( - json => {} - ); - } - # Promote a participant to be owner of a room - elsif ($req->{action} eq 'promote_peer'){ - my $peer_id = $req->{param}->{peer_id}; - if (!$peer_id){ - return $self->render( - json => { - msg => $self->l('ERROR_PEER_ID_MISSING'), - err => 'ERROR_PEER_ID_MISSING' - }, - status => 400 - ); - } - elsif ($self->promote_peer($peer_id)){ - return $self->render( - json => { - msg => $self->l('PEER_PROMOTED') - } - ); - } - return $self->render( - json => { - msg => $self->l('ERROR_OCCURRED'), - err => 'ERROR_OCCURRED' - }, - status => 503 - ); - } - # Wipe room data (chat history and etherpad content) - elsif ($req->{action} eq 'wipe_data'){ - if (!$optf->{etherpad} || - ($optf->{etherpad}->delete_pad($room->{etherpad_group} . '$' . $room->{name}) && - $self->create_pad($room->{name}) && - $self->create_etherpad_session($room->{name}))){ - return $self->render( - json => { - msg => $self->l('DATA_WIPED') - } - ); - } - return $self->render( - json => { - msg => $self->l('ERROR_OCCURRED'), - err => 'ERROR_OCCURRED', - }, - status => 503 - ); - } - # Get a new etherpad session - elsif ($req->{action} eq 'get_pad_session'){ - if ($self->create_etherpad_session($room->{name})){ - return $self->render( - json => { - msg => $self->l('SESSION_CREATED') - } - ); - } - return $self->render( - json => { - msg => $self->l('ERROR_OCCURRED'), - err => 'ERROR_OCCURRED', - }, - status => 503 - ); - } - # Delete a room - elsif ($req->{action} eq 'delete_room'){ - if ($self->delete_room($room->{name})){ - return $self->render( - json => { - msg => $self->l('ROOM_DELETED'), - } - ); - } - return $self->render( - json => { - msg => $self->l('ERROR_OCCURRED'), - err => 'ERROR_OCCURRED', - }, - status => 503 - ); - } -}; - -group { - under '/admin' => sub { - my $self = shift; - # For now, lets just pretend that anyone able to access - # /admin is already logged in (auth is managed outside of VROOM) - # TODO: support several auth method, including an internal one where user are managed - # in our DB, and another where auth is handled by the web server - $self->login; - my $role = $self->get_key_role($self->session('key'), undef); - if (!$role || $role ne 'admin'){ - $self->make_key_admin($self->session('key')); - } - $self->purge_rooms; - $self->stash(page => 'admin'); - return 1; - }; - - # Admin index - get '/' => sub { - my $self = shift; - return $self->render('admin'); - }; - - # Room management - get '/rooms' => sub { - my $self = shift; - return $self->render('admin_manage_rooms'); - }; - - # Audit - get '/audit' => sub { - my $self = shift; - return $self->render('admin_audit'); - }; - - get '/export_events' => sub { - my $self = shift; - if (!$optf->{excel}){ - return $self->render('error', - msg => $self->l('ERROR_FEATURE_NOT_AVAILABLE'), - err => 'ERROR_FEATURE_NOT_AVAILABLE', - room => '' - ); - } - my $from = $self->param('from') || DateTime->now->ymd; - my $to = $self->param('to') || DateTime->now->ymd; - my $file = $self->export_events_xlsx($from,$to); - if (!$file || !-e $file){ - return $self->render('error', - msg => $self->l('ERROR_EXPORT_XLSX'), - err => 'ERROR_EXPORT_XLSX', - room => '' - ); - } - $self->render_file( - filepath => $file, - filename => 'events.xlsx', - cleanup => 1, - format => 'vnd.openxmlformats-officedocument.spreadsheetml.sheet' - ); - }; -}; - -# Catch all route: if nothing else match, it's the name of a room -get '/:room' => sub { - my $self = shift; - my $room = $self->stash('room'); - my $video = $self->param('video') || '1'; - my $token = $self->param('token') || undef; - # Redirect to lower case - if ($room ne lc $room){ - $self->redirect_to($self->get_url('/') . lc $room); - } - $self->purge_rooms; - $self->purge_invitations; - my $res = $self->valid_room_name($room); - if (!$self->valid_room_name($room)){ - return $self->render('error', - msg => $self->l('ERROR_NAME_INVALID'), - err => 'ERROR_NAME_INVALID', - room => $room - ); - } - my $data = $self->get_room_by_name($room); - unless ($data){ - return $self->render('error', - err => 'ERROR_ROOM_s_DOESNT_EXIST', - msg => sprintf ($self->l("ERROR_ROOM_s_DOESNT_EXIST"), $room), - room => $room - ); - } - # Create a session if not already done - $self->login; - # If we've reached the members' limit - my $limit = $self->get_member_limit($room); - if ($limit > 0 && scalar $self->get_room_members($room) >= $limit){ - return $self->render('error', - msg => $self->l("ERROR_TOO_MANY_MEMBERS"), - err => 'ERROR_TOO_MANY_MEMBERS', - room => $room, - ); - } - if ($self->check_invite_token($room, $token)){ - $self->associate_key_to_room({ - room => $room, - key => $self->session('key'), - role => 'participant' - }); - } - # pad doesn't exist yet ? - if ($optf->{etherpad} && !$data->{etherpad_group}){ - $self->create_pad($room); - # Reload data so we get the etherpad_group - $data = $self->get_room_by_name($room); - } - # Now display the room page - return $self->render('join', - page => 'room', - moh => $self->choose_moh(), - video => $video, - etherpadGroup => $data->{etherpad_group}, - ua => $self->req->headers->user_agent - ); -}; - - -# use the templates defined in the config -push @{app->renderer->paths}, 'templates/' . $config->{'interface.template'}; - - -app->update_session_keys; -# Set log level -app->log->level($config->{'daemon.log_level'}); -# Remove timestamp, journald handles it -app->log->format(sub { - my ($time, $level, @lines) = @_; - return "[$level] " . join("\n", @lines) . "\n"; -}); -app->sessions->secure(1); -app->sessions->cookie_name('vroom'); -app->hook(before_dispatch => sub { - my $self = shift; - # Switch to the desired language - if ($self->session('language') && $self->session('language') ne $self->languages){ - $self->languages($self->session('language')); - } - # Stash the configuration hashref - $self->stash(config => $config); - - # Check db is available - # But don't error when user requests static assets - if ($error && @{$self->req->url->path->parts}[-1] !~ m/\.(css|js|png|woff2?|mp3|localize\/.*)$/){ - return $self->render('error', - msg => $self->l($error), - err => $error, - room => '' - ); - } -}); - -if (!app->db){ - $error = 'ERROR_DB_UNAVAILABLE'; -} -if (!app->check_db_version){ - $error = 'ERROR_DB_VERSION_MISMATCH'; -} - -# Are we running in hypnotoad ? -app->config( - hypnotoad => { - listen => ['http://' . $config->{'daemon.listen_ip'} . ':' . $config->{'daemon.listen_port'}], - pid_file => $config->{'daemon.pid_file'}, - proxy => 1 - } -); - -# Emptying peers in redis -app->redis->del('peers'); - -app->log->info('Starting VROOM daemon'); -# And start, lets VROOM !! -app->start; -