#!/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 Encode qw(encode_utf8); 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]); # This callback will send the message to the peer through # websocket when received on the personnal channel $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 elsif (!$to){ $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->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->login; $self->purge_api_keys; my $token = $self->req->headers->header('X-VROOM-API-Key') || $self->session('key'); my $req = Mojo::JSON::decode_json(encode_utf8($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} }; # 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}) ? '1' : '0'; } foreach my $pass (qw/join_password owner_password/){ if ($req->{param}->{$pass} eq "\0"){ $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->url_for('/' . 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;