mirror of
https://github.com/dani/vroom.git
synced 2024-06-28 01:39:29 +02:00
![Daniel Berteaud](/assets/img/avatar_default.png)
Render a plain JS file and include it as a standard JS script instead of relying on an ajax call This makes it available immediatly (before that there could be a slight delay), but more importantly, makes sure the response can be cached Chrome wasn't caching XHR get responses (no matter what the header were)
2410 lines
70 KiB
Perl
Executable File
2410 lines
70 KiB
Perl
Executable File
#!/usr/bin/env perl
|
|
|
|
# This file is part of the VROOM project
|
|
# Released under the MIT licence
|
|
# Copyright 2014-2015 Daniel Berteaud <daniel@firewall-services.com>
|
|
|
|
use lib 'lib';
|
|
use Mojolicious::Lite;
|
|
use Mojolicious::Plugin::Mail;
|
|
use Mojolicious::Plugin::Database;
|
|
use Mojolicious::Plugin::StaticCompressor;
|
|
use Vroom::Constants;
|
|
use Vroom::Conf;
|
|
use Crypt::SaltedHash;
|
|
use Digest::HMAC_SHA1 qw(hmac_sha1);
|
|
use MIME::Base64;
|
|
use File::stat;
|
|
use File::Basename;
|
|
use Session::Token;
|
|
use Email::Valid;
|
|
use Protocol::SocketIO::Handshake;
|
|
use Protocol::SocketIO::Message;
|
|
use File::Path qw(make_path);
|
|
use File::Basename;
|
|
use DateTime;
|
|
use Array::Diff;
|
|
use Data::Dumper;
|
|
|
|
app->log->level('info');
|
|
|
|
our $config = Vroom::Conf::get_conf();
|
|
|
|
# Try to create the cache dir if they doesn't exist
|
|
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::API };
|
|
if ($etherpad){
|
|
import Etherpad::API;
|
|
$optf->{etherpad} = Etherpad::API->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::API 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;
|
|
|
|
# Global peers hash
|
|
our $peers = {};
|
|
|
|
# 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) = @_;
|
|
my $ret = {};
|
|
# A few names are reserved
|
|
my @reserved = qw(about help feedback feedback_thanks goodbye admin localize 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) = @_;
|
|
if (!$id || $id !~ m/^\d+$/){
|
|
return 0;
|
|
}
|
|
return 1;
|
|
};
|
|
|
|
# Check email address format
|
|
helper valid_email => sub {
|
|
my $self = shift;
|
|
my ($email) = @_;
|
|
return Email::Valid->address($email);
|
|
};
|
|
|
|
# Validate a date in YYYY-MM-DD format
|
|
# Also accept YYYY-MM-DD hh:mm:ss
|
|
helper valid_date => sub {
|
|
my $self = shift;
|
|
my ($date) = @_;
|
|
if ($date =~ m/^\d{4}\-\d{1,2}\-\d{1,2}(\s+\d{1,2}:\d{1,2}:\d{1,2})?$/){
|
|
return 1;
|
|
}
|
|
$self->app->log->debug("$date is not a valid date");
|
|
return 0;
|
|
};
|
|
|
|
##########################
|
|
# 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';
|
|
};
|
|
|
|
# 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) = @_;
|
|
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 a list of event between 2 dates
|
|
helper get_event_list => sub {
|
|
my $self = shift;
|
|
my ($start,$end) = @_;
|
|
# 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`<=?');
|
|
};
|
|
if ($@){
|
|
$self->app->log->debug("DB error: $@");
|
|
return 0;
|
|
}
|
|
# 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
|
|
$sth->execute($start,$end . ' 23:59:59');
|
|
if ($sth->err){
|
|
$self->app->log->debug("DB error: " . $sth->errstr . " (code " . $sth->err . ")");
|
|
return 0;
|
|
}
|
|
# 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) = @_;
|
|
# Logout from etherpad
|
|
if ($optf->{etherpad} && $self->session($room) && $self->session($room)->{etherpadSessionId}){
|
|
$optf->{etherpad}->delete_session($self->session($room)->{etherpadSessionId});
|
|
}
|
|
if ($self->session('peer_id') &&
|
|
$peers->{$self->session('peer_id')} &&
|
|
$peers->{$self->session('peer_id')}->{socket}){
|
|
$peers->{$self->session('peer_id')}->{socket}->finish;
|
|
}
|
|
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 two args: the name of the room and the session name of the creator
|
|
helper create_room => sub {
|
|
my $self = shift;
|
|
my ($name) = @_;
|
|
# 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;
|
|
}
|
|
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"
|
|
});
|
|
# Etherpad integration ? If so, create the corresponding pad
|
|
if ($optf->{etherpad}){
|
|
$self->create_pad($name);
|
|
}
|
|
return 1;
|
|
};
|
|
|
|
# Take 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) = @_;
|
|
my $res = $self->valid_room_name($name);
|
|
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) = @_;
|
|
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) = @_;
|
|
if (!$self->valid_id($room->{id})){
|
|
return 0;
|
|
}
|
|
if (!$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;
|
|
}
|
|
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 = '';
|
|
foreach my $field (keys %$room){
|
|
if (($old_room->{$field} // '' ) ne ($room->{$field} // '')){
|
|
if ($field =~ m/_password$/){
|
|
$old_room->{$field} = ($old_room->{$field}) ? '<hidden>' : '<unset>';
|
|
$room->{$field} = ($room->{$field}) ? '<hidden>' : '<unset>';
|
|
}
|
|
$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) = @_;
|
|
# Check the peer exists and is already in the room
|
|
if (!$data->{peer_id} ||
|
|
!$peers->{$data->{peer_id}}){
|
|
return 0;
|
|
}
|
|
$peers->{$data->{peer_id}}->{role} = $data->{role};
|
|
$self->app->log->info("Peer " . $data->{peer_id} . " has now the " .
|
|
$data->{role} . " role");
|
|
$self->log_event({
|
|
event => 'peer_role',
|
|
msg => "Peer " . $data->{peer_id} . " has now the " .
|
|
$data->{role} . " role in room " . $peers->{$data->{peer_id}}->{room}
|
|
});
|
|
return 1;
|
|
};
|
|
|
|
# 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) = @_;
|
|
return $peers->{$peer_id}->{role};
|
|
};
|
|
|
|
# Promote a peer to owner
|
|
helper promote_peer => sub {
|
|
my $self = shift;
|
|
my ($peer_id) = @_;
|
|
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) = @_;
|
|
$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) = @_;
|
|
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) = @_;
|
|
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,$email) = @_;
|
|
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,$emails) = @_;
|
|
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){
|
|
# Skip empty inputs
|
|
if ($email eq ''){
|
|
next;
|
|
}
|
|
$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) = @_;
|
|
$room = $self->get_room_by_name($room) || return undef;
|
|
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 = (<public/snd/moh/*.*>);
|
|
return basename($files[rand @files]);
|
|
};
|
|
|
|
# Add a invitation
|
|
helper add_invitation => sub {
|
|
my $self = shift;
|
|
my ($room,$email) = @_;
|
|
my $data = $self->get_room_by_name($room);
|
|
if (!$data){
|
|
return 0;
|
|
}
|
|
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) = @_;
|
|
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) = @_;
|
|
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,$response,$message) = @_;
|
|
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) = @_;
|
|
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 ID $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,$token) = @_;
|
|
# Expire invitations before checking if it's valid
|
|
$self->purge_invitations;
|
|
my $ret = 0;
|
|
my $data = $self->get_room_by_name($room);
|
|
if (!$data || !$token){
|
|
return 0;
|
|
}
|
|
$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) = @_;
|
|
my $data = $self->get_room_by_name($room);
|
|
if (!$optf->{etherpad} || !$data){
|
|
return 0;
|
|
}
|
|
# 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) = @_;
|
|
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 id
|
|
helper get_key_by_token => sub {
|
|
my $self = shift;
|
|
my ($token) = @_;
|
|
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) = @_;
|
|
my $data = \%data;
|
|
my $room = $self->get_room_by_name($data->{room});
|
|
my $key = $self->get_key_by_token($data->{key});
|
|
if (!$room || !$key){
|
|
return 0;
|
|
}
|
|
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) = @_;
|
|
my $key = $self->get_key_by_token($token);
|
|
if (!$key){
|
|
return 0;
|
|
}
|
|
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,$room) = @_;
|
|
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) = @_;
|
|
my $data = \%data;
|
|
my $actions = API_ACTIONS;
|
|
if (!$data->{action}){
|
|
return 0;
|
|
}
|
|
# 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 as simple partitipant 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;
|
|
if (!$self->get_room_by_name($room)){
|
|
return 0;
|
|
}
|
|
my @p;
|
|
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
|
|
# ecept the sender himself
|
|
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};
|
|
$peers->{$peer}->{socket}->send($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);
|
|
}
|
|
else {
|
|
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,$to) = @_;
|
|
my $tmp = File::Temp->new( DIR => $config->{'directories.cache'}, SUFFIX => '.xlsx' )->filename;
|
|
my $events = $self->get_event_list($from, $to);
|
|
if (!$events){
|
|
return 0;
|
|
}
|
|
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);
|
|
my $cr = scalar(split("\n", $events->{$e}->{message}));
|
|
if ($cr > 1){
|
|
$sheet->set_row($row, $cr*12);
|
|
}
|
|
$row++;
|
|
}
|
|
return $tmp;
|
|
};
|
|
|
|
# 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');
|
|
# 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->send('Bad session id');
|
|
}
|
|
|
|
my $key = $self->session('key');
|
|
|
|
# We create the peer in the global hash
|
|
$peers->{$id}->{socket} = $self->tx;
|
|
# And set the initial "last seen" flag
|
|
$peers->{$id}->{last} = time;
|
|
# Associate the unique ID and name
|
|
$peers->{$id}->{id} = $self->session('id');
|
|
$peers->{$id}->{check_invitations} = 1;
|
|
# Register the i18n stash, for localization will be available in the main IOLoop
|
|
# Outside of Mojo controller
|
|
$peers->{$id}->{i18n} = $self->stash->{i18n};
|
|
|
|
# When we recive a message, lets parse it as e Socket.IO one
|
|
$self->on('message' => sub {
|
|
my $self = shift;
|
|
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);
|
|
$peers->{$id}->{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;
|
|
}
|
|
# We build a list of peers, except this new one so we can send it
|
|
# to the new peer, and he'll be able to contact all those peers
|
|
my $others = {};
|
|
foreach my $peer (keys %$peers){
|
|
next if ($peer eq $id);
|
|
$others->{$peer} = $peers->{$peer}->{details};
|
|
}
|
|
$peers->{$id}->{details} = {
|
|
screen => \0,
|
|
video => \1,
|
|
audio => \0
|
|
};
|
|
$peers->{$id}->{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} &&
|
|
$peers->{$to}->{socket}){
|
|
$peers->{$to}->{socket}->send(Protocol::SocketIO::Message->new(%$msg));
|
|
}
|
|
# No dest, multicast this to every members of the room
|
|
else{
|
|
$self->signal_broadcast_room({
|
|
from => $id,
|
|
msg => Protocol::SocketIO::Message->new(%$msg)
|
|
});
|
|
}
|
|
}
|
|
# When a peer shares its screen
|
|
elsif ($msg->{data}->{name} eq 'shareScreen'){
|
|
$peers->{$id}->{details}->{screen} = \1;
|
|
}
|
|
# Or unshares it
|
|
elsif ($msg->{data}->{name} eq 'unshareScreen'){
|
|
$peers->{$id}->{details}->{screen} = \0;
|
|
$self->signal_broadcast_room({
|
|
from => $id,
|
|
msg => Protocol::SocketIO::Message->new(
|
|
type => 'event',
|
|
data => {
|
|
name => 'remove',
|
|
args => [{ id => $id, type => 'screen' }]
|
|
}
|
|
)
|
|
});
|
|
}
|
|
elsif ($msg->{data}->{name} =~ m/^leave|disconnect$/){
|
|
$peers->{$id}->{socket}->{finish};
|
|
}
|
|
else{
|
|
$self->app->log->debug("Unhandled SocketIO message\n" . Dumper $msg);
|
|
}
|
|
}
|
|
# Heartbeat reply, update timestamp
|
|
elsif ($msg->type eq 'heartbeat'){
|
|
$peers->{$id}->{last} = time;
|
|
# Update room last activity ~ every 40 heartbeat, so about every 2 minutes
|
|
if ((int (rand 200)) <= 5){
|
|
$self->update_room_last_activity($peers->{$id}->{room});
|
|
}
|
|
}
|
|
});
|
|
|
|
# Triggerred when a websocket connection ends
|
|
$self->on(finish => sub {
|
|
my ($self, $code, $reason) = @_;
|
|
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 => Protocol::SocketIO::Message->new(
|
|
type => 'event',
|
|
data => {
|
|
name => 'remove',
|
|
args => [{ id => $id, type => 'video' }]
|
|
}
|
|
)
|
|
});
|
|
$self->update_room_last_activity($peers->{$id}->{room});
|
|
delete $peers->{$id};
|
|
});
|
|
|
|
# This is just the end of the initial handshake, we indicate the client we're ready
|
|
$self->send(Protocol::SocketIO::Message->new( type => 'connect' ));
|
|
};
|
|
|
|
# Send heartbeats to all websocket clients
|
|
# Every 3 seconds
|
|
Mojo::IOLoop->recurring( 3 => sub {
|
|
foreach my $id (keys %{$peers}){
|
|
# This shouldn't happen, but better to log an error and fix it rather
|
|
# than looping indefinitly on a bogus entry if something went wron
|
|
if (!$peers->{$id}->{socket}){
|
|
app->log->debug("Garbage found in peers (peer $id has no socket)\n");
|
|
delete $peers->{$id};
|
|
}
|
|
# If we had no reply from this peer in the last 15 sec
|
|
# (5 heartbeat without response), we consider it dead and remove it
|
|
elsif ($peers->{$id}->{last} < time - 15){
|
|
app->log->debug("Peer $id didn't reply in 15 sec, disconnecting");
|
|
$peers->{$id}->{socket}->finish;
|
|
delete $peers->{$id};
|
|
}
|
|
elsif ($peers->{$id}->{check_invitations}) {
|
|
my $invitations = app->get_invitation_list($peers->{$id}->{id});
|
|
foreach my $invit (keys %{$invitations}){
|
|
my $msg = '';
|
|
$msg .= sprintf($peers->{$id}->{i18n}->localize('INVITE_REPONSE_FROM_s'), $invitations->{$invit}->{email}) . "\n" ;
|
|
if ($invitations->{$invit}->{response} && $invitations->{$invit}->{response} eq 'later'){
|
|
$msg .= $peers->{$id}->{i18n}->localize('HE_WILL_TRY_TO_JOIN_LATER');
|
|
}
|
|
else{
|
|
$msg .= $peers->{$id}->{i18n}->localize('HE_WONT_JOIN');
|
|
}
|
|
if ($invitations->{$invit}->{message} && $invitations->{$invit}->{message} ne ''){
|
|
$msg .= "\n" . $peers->{$id}->{i18n}->localize('MESSAGE') . ":\n" . $invitations->{$invit}->{message} . "\n";
|
|
}
|
|
app->mark_invitation_processed($invitations->{$invit}->{token});
|
|
$peers->{$id}->{socket}->send(
|
|
Protocol::SocketIO::Message->new(
|
|
type => 'event',
|
|
data => {
|
|
name => 'notification',
|
|
args => [{
|
|
payload => {msg => $msg, class => 'info'}
|
|
}]
|
|
}
|
|
)
|
|
);
|
|
delete $peers->{$id}->{check_invitations};
|
|
}
|
|
# Send the heartbeat
|
|
$peers->{$id}->{socket}->send(Protocol::SocketIO::Message->new( type => 'heartbeat' ))
|
|
}
|
|
}
|
|
});
|
|
|
|
# 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';
|
|
|
|
# Routes for feedback. One get to display the form
|
|
# and one post to get data from it
|
|
get '/feedback' => sub {
|
|
my $self = shift;
|
|
$self->stash(
|
|
page => 'feedback'
|
|
);
|
|
} => 'feedback';
|
|
|
|
post '/feedback' => sub {
|
|
my $self = shift;
|
|
my $email = $self->param('email') || '';
|
|
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},
|
|
);
|
|
}
|
|
elsif ($self->req->method eq 'POST'){
|
|
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');
|
|
}
|
|
return $self->render('invitation_thanks');
|
|
}
|
|
};
|
|
|
|
# Create a json script which contains localization
|
|
get '/locales/(:lang).js' => { lang => 'en' } => sub {
|
|
my $self = shift;
|
|
my $usr_lang = $self->languages;
|
|
my $req_lang = $self->stash('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 = {};
|
|
my $fallback_strings = {};
|
|
foreach my $string (keys %Vroom::I18N::fr::Lexicon){
|
|
next if $string eq '';
|
|
$strings->{$string} = $self->l($string);
|
|
}
|
|
# If lang is not en, send also en as a fallback locale
|
|
# useful if a locale is not complete
|
|
if ($req_lang ne 'en'){
|
|
$self->languages('en');
|
|
foreach my $string (keys %Vroom::I18N::fr::Lexicon){
|
|
next if $string eq '';
|
|
$fallback_strings->{$string} = $self->l($string);
|
|
}
|
|
}
|
|
# Set the user locale back
|
|
$self->languages($usr_lang);
|
|
my $res = 'locale = ' . Mojo::JSON::to_json($strings) . ';';
|
|
$res .= 'fallback_locale = ' . Mojo::JSON::to_json($fallback_strings) . ';';
|
|
# And send the response
|
|
$self->render(text => $res, 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;
|
|
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 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'
|
|
);
|
|
}
|
|
|
|
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};
|
|
if ($start eq ''){
|
|
$start = DateTime->now->ymd;
|
|
}
|
|
if ($end eq ''){
|
|
$end = DateTime->now->ymd;
|
|
}
|
|
# 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)){
|
|
if (!$events->{$event}->{$_}){
|
|
$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} = '';
|
|
$self->associate_key_to_room(
|
|
room => $req->{param}->{room},
|
|
key => $token,
|
|
role => 'owner'
|
|
);
|
|
return $self->render(json => $json);
|
|
}
|
|
|
|
if (!$req->{param}->{room}){
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->l('ERROR_ROOM_NAME_MISSING'),
|
|
err => 'ERROR_ROOM_NAME_MISSING'
|
|
},
|
|
status => '400'
|
|
);
|
|
}
|
|
|
|
$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'
|
|
);
|
|
}
|
|
|
|
# Ok, now, we don't have to bother with authorization anymore
|
|
if ($req->{action} eq 'authenticate'){
|
|
my $pass = $req->{param}->{password};
|
|
my $role = $self->get_key_role($token, $room->{name});
|
|
my $reason;
|
|
my $code = 401;
|
|
if ($room->{owner_password} && Crypt::SaltedHash->validate($room->{owner_password}, $pass)){
|
|
$role = 'owner';
|
|
}
|
|
elsif (!$role && $room->{join_password} && Crypt::SaltedHash->validate($room->{join_password}, $pass)){
|
|
$role = 'participant';
|
|
}
|
|
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,
|
|
}
|
|
);
|
|
}
|
|
elsif ($room->{locked} && $room->{owner_password}){
|
|
$reason = $self->l('ROOM_LOCKED_ENTER_OWNER_PASSWORD');
|
|
}
|
|
elsif ($room->{locked}){
|
|
$reason = sprintf($self->l('ERROR_ROOM_s_LOCKED'), $room->{name});
|
|
$code = 403;
|
|
}
|
|
elsif ((!$pass || $pass eq '') && $room->{join_password}){
|
|
$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};
|
|
foreach my $addr (@$rcpts){
|
|
if (!$self->valid_email($addr) && $addr ne ''){
|
|
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);
|
|
}
|
|
$peers->{$self->session('peer_id')}->{check_invitations} = 1;
|
|
return $self->render(
|
|
json => {
|
|
msg => sprintf($self->l('INVITE_SENT_TO_s'), join("\n", @$rcpts)),
|
|
}
|
|
);
|
|
}
|
|
# Handle room lock/unlock
|
|
elsif ($req->{action} =~ m/(un)?lock_room/){
|
|
$room->{locked} = ($req->{action} eq 'lock_room') ? '1':'0';
|
|
if ($self->modify_room($room)){
|
|
my $m = ($req->{action} eq 'lock_room') ? 'ROOM_LOCKED' : 'ROOM_UNLOCKED';
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->l($m),
|
|
err => $m
|
|
}
|
|
);
|
|
}
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->l('ERROR_OCCURRED'),
|
|
err => 'ERROR_OCCURRED',
|
|
},
|
|
status => 503
|
|
);
|
|
}
|
|
# Update room configuration
|
|
elsif ($req->{action} eq 'update_room_conf'){
|
|
$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
|
|
);
|
|
}
|
|
# Handle password (join and owner)
|
|
elsif ($req->{action} eq 'set_join_password'){
|
|
$room->{join_password} = ($req->{param}->{password} && $req->{param}->{password} ne '') ?
|
|
Crypt::SaltedHash->new(algorithm => 'SHA-256')->add($req->{param}->{password})->generate : undef;
|
|
if ($self->modify_room($room)){
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->l(($req->{param}->{password}) ? 'PASSWORD_PROTECT_SET' : 'PASSWORD_PROTECT_UNSET'),
|
|
}
|
|
);
|
|
}
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->('ERROR_OCCURRED'),
|
|
err => 'ERROR_OCCURRED',
|
|
},
|
|
status => 503
|
|
);
|
|
}
|
|
elsif ($req->{action} eq 'set_owner_password'){
|
|
if (grep { $req->{param}->{room} eq $_ } (split /[,;]/, $config->{'rooms.common_names'})){
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->l('ERROR_COMMON_ROOM_NAME'),
|
|
err => 'ERROR_COMMON_ROOM_NAME'
|
|
},
|
|
status => 406
|
|
);
|
|
}
|
|
$room->{owner_password} = ($req->{param}->{password} && $req->{param}->{password} ne '') ?
|
|
Crypt::SaltedHash->new(algorithm => 'SHA-256')->add($req->{param}->{password})->generate : undef;
|
|
if ($self->modify_room($room)){
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->l(($req->{param}->{password}) ? 'ROOM_NOW_RESERVED' : 'ROOM_NO_MORE_RESERVED'),
|
|
}
|
|
);
|
|
}
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->('ERROR_OCCURRED'),
|
|
err => 'ERROR_OCCURRED',
|
|
},
|
|
status => 503
|
|
);
|
|
}
|
|
elsif ($req->{action} eq 'set_persistent'){
|
|
my $set = $self->param('set');
|
|
$room->{persistent} = ($set eq 'on') ? 1 : 0;
|
|
if ($self->modify_room($room)){
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->l(($set eq 'on') ? 'ROOM_NOW_PERSISTENT' : 'ROOM_NO_MORE_PERSISTENT')
|
|
}
|
|
);
|
|
}
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->l('ERROR_OCCURRED'),
|
|
err => 'ERROR_OCCURRED',
|
|
},
|
|
status => 503
|
|
);
|
|
}
|
|
# Set/unset askForName
|
|
elsif ($req->{action} eq 'set_ask_for_name'){
|
|
my $set = $req->{param}->{set};
|
|
$room->{ask_for_name} = ($set eq 'on') ? 1 : 0;
|
|
if ($self->modify_room($room)){
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->l(($set eq 'on') ? 'FORCE_DISPLAY_NAME' : 'NAME_WONT_BE_ASKED')
|
|
}
|
|
);
|
|
}
|
|
return $self->render(
|
|
json => {
|
|
msg => $self->l('ERROR_OCCURRED'),
|
|
err => 'ERROR_OCCURRED',
|
|
},
|
|
status => 503
|
|
);
|
|
}
|
|
# Return configuration for SimpleWebRTC
|
|
elsif ($req->{action} eq 'get_rtc_conf'){
|
|
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
|
|
}
|
|
};
|
|
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 just 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->app->log->debug('Sending an email to ' . $recipients->{$rcpt}->{email});
|
|
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',
|
|
},
|
|
styaus => 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,
|
|
);
|
|
}
|
|
# 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->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
|
|
}
|
|
);
|
|
|
|
app->log->info('Starting VROOM daemon');
|
|
# And start, lets VROOM !!
|
|
app->start;
|
|
|