diff --git a/script/start_server.pl b/script/start_server.pl index 3bbff4b..8bd6f17 100755 --- a/script/start_server.pl +++ b/script/start_server.pl @@ -19,8 +19,8 @@ if ($config->{'daemon.backend'} eq 'morbo'){ '-w', 'conf/settings.ini', '-w', 'lib', '-w', 'templates', - '-v', 'vroom.pl'); + '-v', 'vroom'); } else{ - exec ('/usr/bin/hypnotoad', '-f', 'vroom.pl'); + exec ('/usr/bin/hypnotoad', '-f', 'vroom'); } diff --git a/vroom b/vroom new file mode 100755 index 0000000..2e851cf --- /dev/null +++ b/vroom @@ -0,0 +1,2408 @@ +#!/usr/bin/env perl + +# This file is part of the VROOM project +# Released under the MIT licence +# Copyright 2014-2015 Daniel Berteaud +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; +use lib dirname($0) . '/lib'; + + +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; +