diff --git a/France_in_XXI_Century._School.jpg b/France_in_XXI_Century._School.jpg new file mode 100644 index 0000000..fa6a037 Binary files /dev/null and b/France_in_XXI_Century._School.jpg differ diff --git a/README.md b/README.md index 4188f7f..287320d 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ If you don't have YunoHost, please consult [the guide](https://yunohost.org/#/in ## Overview Galène is a videoconferencing server that is easy to deploy (just copy a few files and run the binary) and that requires moderate server resources. It was originally designed for lectures and conferences (where a single speaker streams audio and video to hundreds or thousands of users), but later evolved to be useful for student practicals (where users are divided into many small groups), and meetings (where a few dozen users interact with each other). -**Shipped version:** 0.1 +**Shipped version:** 0.2 ## Screenshots -![](Link to a screenshot of this app.) +![](France_in_XXI_Century._School.jpg) ## Demo @@ -33,8 +33,8 @@ Galène is a videoconferencing server that is easy to deploy (just copy a few fi #### Multi-user support - * Are LDAP and HTTP auth supported? - * Can the app be used by multiple users? + * Are LDAP and HTTP auth supported? **No** + * Can the app be used by multiple users? **Yes** #### Supported architectures @@ -49,9 +49,6 @@ Galène is a videoconferencing server that is easy to deploy (just copy a few fi * Other info you would like to add about this app. -**More info on the documentation page:** -https://yunohost.org/packaging_apps - ## Links * Report a bug: https://github.com/YunoHost-Apps/galene_ynh/issues diff --git a/README_fr.md b/README_fr.md index 43aeb45..22f3ab1 100644 --- a/README_fr.md +++ b/README_fr.md @@ -11,11 +11,11 @@ Si vous n'avez pas YunoHost, consultez [le guide](https://yunohost.org/#/install ## Vue d'ensemble Galène est un serveur de visioconférence facile à déployer (il suffit de copier quelques fichiers et d'exécuter le binaire) et qui nécessite des ressources serveur modérées. Il a été conçu à l'origine pour les conférences (où un seul orateur diffuse l'audio et la vidéo à des centaines ou des milliers d'utilisateurs), mais a ensuite évolué pour être utile pour les travaux pratiques des étudiants (où les utilisateurs sont divisés en plusieurs petits groupes) et les réunions (où un quelques dizaines d'utilisateurs interagissent les uns avec les autres). -**Version incluse :** 0.1 +**Version incluse :** 0.2 ## Captures d'écran -![](Link to a screenshot of this app.) +![](France_in_XXI_Century._School.jpg) ## Démo @@ -23,7 +23,6 @@ Galène est un serveur de visioconférence facile à déployer (il suffit de cop ## Configuration - ## Documentation * Documentation officielle : https://galene.org/ @@ -33,8 +32,8 @@ Galène est un serveur de visioconférence facile à déployer (il suffit de cop #### Support multi-utilisateur -* L'authentification LDAP est-elle prise en charge ? -* L'application peut-elle être utilisée par plusieurs utilisateurs ? +* L'authentification LDAP est-elle prise en charge ? **Non** +* L'application peut-elle être utilisée par plusieurs utilisateurs ? **Oui** #### Supported architectures diff --git a/check_process b/check_process index a2ca1a6..f2ce840 100644 --- a/check_process +++ b/check_process @@ -10,7 +10,7 @@ admin="john" (USER) is_public=1 (PUBLIC|public=1|private=0) password="pass" - group_name="groupname" + group_name="public" ; Checks pkg_linter=1 setup_sub_dir=0 @@ -21,7 +21,7 @@ upgrade=1 backup_restore=1 multi_instance=0 - port_already_use=0 + port_already_use=1 change_url=1 ;;; Options Email= diff --git a/conf/app.src b/conf/app.src deleted file mode 100644 index b14d80f..0000000 --- a/conf/app.src +++ /dev/null @@ -1,7 +0,0 @@ -SOURCE_URL=https://github.com/jech/galene/archive/galene-0.1.zip -SOURCE_SUM=738a6ba9649185112a53c22ba2d7afa7f5ac00bb35ee8b71192194d123c861c2 -SOURCE_SUM_PRG=sha256sum -SOURCE_FORMAT=zip -SOURCE_IN_SUBDIR=true -SOURCE_FILENAME= -SOURCE_EXTRACT=true diff --git a/conf/arm.src b/conf/arm.src new file mode 100644 index 0000000..6f6b58f --- /dev/null +++ b/conf/arm.src @@ -0,0 +1,7 @@ +SOURCE_URL=https://github.com/YunoHost-Apps/galene_ynh/releases/download/v0.2/galene_0.2_Linux_arm.tar.gz +SOURCE_SUM=a7da5ff9a34422732fea1bbe9fb591c42813875ff7fcd4c30590a54c786bdf19 +SOURCE_SUM_PRG=sha256sum +SOURCE_FORMAT=tar.gz +SOURCE_IN_SUBDIR=true +SOURCE_FILENAME= +SOURCE_EXTRACT=true diff --git a/conf/arm64.src b/conf/arm64.src new file mode 100644 index 0000000..0ede6a8 --- /dev/null +++ b/conf/arm64.src @@ -0,0 +1,7 @@ +SOURCE_URL=https://github.com/YunoHost-Apps/galene_ynh/releases/download/v0.2/galene_0.2_Linux_arm64.tar.gz +SOURCE_SUM=8e755dc9779c5301d9f63e8120e2bd307118fd2ebc1bdc003e2c2c0ce905f9c7 +SOURCE_SUM_PRG=sha256sum +SOURCE_FORMAT=tar.gz +SOURCE_IN_SUBDIR=true +SOURCE_FILENAME= +SOURCE_EXTRACT=true diff --git a/conf/systemd.service b/conf/systemd.service index 88914a9..ddc546d 100644 --- a/conf/systemd.service +++ b/conf/systemd.service @@ -1,5 +1,5 @@ [Unit] -Description=Galene +Description=Galène: videoconferencing server After=network.target [Service] @@ -11,4 +11,4 @@ ExecStart=__FINALPATH__/galene LimitNOFILE=65536 [Install] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target diff --git a/conf/x86-64.src b/conf/x86-64.src new file mode 100644 index 0000000..c236676 --- /dev/null +++ b/conf/x86-64.src @@ -0,0 +1,7 @@ +SOURCE_URL=https://github.com/YunoHost-Apps/galene_ynh/releases/download/v0.2/galene_0.2_Linux_x86_64.tar.gz +SOURCE_SUM=4878741a204a35e900cf75399093f121a56f9e32b6a08a60fff254d561c18444 +SOURCE_SUM_PRG=sha256sum +SOURCE_FORMAT=tar.gz +SOURCE_IN_SUBDIR=true +SOURCE_FILENAME= +SOURCE_EXTRACT=true diff --git a/manifest.json b/manifest.json index c0cfbf2..6d16f69 100644 --- a/manifest.json +++ b/manifest.json @@ -6,7 +6,7 @@ "en": "Videoconferencing server that is easy to deploy", "fr": "Serveur de visioconférence facile à déployer" }, - "version": "0.1~ynh1", + "version": "0.2~ynh1", "url": "https://galene.org/", "license": "MIT", "maintainer": { diff --git a/scripts/_common.sh b/scripts/_common.sh index ecd263f..24e8305 100755 --- a/scripts/_common.sh +++ b/scripts/_common.sh @@ -18,3 +18,29 @@ pkg_dependencies="" #================================================= # FUTURE OFFICIAL HELPERS #================================================= + +# Check the architecture +# +# example: architecture=$(ynh_detect_arch) +# +# usage: ynh_detect_arch +# +# Requires YunoHost version 2.2.4 or higher. + +ynh_detect_arch(){ + local architecture + if [ -n "$(uname -m | grep arm64)" ] || [ -n "$(uname -m | grep aarch64)" ]; then + architecture="arm64" + elif [ -n "$(uname -m | grep 64)" ]; then + architecture="x86-64" + elif [ -n "$(uname -m | grep armv7)" ]; then + architecture="arm" + elif [ -n "$(uname -m | grep armv6)" ]; then + architecture="arm" + elif [ -n "$(uname -m | grep armv5)" ]; then + architecture="arm" + else + architecture="unknown" + fi + echo $architecture +} \ No newline at end of file diff --git a/scripts/backup b/scripts/backup index 95950f9..94a5224 100755 --- a/scripts/backup +++ b/scripts/backup @@ -6,7 +6,6 @@ # IMPORT GENERIC HELPERS #================================================= -# Keep this path for calling _common.sh inside the execution's context of backup and restore scripts source ../settings/scripts/_common.sh source /usr/share/yunohost/helpers @@ -15,8 +14,7 @@ source /usr/share/yunohost/helpers #================================================= ynh_clean_setup () { - ### Remove this function if there's nothing to clean before calling the remove script. - true + ynh_clean_check_starting } # Exit if an error occurs during the execution of the script ynh_abort_if_errors diff --git a/scripts/change_url b/scripts/change_url index 2733c0d..10d80ee 100755 --- a/scripts/change_url +++ b/scripts/change_url @@ -24,7 +24,7 @@ app=$YNH_APP_INSTANCE_NAME #================================================= # LOAD SETTINGS #================================================= -ynh_script_progression --message="Loading installation settings..." --time --weight=1 +ynh_script_progression --message="Loading installation settings..." --weight=1 # Needed for helper "ynh_add_nginx_config" final_path=$(ynh_app_setting_get --app=$app --key=final_path) @@ -33,7 +33,7 @@ port=$(ynh_app_setting_get --app=$app --key=port) #================================================= # BACKUP BEFORE UPGRADE THEN ACTIVE TRAP #================================================= -ynh_script_progression --message="Backing up the app before changing its URL (may take a while)..." --time --weight=1 +ynh_script_progression --message="Backing up the app before changing its URL (may take a while)..." --weight=1 # Backup the current version of the app ynh_backup_before_upgrade @@ -68,14 +68,14 @@ fi #================================================= # STOP SYSTEMD SERVICE #================================================= -ynh_script_progression --message="Stopping a systemd service..." --time --weight=1 +ynh_script_progression --message="Stopping a systemd service..." --weight=1 ynh_systemd_action --service_name=$app --action="stop" --log_path="/var/log/$app/$app.log" #================================================= # MODIFY URL IN NGINX CONF #================================================= -ynh_script_progression --message="Updating NGINX web server configuration..." --time --weight=1 +ynh_script_progression --message="Updating NGINX web server configuration..." --weight=2 nginx_conf_path=/etc/nginx/conf.d/$old_domain.d/$app.conf @@ -101,18 +101,12 @@ then ynh_store_file_checksum --file="/etc/nginx/conf.d/$new_domain.d/$app.conf" fi -#================================================= -# SPECIFIC MODIFICATIONS -#================================================= -# ... -#================================================= - #================================================= # GENERIC FINALISATION #================================================= # START SYSTEMD SERVICE #================================================= -ynh_script_progression --message="Starting a systemd service..." --time --weight=1 +ynh_script_progression --message="Starting a systemd service..." --time --weight=3 ynh_systemd_action --service_name=$app --action="start" --log_path="/var/log/$app/$app.log" @@ -127,4 +121,4 @@ ynh_systemd_action --service_name=nginx --action=reload # END OF SCRIPT #================================================= -ynh_script_progression --message="Change of URL completed for $app" --time --last +ynh_script_progression --message="Change of URL completed for $app" --last diff --git a/scripts/install b/scripts/install index 9db57da..763b6b6 100755 --- a/scripts/install +++ b/scripts/install @@ -14,8 +14,7 @@ source /usr/share/yunohost/helpers #================================================= ynh_clean_setup () { - ### Remove this function if there's nothing to clean before calling the remove script. - true + ynh_clean_check_starting } # Exit if an error occurs during the execution of the script ynh_abort_if_errors @@ -30,6 +29,7 @@ admin=$YNH_APP_ARG_ADMIN is_public=$YNH_APP_ARG_IS_PUBLIC password=$YNH_APP_ARG_PASSWORD group_name=$YNH_APP_ARG_GROUP_NAME +architecture=$(ynh_detect_arch) app=$YNH_APP_INSTANCE_NAME @@ -83,9 +83,7 @@ ynh_script_progression --message="Setting up source files..." --weight=1 ynh_app_setting_set --app=$app --key=final_path --value=$final_path # Download, check integrity, uncompress and patch the source from app.src -#ynh_setup_source --dest_dir="$final_path" -mkdir -p $final_path -cp -R ../sources/* $final_path/ +ynh_setup_source --dest_dir="$final_path" --source_id="$architecture" #================================================= # CREATE A SERVER CERTIFICATE @@ -130,7 +128,7 @@ cp ../conf/passwd $final_path/data/passwd ynh_replace_string --match_string="__ADMIN__" --replace_string="$admin" --target_file="$final_path/data/passwd" ynh_replace_string --match_string="__PASSWORD__" --replace_string="$password" --target_file="$final_path/data/passwd" -cp ../sources/groups/groupname.json $final_path/groups/$group_name.json +mv -f $final_path/groups/groupname.json $final_path/groups/$group_name.json ynh_replace_string --match_string="__ADMIN__" --replace_string="$admin" --target_file="$final_path/groups/$group_name.json" ynh_replace_string --match_string="__PASSWORD__" --replace_string="$password" --target_file="$final_path/groups/$group_name.json" @@ -179,7 +177,7 @@ ynh_systemd_action --service_name=$app --action="start" --log_path="/var/log/$ap #================================================= # SETUP SSOWAT #================================================= -ynh_script_progression --message="Configuring SSOwat..." --weight=2 +ynh_script_progression --message="Configuring permissions..." --weight=2 # Make app public if necessary if [ $is_public -eq 1 ] diff --git a/scripts/restore b/scripts/restore index f46aaba..43e1834 100755 --- a/scripts/restore +++ b/scripts/restore @@ -15,8 +15,7 @@ source /usr/share/yunohost/helpers #================================================= ynh_clean_setup () { - #### Remove this function if there's nothing to clean before calling the remove script. - true + ynh_clean_check_starting } # Exit if an error occurs during the execution of the script ynh_abort_if_errors diff --git a/scripts/upgrade b/scripts/upgrade index 48baf61..e9093c1 100755 --- a/scripts/upgrade +++ b/scripts/upgrade @@ -23,6 +23,7 @@ is_public=$(ynh_app_setting_get --app=$app --key=is_public) final_path=$(ynh_app_setting_get --app=$app --key=final_path) group_name=$(ynh_app_setting_get --app=$app --key=group_name) port=$(ynh_app_setting_get --app=$app --key=port) +architecture=$(ynh_detect_arch) #================================================= # CHECK VERSION @@ -81,8 +82,7 @@ then # Remove the app directory securely ynh_secure_remove --file="$final_path" - mkdir -p $final_path - cp -R ../sources/* $final_path/ + ynh_setup_source --dest_dir="$final_path" --source_id="$architecture" # Copy the admin saved settings from tmp directory to final path cp -ar "$tmpdir/groups" "$final_path/groups" @@ -94,7 +94,7 @@ then pushd "$final_path" ynh_exec_warn_less openssl req -newkey rsa:2048 -nodes -keyout data/key.pem -x509 -days 365 -out data/cert.pem \ -subj "/C=/ST=/L=/O=/OU=/CN=/emailAddress=" - chmod 640 data/{key.pem,cert.pem} + chmod 640 data/{key.pem,cert.pem} popd fi @@ -129,19 +129,6 @@ ynh_script_progression --message="Upgrading systemd configuration..." --weight=1 # Create a dedicated systemd config ynh_add_systemd_config -#================================================= -# MODIFY A CONFIG FILE -#================================================= - -# ### Verify the checksum of a file, stored by `ynh_store_file_checksum` in the install script. -# ### And create a backup of this file if the checksum is different. So the file will be backed up if the admin had modified it. -# ynh_backup_if_checksum_is_different --file="$final_path/CONFIG_FILE" - -# ynh_replace_string --match_string="match_string" --replace_string="replace_string" --target_file="$final_path/CONFIG_FILE" - -# # Recalculate and store the checksum of the file for the next upgrade. -# ynh_store_file_checksum --file="$final_path/CONFIG_FILE" - #================================================= # GENERIC FINALIZATION #================================================= diff --git a/sources/data/ice-servers.json b/sources/data/ice-servers.json deleted file mode 100755 index fe51488..0000000 --- a/sources/data/ice-servers.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/sources/galene b/sources/galene deleted file mode 100755 index 747b172..0000000 Binary files a/sources/galene and /dev/null differ diff --git a/sources/group/client.go b/sources/group/client.go deleted file mode 100755 index 0f17377..0000000 --- a/sources/group/client.go +++ /dev/null @@ -1,107 +0,0 @@ -package group - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "hash" - - "golang.org/x/crypto/pbkdf2" - - "github.com/jech/galene/conn" -) - -type RawPassword struct { - Type string `json:"type,omitempty"` - Hash string `json:"hash,omitempty"` - Key string `json:"key"` - Salt string `json:"salt,omitempty"` - Iterations int `json:"iterations,omitempty"` -} - -type Password RawPassword - -func (p Password) Match(pw string) (bool, error) { - switch p.Type { - case "": - return p.Key == pw, nil - case "pbkdf2": - key, err := hex.DecodeString(p.Key) - if err != nil { - return false, err - } - salt, err := hex.DecodeString(p.Salt) - if err != nil { - return false, err - } - var h func() hash.Hash - switch p.Hash { - case "sha-256": - h = sha256.New - default: - return false, errors.New("unknown hash type") - } - theirKey := pbkdf2.Key( - []byte(pw), salt, p.Iterations, len(key), h, - ) - return bytes.Compare(key, theirKey) == 0, nil - default: - return false, errors.New("unknown password type") - } -} - -func (p *Password) UnmarshalJSON(b []byte) error { - var k string - err := json.Unmarshal(b, &k) - if err == nil { - *p = Password{ - Key: k, - } - return nil - } - var r RawPassword - err = json.Unmarshal(b, &r) - if err == nil { - *p = Password(r) - } - return err -} - -func (p Password) MarshalJSON() ([]byte, error) { - if p.Type == "" && p.Hash == "" && p.Salt == "" && p.Iterations == 0 { - return json.Marshal(p.Key) - } - return json.Marshal(RawPassword(p)) -} - -type ClientCredentials struct { - Username string `json:"username,omitempty"` - Password *Password `json:"password,omitempty"` -} - -type ClientPermissions struct { - Op bool `json:"op,omitempty"` - Present bool `json:"present,omitempty"` - Record bool `json:"record,omitempty"` -} - -type Challengeable interface { - Username() string - Challenge(string, ClientCredentials) bool -} - -type Client interface { - Group() *Group - Id() string - Challengeable - SetPermissions(ClientPermissions) - OverridePermissions(*Group) bool - PushConn(g *Group, id string, conn conn.Up, tracks []conn.UpTrack, label string) error - PushClient(id, username string, add bool) error -} - -type Kickable interface { - Kick(id, user, message string) error -} diff --git a/sources/group/client_test.go b/sources/group/client_test.go deleted file mode 100755 index 9352fc6..0000000 --- a/sources/group/client_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package group - -import ( - "encoding/json" - "log" - "reflect" - "testing" -) - -var pw1 = Password{} -var pw2 = Password{Key: "pass"} -var pw3 = Password{ - Type: "pbkdf2", - Hash: "sha-256", - Key: "fe499504e8f144693fae828e8e371d50e019d0e4c84994fa03f7f445bd8a570a", - Salt: "bcc1717851030776", - Iterations: 4096, -} -var pw4 = Password{ - Type: "bad", -} - -func TestGood(t *testing.T) { - if match, err := pw2.Match("pass"); err != nil || !match { - t.Errorf("pw2 doesn't match (%v)", err) - } - if match, err := pw3.Match("pass"); err != nil || !match { - t.Errorf("pw3 doesn't match (%v)", err) - } -} - -func TestBad(t *testing.T) { - if match, err := pw1.Match("bad"); err != nil || match { - t.Errorf("pw1 matches") - } - if match, err := pw2.Match("bad"); err != nil || match { - t.Errorf("pw2 matches") - } - if match, err := pw3.Match("bad"); err != nil || match { - t.Errorf("pw3 matches") - } - if match, err := pw4.Match("bad"); err == nil || match { - t.Errorf("pw4 matches") - } -} - -func TestJSON(t *testing.T) { - plain, err := json.Marshal(pw2) - if err != nil || string(plain) != `"pass"` { - t.Errorf("Expected \"pass\", got %v", string(plain)) - } - - for _, pw := range []Password{pw1, pw2, pw3, pw4} { - j, err := json.Marshal(pw) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - if testing.Verbose() { - log.Printf("%v", string(j)) - } - var pw2 Password - err = json.Unmarshal(j, &pw2) - if err != nil { - t.Fatalf("Unmarshal: %v", err) - } else if !reflect.DeepEqual(pw, pw2) { - t.Errorf("Expected %v, got %v", pw, pw2) - } - } -} - -func BenchmarkPlain(b *testing.B) { - for i := 0; i < b.N; i++ { - match, err := pw2.Match("bad") - if err != nil || match { - b.Errorf("pw2 matched") - } - } -} - -func BenchmarkPBKDF2(b *testing.B) { - for i := 0; i < b.N; i++ { - match, err := pw3.Match("bad") - if err != nil || match { - b.Errorf("pw3 matched") - } - } -} diff --git a/sources/group/group.go b/sources/group/group.go deleted file mode 100755 index c52c1fb..0000000 --- a/sources/group/group.go +++ /dev/null @@ -1,756 +0,0 @@ -package group - -import ( - "encoding/json" - "errors" - "log" - "os" - "path" - "path/filepath" - "sort" - "strings" - "sync" - "time" - - "github.com/pion/ice/v2" - "github.com/pion/webrtc/v3" -) - -var Directory string -var UseMDNS bool - -var ErrNotAuthorised = errors.New("not authorised") - -type UserError string - -func (err UserError) Error() string { - return string(err) -} - -type KickError struct { - Id string - Username string - Message string -} - -func (err KickError) Error() string { - m := "kicked out" - if err.Message != "" { - m += "(" + err.Message + ")" - } - if err.Username != "" { - m += " by " + err.Username - } - return m -} - -type ProtocolError string - -func (err ProtocolError) Error() string { - return string(err) -} - -var IceFilename string - -var iceConf webrtc.Configuration -var iceOnce sync.Once - -func IceConfiguration() webrtc.Configuration { - iceOnce.Do(func() { - var iceServers []webrtc.ICEServer - file, err := os.Open(IceFilename) - if err != nil { - log.Printf("Open %v: %v", IceFilename, err) - return - } - defer file.Close() - d := json.NewDecoder(file) - err = d.Decode(&iceServers) - if err != nil { - log.Printf("Get ICE configuration: %v", err) - return - } - iceConf = webrtc.Configuration{ - ICEServers: iceServers, - } - }) - - return iceConf -} - -type ChatHistoryEntry struct { - Id string - User string - Time int64 - Kind string - Value string -} - -const ( - MinBitrate = 200000 -) - -type Group struct { - name string - - mu sync.Mutex - description *description - locked *string - clients map[string]Client - history []ChatHistoryEntry - timestamp time.Time -} - -func (g *Group) Name() string { - return g.name -} - -func (g *Group) Locked() (bool, string) { - g.mu.Lock() - defer g.mu.Unlock() - if g.locked != nil { - return true, *g.locked - } else { - return false, "" - } -} - -func (g *Group) SetLocked(locked bool, message string) { - g.mu.Lock() - defer g.mu.Unlock() - if locked { - g.locked = &message - } else { - g.locked = nil - } -} - -func (g *Group) Public() bool { - g.mu.Lock() - defer g.mu.Unlock() - return g.description.Public -} - -func (g *Group) Redirect() string { - g.mu.Lock() - defer g.mu.Unlock() - return g.description.Redirect -} - -func (g *Group) AllowRecording() bool { - g.mu.Lock() - defer g.mu.Unlock() - return g.description.AllowRecording -} - -var groups struct { - mu sync.Mutex - groups map[string]*Group - api *webrtc.API -} - -func (g *Group) API() *webrtc.API { - return groups.api -} - -func Add(name string, desc *description) (*Group, error) { - if name == "" || strings.HasSuffix(name, "/") { - return nil, UserError("illegal group name") - } - - groups.mu.Lock() - defer groups.mu.Unlock() - - if groups.groups == nil { - groups.groups = make(map[string]*Group) - s := webrtc.SettingEngine{} - s.SetSRTPReplayProtectionWindow(512) - if !UseMDNS { - s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled) - } - m := webrtc.MediaEngine{} - m.RegisterCodec( - webrtc.RTPCodecParameters{ - RTPCodecCapability: webrtc.RTPCodecCapability{ - "video/VP8", 90000, 0, - "", - []webrtc.RTCPFeedback{ - {"goog-remb", ""}, - {"nack", ""}, - {"nack", "pli"}, - {"ccm", "fir"}, - }, - }, - PayloadType: 96, - }, - webrtc.RTPCodecTypeVideo, - ) - m.RegisterCodec( - webrtc.RTPCodecParameters{ - RTPCodecCapability: webrtc.RTPCodecCapability{ - "audio/opus", 48000, 2, - "minptime=10;useinbandfec=1", - nil, - }, - PayloadType: 111, - }, - webrtc.RTPCodecTypeAudio, - ) - groups.api = webrtc.NewAPI( - webrtc.WithSettingEngine(s), - webrtc.WithMediaEngine(&m), - ) - } - - var err error - - g := groups.groups[name] - if g == nil { - if desc == nil { - desc, err = GetDescription(name) - if err != nil { - return nil, err - } - } - g = &Group{ - name: name, - description: desc, - clients: make(map[string]Client), - timestamp: time.Now(), - } - groups.groups[name] = g - return g, nil - } - - g.mu.Lock() - defer g.mu.Unlock() - - if desc != nil { - g.description = desc - return g, nil - } - - if time.Since(g.description.loadTime) > 5*time.Second { - if descriptionChanged(name, g.description) { - desc, err := GetDescription(name) - if err != nil { - if !os.IsNotExist(err) { - log.Printf("Reading group %v: %v", - name, err) - } - deleteUnlocked(g) - return nil, err - } - g.description = desc - } else { - g.description.loadTime = time.Now() - } - } - - return g, nil -} - -func Range(f func(g *Group) bool) { - groups.mu.Lock() - defer groups.mu.Unlock() - - for _, g := range groups.groups { - ok := f(g) - if !ok { - break - } - } -} - -func GetNames() []string { - names := make([]string, 0) - - Range(func(g *Group) bool { - names = append(names, g.name) - return true - }) - return names -} - -type SubGroup struct { - Name string - Clients int -} - -func GetSubGroups(parent string) []SubGroup { - prefix := parent + "/" - subgroups := make([]SubGroup, 0) - - Range(func(g *Group) bool { - if strings.HasPrefix(g.name, prefix) { - g.mu.Lock() - count := len(g.clients) - g.mu.Unlock() - if count > 0 { - subgroups = append(subgroups, - SubGroup{g.name, count}) - } - } - return true - }) - return subgroups -} - -func Get(name string) *Group { - groups.mu.Lock() - defer groups.mu.Unlock() - - return groups.groups[name] -} - -func Delete(name string) bool { - groups.mu.Lock() - defer groups.mu.Unlock() - g := groups.groups[name] - if g == nil { - return false - } - - g.mu.Lock() - defer g.mu.Unlock() - return deleteUnlocked(g) -} - -// Called with both groups.mu and g.mu taken. -func deleteUnlocked(g *Group) bool { - if len(g.clients) != 0 { - return false - } - - delete(groups.groups, g.name) - return true -} - -func Expire() { - names := GetNames() - now := time.Now() - - for _, name := range names { - g := Get(name) - if g == nil { - continue - } - - old := false - - g.mu.Lock() - empty := len(g.clients) == 0 - if empty && !g.description.Public { - age := now.Sub(g.timestamp) - old = age > maxHistoryAge(g.description) - } - // We cannot take groups.mu at this point without a deadlock. - g.mu.Unlock() - - if empty && old { - // Delete will check if the group is still empty - Delete(name) - } - } -} - -func AddClient(group string, c Client) (*Group, error) { - g, err := Add(group, nil) - if err != nil { - return nil, err - } - - g.mu.Lock() - defer g.mu.Unlock() - - if !c.OverridePermissions(g) { - perms, err := g.description.GetPermission(group, c) - if err != nil { - return nil, err - } - - c.SetPermissions(perms) - - if !perms.Op && g.locked != nil { - m := *g.locked - if m == "" { - m = "group is locked" - } - return nil, UserError(m) - } - - if !perms.Op && g.description.MaxClients > 0 { - if len(g.clients) >= g.description.MaxClients { - return nil, UserError("too many users") - } - } - } - - if g.clients[c.Id()] != nil { - return nil, ProtocolError("duplicate client id") - } - - g.clients[c.Id()] = c - g.timestamp = time.Now() - - go func(clients []Client) { - u := c.Username() - c.PushClient(c.Id(), u, true) - for _, cc := range clients { - uu := cc.Username() - c.PushClient(cc.Id(), uu, true) - cc.PushClient(c.Id(), u, true) - } - }(g.getClientsUnlocked(c)) - - return g, nil -} - -func DelClient(c Client) { - g := c.Group() - if g == nil { - return - } - g.mu.Lock() - defer g.mu.Unlock() - - if g.clients[c.Id()] != c { - log.Printf("Deleting unknown client") - return - } - delete(g.clients, c.Id()) - g.timestamp = time.Now() - - go func(clients []Client) { - for _, cc := range clients { - cc.PushClient(c.Id(), c.Username(), false) - } - }(g.getClientsUnlocked(nil)) -} - -func (g *Group) GetClients(except Client) []Client { - g.mu.Lock() - defer g.mu.Unlock() - return g.getClientsUnlocked(except) -} - -func (g *Group) getClientsUnlocked(except Client) []Client { - clients := make([]Client, 0, len(g.clients)) - for _, c := range g.clients { - if c != except { - clients = append(clients, c) - } - } - return clients -} - -func (g *Group) GetClient(id string) Client { - g.mu.Lock() - defer g.mu.Unlock() - return g.getClientUnlocked(id) -} - -func (g *Group) getClientUnlocked(id string) Client { - for idd, c := range g.clients { - if idd == id { - return c - } - } - return nil -} - -func (g *Group) Range(f func(c Client) bool) { - g.mu.Lock() - defer g.mu.Unlock() - for _, c := range g.clients { - ok := f(c) - if !ok { - break - } - } -} - -func (g *Group) Shutdown(message string) { - g.Range(func(c Client) bool { - cc, ok := c.(Kickable) - if ok { - cc.Kick("", "", message) - } - return true - }) -} - -func FromJSTime(tm int64) time.Time { - if tm == 0 { - return time.Time{} - } - return time.Unix(int64(tm)/1000, (int64(tm)%1000)*1000000) -} - -func ToJSTime(tm time.Time) int64 { - return int64((tm.Sub(time.Unix(0, 0)) + time.Millisecond/2) / - time.Millisecond) -} - -const maxChatHistory = 50 - -func (g *Group) ClearChatHistory() { - g.mu.Lock() - defer g.mu.Unlock() - g.history = nil -} - -func (g *Group) AddToChatHistory(id, user string, time int64, kind, value string) { - g.mu.Lock() - defer g.mu.Unlock() - - if len(g.history) >= maxChatHistory { - copy(g.history, g.history[1:]) - g.history = g.history[:len(g.history)-1] - } - g.history = append(g.history, - ChatHistoryEntry{Id: id, User: user, Time: time, Kind: kind, Value: value}, - ) -} - -func discardObsoleteHistory(h []ChatHistoryEntry, duration time.Duration) []ChatHistoryEntry { - i := 0 - for i < len(h) { - if time.Since(FromJSTime(h[i].Time)) <= duration { - break - } - i++ - } - if i > 0 { - copy(h, h[i:]) - h = h[:len(h)-i] - } - return h -} - -func (g *Group) GetChatHistory() []ChatHistoryEntry { - g.mu.Lock() - defer g.mu.Unlock() - - g.history = discardObsoleteHistory( - g.history, maxHistoryAge(g.description), - ) - - h := make([]ChatHistoryEntry, len(g.history)) - copy(h, g.history) - return h -} - -func matchClient(group string, c Challengeable, users []ClientCredentials) (bool, bool) { - for _, u := range users { - if u.Username == "" { - if c.Challenge(group, u) { - return true, true - } - } else if u.Username == c.Username() { - if c.Challenge(group, u) { - return true, true - } else { - return true, false - } - } - } - return false, false -} - -type description struct { - fileName string `json:"-"` - loadTime time.Time `json:"-"` - modTime time.Time `json:"-"` - fileSize int64 `json:"-"` - Description string `json:"description,omitempty"` - Redirect string `json:"redirect,omitempty"` - Public bool `json:"public,omitempty"` - MaxClients int `json:"max-clients,omitempty"` - MaxHistoryAge int `json:"max-history-age,omitempty"` - AllowAnonymous bool `json:"allow-anonymous,omitempty"` - AllowRecording bool `json:"allow-recording,omitempty"` - AllowSubgroups bool `json:"allow-subgroups,omitempty"` - Op []ClientCredentials `json:"op,omitempty"` - Presenter []ClientCredentials `json:"presenter,omitempty"` - Other []ClientCredentials `json:"other,omitempty"` -} - -const DefaultMaxHistoryAge = 4 * time.Hour - -func maxHistoryAge(desc *description) time.Duration { - if desc.MaxHistoryAge != 0 { - return time.Duration(desc.MaxHistoryAge) * time.Second - } - return DefaultMaxHistoryAge -} - -func openDescriptionFile(name string) (*os.File, string, bool, error) { - isParent := false - for name != "" { - fileName := filepath.Join( - Directory, path.Clean("/"+name)+".json", - ) - r, err := os.Open(fileName) - if !os.IsNotExist(err) { - return r, fileName, isParent, err - } - isParent = true - name, _ = path.Split(name) - name = strings.TrimRight(name, "/") - } - return nil, "", false, os.ErrNotExist -} - -func statDescriptionFile(name string) (os.FileInfo, string, bool, error) { - isParent := false - for name != "" { - fileName := filepath.Join( - Directory, path.Clean("/"+name)+".json", - ) - fi, err := os.Stat(fileName) - if !os.IsNotExist(err) { - return fi, fileName, isParent, err - } - isParent = true - name, _ = path.Split(name) - name = strings.TrimRight(name, "/") - } - return nil, "", false, os.ErrNotExist -} - -// descriptionChanged returns true if a group's description may have -// changed since it was last read. -func descriptionChanged(name string, desc *description) bool { - fi, fileName, _, err := statDescriptionFile(name) - if err != nil || fileName != desc.fileName { - return true - } - - if fi.Size() != desc.fileSize || fi.ModTime() != desc.modTime { - return true - } - return false -} - -func GetDescription(name string) (*description, error) { - r, fileName, isParent, err := openDescriptionFile(name) - if err != nil { - return nil, err - } - defer r.Close() - - var desc description - - fi, err := r.Stat() - if err != nil { - return nil, err - } - - d := json.NewDecoder(r) - err = d.Decode(&desc) - if err != nil { - return nil, err - } - if isParent { - if !desc.AllowSubgroups { - return nil, os.ErrNotExist - } - desc.Public = false - desc.Description = "" - } - - desc.fileName = fileName - desc.fileSize = fi.Size() - desc.modTime = fi.ModTime() - desc.loadTime = time.Now() - - return &desc, nil -} - -func (desc *description) GetPermission(group string, c Challengeable) (ClientPermissions, error) { - var p ClientPermissions - if !desc.AllowAnonymous && c.Username() == "" { - return p, UserError("anonymous users not allowed in this group, please choose a username") - } - if found, good := matchClient(group, c, desc.Op); found { - if good { - p.Op = true - p.Present = true - if desc.AllowRecording { - p.Record = true - } - return p, nil - } - return p, ErrNotAuthorised - } - if found, good := matchClient(group, c, desc.Presenter); found { - if good { - p.Present = true - return p, nil - } - return p, ErrNotAuthorised - } - if found, good := matchClient(group, c, desc.Other); found { - if good { - return p, nil - } - return p, ErrNotAuthorised - } - return p, ErrNotAuthorised -} - -type Public struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - ClientCount int `json:"clientCount"` -} - -func GetPublic() []Public { - gs := make([]Public, 0) - Range(func(g *Group) bool { - if g.Public() { - gs = append(gs, Public{ - Name: g.name, - Description: g.description.Description, - ClientCount: len(g.clients), - }) - } - return true - }) - sort.Slice(gs, func(i, j int) bool { - return gs[i].Name < gs[j].Name - }) - return gs -} - -func ReadPublicGroups() { - dir, err := os.Open(Directory) - if err != nil { - return - } - defer dir.Close() - - fis, err := dir.Readdir(-1) - if err != nil { - log.Printf("readPublicGroups: %v", err) - return - } - - for _, fi := range fis { - if !strings.HasSuffix(fi.Name(), ".json") { - continue - } - name := fi.Name()[:len(fi.Name())-5] - desc, err := GetDescription(name) - if err != nil { - if !os.IsNotExist(err) { - log.Printf("Reading group %v: %v", name, err) - } - continue - } - if desc.Public { - Add(name, desc) - } - } -} diff --git a/sources/group/group_test.go b/sources/group/group_test.go deleted file mode 100755 index fddd982..0000000 --- a/sources/group/group_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package group - -import ( - "encoding/json" - "reflect" - "testing" - "time" -) - -func TestJSTime(t *testing.T) { - tm := time.Now() - js := ToJSTime(tm) - tm2 := FromJSTime(js) - js2 := ToJSTime(tm2) - - if js != js2 { - t.Errorf("%v != %v", js, js2) - } - - delta := tm.Sub(tm2) - if delta < -time.Millisecond/2 || delta > time.Millisecond/2 { - t.Errorf("Delta %v, %v, %v", delta, tm, tm2) - } -} - -func TestDescriptionJSON(t *testing.T) { - d := ` -{ - "op":[{"username": "jch","password": "topsecret"}], - "max-history-age": 10, - "allow-subgroups": true, - "presenter":[ - {"user": "john", "password": "secret"}, - {} - ] -}` - - var dd description - err := json.Unmarshal([]byte(d), &dd) - if err != nil { - t.Fatalf("unmarshal: %v", err) - } - - ddd, err := json.Marshal(dd) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - var dddd description - err = json.Unmarshal([]byte(ddd), &dddd) - if err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if !reflect.DeepEqual(dd, dddd) { - t.Errorf("Got %v, expected %v", dddd, dd) - } -} diff --git a/sources/groups/groupname.json b/sources/groups/groupname.json deleted file mode 100644 index 9751310..0000000 --- a/sources/groups/groupname.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "op": [{"username": "__ADMIN__", "password": "__PASSWORD__"}], - "presenter": [{}] -} diff --git a/sources/static/404.css b/sources/static/404.css deleted file mode 100755 index 0dcc899..0000000 --- a/sources/static/404.css +++ /dev/null @@ -1,69 +0,0 @@ -body { - margin: 0; - display: flex; - flex-direction: column; - justify-content: center; - height: 100vh; - padding: 0px 30px; - background: #ddd; -} - -.wrapper { - max-width: 960px; - width: 100%; - margin: 30px auto; - transform: scale(0.8); -} - -.landing-page { - max-width: 960px; - height: 475px; - margin: 0; - box-shadow: 0px 0px 8px 1px #ccc; - background: #fafafa; - border-radius: 8px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -.logo { - color: #7e7e7e; - font-size: 8em; - text-align: center; - line-height: 1.1; -} - -.logo .fa { - color: #c39999; -} - -h1 { - font-size: 48px; - margin: 0; - color: #7e7e7e; -} - -p { - font-size: 18px; - width: 35%; - margin: 16px auto 24px; - text-align: center; -} - -.home-link { - text-decoration: none; - border-radius: 8px; - padding: 12px 24px; - font-size: 18px; - cursor: pointer; - background: #610a86; - color: #fff; - border: none; - box-shadow: 0 4px 4px 0 #ccc; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} diff --git a/sources/static/404.html b/sources/static/404.html deleted file mode 100755 index fbf187a..0000000 --- a/sources/static/404.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - Page not Found - - - - - - - - - - - - - -
-
- - -

Page not found!

-

We can't find the page you're looking for.

- Back to home -
-
- - - diff --git a/sources/static/common.css b/sources/static/common.css deleted file mode 100755 index 9553cbf..0000000 --- a/sources/static/common.css +++ /dev/null @@ -1,44 +0,0 @@ -h1 { - font-size: 160%; -} - -.inline { - display: inline; -} - -.signature { - border-top: solid; - padding-top: 0; - border-width: thin; - clear: both; - height: 3.125rem; - text-align: center; -} - -body { - overflow-x: hidden; -} -body, html { - height: 100%; -} -body { - margin: 0; - font-family: Metropolis,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: #687281; - text-align: left; - background-color: #eff3f9; -} - -*, :after, :before { - box-sizing: border-box; -} - -textarea { - font-family: Metropolis,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; -} \ No newline at end of file diff --git a/sources/static/css/fontawesome.min.css b/sources/static/css/fontawesome.min.css deleted file mode 100755 index 8e36e25..0000000 --- a/sources/static/css/fontawesome.min.css +++ /dev/null @@ -1,5 +0,0 @@ -/*! - * Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) - */ -.fa,.fab,.fad,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical,.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-freshener:before{content:"\f5d0"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-atlas:before{content:"\f558"}.fa-atlassian:before{content:"\f77b"}.fa-atom:before{content:"\f5d2"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-award:before{content:"\f559"}.fa-aws:before{content:"\f375"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before{content:"\f77d"}.fa-backspace:before{content:"\f55a"}.fa-backward:before{content:"\f04a"}.fa-bacon:before{content:"\f7e5"}.fa-bacteria:before{content:"\e059"}.fa-bacterium:before{content:"\e05a"}.fa-bahai:before{content:"\f666"}.fa-balance-scale:before{content:"\f24e"}.fa-balance-scale-left:before{content:"\f515"}.fa-balance-scale-right:before{content:"\f516"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-barcode:before{content:"\f02a"}.fa-bars:before{content:"\f0c9"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-bath:before{content:"\f2cd"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-three-quarters:before{content:"\f241"}.fa-battle-net:before{content:"\f835"}.fa-bed:before{content:"\f236"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bible:before{content:"\f647"}.fa-bicycle:before{content:"\f206"}.fa-biking:before{content:"\f84a"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blind:before{content:"\f29d"}.fa-blog:before{content:"\f781"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-book-dead:before{content:"\f6b7"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-reader:before{content:"\f5da"}.fa-bookmark:before{content:"\f02e"}.fa-bootstrap:before{content:"\f836"}.fa-border-all:before{content:"\f84c"}.fa-border-none:before{content:"\f850"}.fa-border-style:before{content:"\f853"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-box-open:before{content:"\f49e"}.fa-box-tissue:before{content:"\e05b"}.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-brush:before{content:"\f55d"}.fa-btc:before{content:"\f15a"}.fa-buffer:before{content:"\f837"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before{content:"\f55e"}.fa-business-time:before{content:"\f64a"}.fa-buy-n-large:before{content:"\f8a6"}.fa-buysellads:before{content:"\f20d"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-times:before{content:"\f273"}.fa-calendar-week:before{content:"\f784"}.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-campground:before{content:"\f6bb"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-candy-cane:before{content:"\f786"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-car-alt:before{content:"\f5de"}.fa-car-battery:before{content:"\f5df"}.fa-car-crash:before{content:"\f5e1"}.fa-car-side:before{content:"\f5e4"}.fa-caravan:before{content:"\f8ff"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cash-register:before{content:"\f788"}.fa-cat:before{content:"\f6be"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-charging-station:before{content:"\f5e7"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-pie:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-double:before{content:"\f560"}.fa-check-square:before{content:"\f14a"}.fa-cheese:before{content:"\f7ef"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chrome:before{content:"\f268"}.fa-chromecast:before{content:"\f838"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-city:before{content:"\f64f"}.fa-clinic-medical:before{content:"\f7f2"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudflare:before{content:"\e07d"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cocktail:before{content:"\f561"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-slash:before{content:"\f4b3"}.fa-comments:before{content:"\f086"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compress:before{content:"\f066"}.fa-compress-alt:before{content:"\f422"}.fa-compress-arrows-alt:before{content:"\f78c"}.fa-concierge-bell:before{content:"\f562"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-cotton-bureau:before{content:"\f89e"}.fa-couch:before{content:"\f4b8"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-credit-card:before{content:"\f09d"}.fa-critical-role:before{content:"\f6c9"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dailymotion:before{content:"\e052"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-deezer:before{content:"\e077"}.fa-delicious:before{content:"\f1a5"}.fa-democrat:before{content:"\f747"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dharmachakra:before{content:"\f655"}.fa-dhl:before{content:"\f790"}.fa-diagnoses:before{content:"\f470"}.fa-diaspora:before{content:"\f791"}.fa-dice:before{content:"\f522"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-digital-tachograph:before{content:"\f566"}.fa-directions:before{content:"\f5eb"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-disease:before{content:"\f7fa"}.fa-divide:before{content:"\f529"}.fa-dizzy:before{content:"\f567"}.fa-dna:before{content:"\f471"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dog:before{content:"\f6d3"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-flatbed:before{content:"\f474"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-drafting-compass:before{content:"\f568"}.fa-dragon:before{content:"\f6d5"}.fa-draw-polygon:before{content:"\f5ee"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-drupal:before{content:"\f1a9"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edge-legacy:before{content:"\e078"}.fa-edit:before{content:"\f044"}.fa-egg:before{content:"\f7fb"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-ethernet:before{content:"\f796"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-evernote:before{content:"\f839"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-alt:before{content:"\f424"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fan:before{content:"\f863"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-faucet:before{content:"\e005"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before{content:"\f56b"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-female:before{content:"\f182"}.fa-fighter-jet:before{content:"\f0fb"}.fa-figma:before{content:"\f799"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-download:before{content:"\f56d"}.fa-file-excel:before{content:"\f1c3"}.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-upload:before{content:"\f574"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-alt:before{content:"\f7e4"}.fa-fire-extinguisher:before{content:"\f134"}.fa-firefox:before{content:"\f269"}.fa-firefox-browser:before{content:"\e007"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-fish:before{content:"\f578"}.fa-fist-raised:before{content:"\f6de"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flask:before{content:"\f0c3"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-flushed:before{content:"\f579"}.fa-fly:before{content:"\f417"}.fa-folder:before{content:"\f07b"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-frog:before{content:"\f52e"}.fa-frown:before{content:"\f119"}.fa-frown-open:before{content:"\f57a"}.fa-fulcrum:before{content:"\f50b"}.fa-funnel-dollar:before{content:"\f662"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gifts:before{content:"\f79c"}.fa-git:before{content:"\f1d3"}.fa-git-alt:before{content:"\f841"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass-cheers:before{content:"\f79f"}.fa-glass-martini:before{content:"\f000"}.fa-glass-martini-alt:before{content:"\f57b"}.fa-glass-whiskey:before{content:"\f7a0"}.fa-glasses:before{content:"\f530"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-globe-africa:before{content:"\f57c"}.fa-globe-americas:before{content:"\f57d"}.fa-globe-asia:before{content:"\f57e"}.fa-globe-europe:before{content:"\f7a2"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-pay:before{content:"\e079"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before{content:"\f19d"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-grimace:before{content:"\f57f"}.fa-grin:before{content:"\f580"}.fa-grin-alt:before{content:"\f581"}.fa-grin-beam:before{content:"\f582"}.fa-grin-beam-sweat:before{content:"\f583"}.fa-grin-hearts:before{content:"\f584"}.fa-grin-squint:before{content:"\f585"}.fa-grin-squint-tears:before{content:"\f586"}.fa-grin-stars:before{content:"\f587"}.fa-grin-tears:before{content:"\f588"}.fa-grin-tongue:before{content:"\f589"}.fa-grin-tongue-squint:before{content:"\f58a"}.fa-grin-tongue-wink:before{content:"\f58b"}.fa-grin-wink:before{content:"\f58c"}.fa-grip-horizontal:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guilded:before{content:"\e07e"}.fa-guitar:before{content:"\f7a6"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hamburger:before{content:"\f805"}.fa-hammer:before{content:"\f6e3"}.fa-hamsa:before{content:"\f665"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-holding-water:before{content:"\f4c1"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-sparkles:before{content:"\e05d"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-helping:before{content:"\f4c4"}.fa-hands-wash:before{content:"\e05e"}.fa-handshake:before{content:"\f2b5"}.fa-handshake-alt-slash:before{content:"\e05f"}.fa-handshake-slash:before{content:"\e060"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-hat:before{content:"\f807"}.fa-hashtag:before{content:"\f292"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-hat-wizard:before{content:"\f6e8"}.fa-hdd:before{content:"\f0a0"}.fa-head-side-cough:before{content:"\e061"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-head-side-mask:before{content:"\e063"}.fa-head-side-virus:before{content:"\e064"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before{content:"\f7a9"}.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-highlighter:before{content:"\f591"}.fa-hiking:before{content:"\f6ec"}.fa-hippo:before{content:"\f6ed"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hive:before{content:"\e07f"}.fa-hockey-puck:before{content:"\f453"}.fa-holly-berry:before{content:"\f7aa"}.fa-home:before{content:"\f015"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hospital-user:before{content:"\f80d"}.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-house-damage:before{content:"\f6f1"}.fa-house-user:before{content:"\e065"}.fa-houzz:before{content:"\f27c"}.fa-hryvnia:before{content:"\f6f2"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-icicles:before{content:"\f7ad"}.fa-icons:before{content:"\f86d"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-ideal:before{content:"\e013"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-innosoft:before{content:"\e080"}.fa-instagram:before{content:"\f16d"}.fa-instagram-square:before{content:"\e055"}.fa-instalod:before{content:"\e081"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-italic:before{content:"\f033"}.fa-itch-io:before{content:"\f83a"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi:before{content:"\f669"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joint:before{content:"\f595"}.fa-joomla:before{content:"\f1aa"}.fa-journal-whills:before{content:"\f66a"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-kaaba:before{content:"\f66b"}.fa-kaggle:before{content:"\f5fa"}.fa-key:before{content:"\f084"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-khanda:before{content:"\f66d"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kiss:before{content:"\f596"}.fa-kiss-beam:before{content:"\f597"}.fa-kiss-wink-heart:before{content:"\f598"}.fa-kiwi-bird:before{content:"\f535"}.fa-korvue:before{content:"\f42f"}.fa-landmark:before{content:"\f66f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-house:before{content:"\e066"}.fa-laptop-medical:before{content:"\f812"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-laugh:before{content:"\f599"}.fa-laugh-beam:before{content:"\f59a"}.fa-laugh-squint:before{content:"\f59b"}.fa-laugh-wink:before{content:"\f59c"}.fa-layer-group:before{content:"\f5fd"}.fa-leaf:before{content:"\f06c"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-line:before{content:"\f3c0"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location-arrow:before{content:"\f124"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-low-vision:before{content:"\f2a8"}.fa-luggage-cart:before{content:"\f59d"}.fa-lungs:before{content:"\f604"}.fa-lungs-virus:before{content:"\e067"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-mail-bulk:before{content:"\f674"}.fa-mailchimp:before{content:"\f59e"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-map:before{content:"\f279"}.fa-map-marked:before{content:"\f59f"}.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-markdown:before{content:"\f60f"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mask:before{content:"\f6fa"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-mdb:before{content:"\f8ca"}.fa-medal:before{content:"\f5a2"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-meh:before{content:"\f11a"}.fa-meh-blank:before{content:"\f5a4"}.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-memory:before{content:"\f538"}.fa-mendeley:before{content:"\f7b3"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-meteor:before{content:"\f753"}.fa-microblog:before{content:"\e01a"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microscope:before{content:"\f610"}.fa-microsoft:before{content:"\f3ca"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-square:before{content:"\f146"}.fa-mitten:before{content:"\f7b5"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mixer:before{content:"\e056"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mouse:before{content:"\f8cc"}.fa-mouse-pointer:before{content:"\f245"}.fa-mug-hot:before{content:"\f7b6"}.fa-music:before{content:"\f001"}.fa-napster:before{content:"\f3d2"}.fa-neos:before{content:"\f612"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nimblr:before{content:"\f5a8"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-octopus-deploy:before{content:"\e082"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-oil-can:before{content:"\f613"}.fa-old-republic:before{content:"\f510"}.fa-om:before{content:"\f679"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-orcid:before{content:"\f8d2"}.fa-osi:before{content:"\f41a"}.fa-otter:before{content:"\f700"}.fa-outdent:before{content:"\f03b"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-pager:before{content:"\f815"}.fa-paint-brush:before{content:"\f1fc"}.fa-paint-roller:before{content:"\f5aa"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-parking:before{content:"\f540"}.fa-passport:before{content:"\f5ab"}.fa-pastafarianism:before{content:"\f67b"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paypal:before{content:"\f1ed"}.fa-peace:before{content:"\f67c"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-square:before{content:"\f14b"}.fa-pencil-alt:before{content:"\f303"}.fa-pencil-ruler:before{content:"\f5ae"}.fa-penny-arcade:before{content:"\f704"}.fa-people-arrows:before{content:"\e068"}.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-perbyte:before{content:"\e083"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-person-booth:before{content:"\f756"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-alt:before{content:"\f879"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-square-alt:before{content:"\f87b"}.fa-phone-volume:before{content:"\f2a0"}.fa-photo-video:before{content:"\f87c"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-square:before{content:"\e01e"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-plane-slash:before{content:"\e069"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-poll:before{content:"\f681"}.fa-poll-h:before{content:"\f682"}.fa-poo:before{content:"\f2fe"}.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-pray:before{content:"\f683"}.fa-praying-hands:before{content:"\f684"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-pump-medical:before{content:"\e06a"}.fa-pump-soap:before{content:"\e06b"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-quran:before{content:"\f687"}.fa-r-project:before{content:"\f4f7"}.fa-radiation:before{content:"\f7b9"}.fa-radiation-alt:before{content:"\f7ba"}.fa-rainbow:before{content:"\f75b"}.fa-random:before{content:"\f074"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-record-vinyl:before{content:"\f8d9"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-registered:before{content:"\f25d"}.fa-remove-format:before{content:"\f87d"}.fa-renren:before{content:"\f18b"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-republican:before{content:"\f75e"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-rev:before{content:"\f5b2"}.fa-ribbon:before{content:"\f4d6"}.fa-ring:before{content:"\f70b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-route:before{content:"\f4d7"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-running:before{content:"\f70c"}.fa-rupee-sign:before{content:"\f156"}.fa-rust:before{content:"\e07a"}.fa-sad-cry:before{content:"\f5b3"}.fa-sad-tear:before{content:"\f5b4"}.fa-safari:before{content:"\f267"}.fa-salesforce:before{content:"\f83b"}.fa-sass:before{content:"\f41e"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-save:before{content:"\f0c7"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-scroll:before{content:"\f70e"}.fa-sd-card:before{content:"\f7c2"}.fa-search:before{content:"\f002"}.fa-search-dollar:before{content:"\f688"}.fa-search-location:before{content:"\f689"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-shapes:before{content:"\f61f"}.fa-share:before{content:"\f064"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield-alt:before{content:"\f3ed"}.fa-shield-virus:before{content:"\e06c"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopify:before{content:"\e057"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shopware:before{content:"\f5b5"}.fa-shower:before{content:"\f2cc"}.fa-shuttle-van:before{content:"\f5b6"}.fa-sign:before{content:"\f4d9"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-sim-card:before{content:"\f7c4"}.fa-simplybuilt:before{content:"\f215"}.fa-sink:before{content:"\e06d"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skating:before{content:"\f7c5"}.fa-sketch:before{content:"\f7c6"}.fa-skiing:before{content:"\f7c9"}.fa-skiing-nordic:before{content:"\f7ca"}.fa-skull:before{content:"\f54c"}.fa-skull-crossbones:before{content:"\f714"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-slash:before{content:"\f715"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before{content:"\f1de"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smile-beam:before{content:"\f5b8"}.fa-smile-wink:before{content:"\f4da"}.fa-smog:before{content:"\f75f"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-sms:before{content:"\f7cd"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snowboarding:before{content:"\f7ce"}.fa-snowflake:before{content:"\f2dc"}.fa-snowman:before{content:"\f7d0"}.fa-snowplow:before{content:"\f7d2"}.fa-soap:before{content:"\e06e"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-down-alt:before{content:"\f884"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-amount-up-alt:before{content:"\f885"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-sourcetree:before{content:"\f7d3"}.fa-spa:before{content:"\f5bb"}.fa-space-shuttle:before{content:"\f197"}.fa-speakap:before{content:"\f3f3"}.fa-speaker-deck:before{content:"\f83c"}.fa-spell-check:before{content:"\f891"}.fa-spider:before{content:"\f717"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spotify:before{content:"\f1bc"}.fa-spray-can:before{content:"\f5bd"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-square-root-alt:before{content:"\f698"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stackpath:before{content:"\f842"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-stopwatch-20:before{content:"\e06f"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-store-alt-slash:before{content:"\e070"}.fa-store-slash:before{content:"\e071"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-surprise:before{content:"\f5c2"}.fa-suse:before{content:"\f7d6"}.fa-swatchbook:before{content:"\f5c3"}.fa-swift:before{content:"\f8e1"}.fa-swimmer:before{content:"\f5c4"}.fa-swimming-pool:before{content:"\f5c5"}.fa-symfony:before{content:"\f83d"}.fa-synagogue:before{content:"\f69b"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-low:before{content:"\f76b"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-tenge:before{content:"\f7d7"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-the-red-yeti:before{content:"\f69d"}.fa-theater-masks:before{content:"\f630"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-think-peaks:before{content:"\f731"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-ticket-alt:before{content:"\f3ff"}.fa-tiktok:before{content:"\e07b"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-tint:before{content:"\f043"}.fa-tint-slash:before{content:"\f5c7"}.fa-tired:before{content:"\f5c8"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-toolbox:before{content:"\f552"}.fa-tools:before{content:"\f7d9"}.fa-tooth:before{content:"\f5c9"}.fa-torah:before{content:"\f6a0"}.fa-torii-gate:before{content:"\f6a1"}.fa-tractor:before{content:"\f722"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-traffic-light:before{content:"\f637"}.fa-trailer:before{content:"\e041"}.fa-train:before{content:"\f238"}.fa-tram:before{content:"\f7da"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-trash-restore:before{content:"\f829"}.fa-trash-restore-alt:before{content:"\f82a"}.fa-tree:before{content:"\f1bb"}.fa-trello:before{content:"\f181"}.fa-tripadvisor:before{content:"\f262"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-tv:before{content:"\f26c"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-uikit:before{content:"\f403"}.fa-umbraco:before{content:"\f8e8"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-uncharted:before{content:"\e084"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-uniregistry:before{content:"\f404"}.fa-unity:before{content:"\e049"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-unsplash:before{content:"\e07c"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-user:before{content:"\f007"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-injured:before{content:"\f728"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before{content:"\f509"}.fa-users-slash:before{content:"\e073"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-vaadin:before{content:"\f408"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-vest:before{content:"\e085"}.fa-vest-patches:before{content:"\e086"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-virus:before{content:"\e074"}.fa-virus-slash:before{content:"\e075"}.fa-viruses:before{content:"\e076"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-voicemail:before{content:"\f897"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume-down:before{content:"\f027"}.fa-volume-mute:before{content:"\f6a9"}.fa-volume-off:before{content:"\f026"}.fa-volume-up:before{content:"\f028"}.fa-vote-yea:before{content:"\f772"}.fa-vr-cardboard:before{content:"\f729"}.fa-vuejs:before{content:"\f41f"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-warehouse:before{content:"\f494"}.fa-watchman-monitoring:before{content:"\e087"}.fa-water:before{content:"\f773"}.fa-wave-square:before{content:"\f83e"}.fa-waze:before{content:"\f83f"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheelchair:before{content:"\f193"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wikipedia-w:before{content:"\f266"}.fa-wind:before{content:"\f72e"}.fa-window-close:before{content:"\f410"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before{content:"\f5ce"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wodu:before{content:"\e088"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yammer:before{content:"\f840"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto} \ No newline at end of file diff --git a/sources/static/css/regular.css b/sources/static/css/regular.css deleted file mode 100755 index 8db06c6..0000000 --- a/sources/static/css/regular.css +++ /dev/null @@ -1,15 +0,0 @@ -/*! - * Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) - */ -@font-face { - font-family: 'Font Awesome 5 Free'; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-regular-400.eot"); - src: url("../webfonts/fa-regular-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.woff") format("woff"), url("../webfonts/fa-regular-400.ttf") format("truetype"), url("../webfonts/fa-regular-400.svg#fontawesome") format("svg"); } - -.far { - font-family: 'Font Awesome 5 Free'; - font-weight: 400; } diff --git a/sources/static/css/solid.css b/sources/static/css/solid.css deleted file mode 100755 index 62922cb..0000000 --- a/sources/static/css/solid.css +++ /dev/null @@ -1,16 +0,0 @@ -/*! - * Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) - */ -@font-face { - font-family: 'Font Awesome 5 Free'; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-solid-900.eot"); - src: url("../webfonts/fa-solid-900.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.woff") format("woff"), url("../webfonts/fa-solid-900.ttf") format("truetype"), url("../webfonts/fa-solid-900.svg#fontawesome") format("svg"); } - -.fa, -.fas { - font-family: 'Font Awesome 5 Free'; - font-weight: 900; } diff --git a/sources/static/css/toastify.min.css b/sources/static/css/toastify.min.css deleted file mode 100755 index 8041580..0000000 --- a/sources/static/css/toastify.min.css +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Minified by jsDelivr using clean-css v4.2.3. - * Original file: /npm/toastify-js@1.9.1/src/toastify.css - * - * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files - */ -/*! - * Toastify js 1.9.1 - * https://github.com/apvarun/toastify-js - * @license MIT licensed - * - * Copyright (C) 2018 Varun A P - */ -.toastify{padding:12px 20px;color:#fff;display:inline-block;box-shadow:0 3px 6px -1px rgba(0,0,0,.12),0 10px 36px -4px rgba(77,96,232,.3);background:-webkit-linear-gradient(315deg,#73a5ff,#5477f5);background:linear-gradient(135deg,#73a5ff,#5477f5);position:fixed;opacity:0;transition:all .4s cubic-bezier(.215,.61,.355,1);border-radius:2px;cursor:pointer;text-decoration:none;max-width:calc(50% - 20px);z-index:2147483647}.toastify.on{opacity:1}.toast-close{opacity:.4;padding:0 5px}.toastify-right{right:15px}.toastify-left{left:15px}.toastify-top{top:-150px}.toastify-bottom{bottom:-150px}.toastify-rounded{border-radius:25px}.toastify-avatar{width:1.5em;height:1.5em;margin:-7px 5px;border-radius:2px}.toastify-center{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content}@media only screen and (max-width:360px){.toastify-left,.toastify-right{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content}} -/*# sourceMappingURL=/sm/9c0bbf2acc17f6468f9dd75307f4d772b55e466d0ddceef6dc95ee31ca309918.map */ \ No newline at end of file diff --git a/sources/static/galene.css b/sources/static/galene.css deleted file mode 100755 index 814b186..0000000 --- a/sources/static/galene.css +++ /dev/null @@ -1,1307 +0,0 @@ -.nav-fixed .topnav { - z-index: 1039; -} - -.fixed-top{ - position: fixed; - top: 0; - right: 0; - left: 0; - z-index: 1030; -} - -.topnav { - padding-left: 0; - height: 3.5rem; - z-index: 1039; -} - -.navbar .form-control, .topnav { - font-size: 1rem; -} - -.form-control { - display: block; - padding: .375rem .75rem; - font-size: 1rem; - line-height: 1.5; - color: #495057; - background-color: #fff; - background-clip: padding-box; - border: 1px solid #ced4da; - border-radius: .25rem; - transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; -} - -.form-control-inline { - display: inline-block; -} - -.shadow { - box-shadow: 0 .15rem 1.75rem 0 rgba(31,45,65,.15); -} -.bg-white { - background-color: #fff; -} - -.bg-gray { - background-color: #eee; -} - -.profile { - width: 230px; -} - -.profile-logo { - float: left; - width: 50px; - height: 50px; - background: #b681c3; - border-radius: 25px; - text-align: center; - vertical-align: middle; - font-size: 1.4em; - padding: 7px; - color: #f9f9f9; -} - -.profile-info { - float: left; - margin-left: 10px; - margin-top: 8px; - color: #616263; - width: 120px; -} - -.user-logout { - float: right; - text-align: center; -} - -.logout-icon { - display: block; - font-size: 1.5em; -} - -.logout-text { - font-size: .7em; -} - -.profile-info span { - display: block; - line-height: 1.2; - text-transform: capitalize; -} - -#permspan { - font-size: .9em; - color: #108e07; - font-style: italic; -} - -.sidenav .user-logout a { - font-size: 1em; - padding: 7px 0 0; - color: #e4157e; - cursor: pointer; - line-height: .7; -} - -.sidenav .user-logout a:hover { - color: #ab0659; -} - -.navbar, .navbar .container, .navbar .container-fluid, .navbar .container-lg, .navbar .container-md, .navbar .container-sm, .navbar .container-xl { - display: -webkit-box; - display: flex; - flex-wrap: wrap; - -webkit-box-align: center; - align-items: center; - -webkit-box-pack: justify; - justify-content: space-between; - background: #610a86; -} -.navbar { - position: relative; - padding: .1rem; -} - -.topnav .navbar-brand { - width: 15rem; - padding-left: 1rem; - padding-right: 1rem; - margin: 0; - font-size: 1rem; - font-weight: 700; -} - -.btn { - display: inline-block; - font-weight: 400; - font-size: 1em; - text-align: center; - white-space: nowrap; - vertical-align: middle; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - border: 1px solid transparent; - padding: 0.255rem .75rem; - line-height: 1.5; - border-radius: .25rem; - transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out; -} - -.btn { - transition-duration: 0.4s; -} - -.btn-default:hover { - color: #fff; - background-color: #545b62; - border-color: #4e555b; -} - -.btn-default { - color: #fff; - background-color: #6c757d; - border-color: #6c757d; -} - -.btn:not(:disabled):not(.disabled) { - cursor: pointer; -} - -.btn-success { - color: #fff; - background-color: #28a745; - border-color: #28a745; -} - -.btn-success:hover { - color: #fff; - background-color: #218838; - border-color: #1e7e34; -} - -.btn-cancel { - color: #fff; - background-color: #dc3545; - border-color: #dc3545; -} - -.btn-cancel:hover { - color: #fff; - background-color: #c82333; - border-color: #bd2130; -} - -.btn-blue { - color: #fff; - background-color: #007bff; - border-color: #007bff; -} - -.btn-blue:hover { - color: #fff; - background-color: #0069d9; - border-color: #0062cc; -} - -.btn-warn { - color: #ffc107; - background-color: transparent; - background-image: none; - border-color: #ffc107; -} - -.btn-warn:hover { - color: #212529; - background-color: #ffc107; - border-color: #ffc107; -} - -.btn-large { - font-size: 110%; -} - -.app { - background-color: #f4f4f4; - overflow: hidden; - margin: 0; - padding: 0; - box-shadow: 0 1px 1px 0 rgba(0, 0, 0, .06), 0 2px 5px 0 rgba(0, 0, 0, .2); -} - -.coln-left { - flex: 30%; - padding: 0; - margin: 0; -} - -.coln-left-hide { - flex: 0; -} - -.coln-right { - flex: 70%; - position: relative; -} - -/* Clear floats after the columns */ -.row { - display: flex; -} - -.full-height { - height: calc(var(--vh, 1vh) * 100); -} - -.full-width { - width: calc(100vw - 200px); - height: calc(var(--vh, 1vh) * 100 - 56px); -} - -.full-width-active { - width: 100vw; -} - -.container { - width: 100%; -} - -.users-header { - height: 3.5rem; - padding: 10px; - background: #610a86; - font-size: .95rem; - font-weight: 500; -} - -.users-header:after, .profile-user:after, .users-header:before { - display: table; - content: " "; -} - -.users-header:after, .profile-user:after { - clear: both; -} - -.reply { - height: 53px; - width: 100%; - background-color: #eae7e5; - padding: 10px 5px 10px 5px; - margin: 0; - z-index: 1000; -} - -.reply textarea { - width: 100%; - resize: none; - overflow: hidden; - padding: 5px; - outline: none; - border: none; - text-indent: 5px; - box-shadow: none; - height: 100%; -} - -textarea.form-reply { - height: 2.1em; - margin-right: .5em; -} - -.form-reply { - display: block; - width: 100%; - height: 34px; - padding: 6px 12px; - font-size: 1rem; - color: #555; - background-color: #fff; - background-image: none; - border: 1px solid #ccc; - border-radius: 4px; - box-shadow: inset 0 1px 1px rgba(0,0,0,.075); - transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s; -} - -.form-reply::placeholder { - opacity: .7; -} - -.select { - display: block; - width: 100%; - padding: .275rem .75rem; - font-size: 1rem; - line-height: 1.5; - color: #495057; - background-color: #fff; - background-clip: padding-box; - border: 1px solid #ced4da; - border-radius: .25rem; - transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; -} - -.select-inline { - display: inline-block; -} - -.message { - width: auto !important; - padding: 4px 10px 7px !important; - background: #daf1c6; - font-size: 12px; - box-shadow: 0 1px 1px rgba(43, 43, 43, 0.16); - border-radius: 5px; - word-wrap: break-word; - display: inline-block; - margin: 1em 0 0; - max-width: 90%; - text-align: left; -} - -.message-sender { - background: #e6e6e6; -} - -.message-private { - background: white; -} - -.message-private .message-header:after { - content: "(private)"; - margin-left: 1em; -} - -.message-system { - font-size: 10px; - background: #ececec; -} - -.message-row:after, .message-row:before { - display: table; - content: " "; -} - -.message-row:after { - clear: both; -} - -.message-content { - white-space: pre-wrap; - margin: 0; - padding: 0; - padding-left: 5px; - word-wrap: break-word; - word-break: break-word; - font-weight: 400; - font-size: 14px; - color: #202035; -} - -.message-header { - margin: 0; - font-style: italic; - text-shadow: none; -} - -.message-footer { - margin: 0; - padding: 0; - margin-bottom: -5px; - line-height: .9; - text-align: right; -} - -.message-time { - margin-left: 1em; -} - -.message-me-asterisk, .message-me-user { - margin-right: 0.33em; -} - -.video-container { - height: calc(var(--vh, 1vh) * 100 - 56px); - position: relative; - background: rgba(0, 0, 0, 0.91); - /* Display only when showing video */ - display: block; -} - -.collapse-video { - display: none; - /*on top of video peers*/ - z-index: 1002; - font-size: 1.8em; - position: absolute; - top: 10px; - left: 10px; - cursor: pointer; -} - -.collapse-video .open-chat { - color: #cac7c7; - height: 50px; - padding: 10px; - text-shadow: 0px 0px 1px #b3adad; -} - -.no-video { - display: none; -} - -.video-controls, .top-video-controls { - position: absolute; - width: 100%; - left: 0; - bottom: 25px; - text-align: center; - color: #eaeaea; - font-size: 1.1em; - opacity: 0; - height: 32px; -} - -.video-controls:after, .top-video-controls:after { - clear: both; - display: table; - content: " "; -} - -.top-video-controls { - text-align: right; - bottom: inherit; - top: 5px; -} - -.controls-button { - padding: 3px 10px; - vertical-align: middle; - height: 100%; -} - -.controls-left { - float: left; - text-align: left; -} - -.controls-right { - float: right; - text-align: right; -} - -.vc-overlay { - background: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 50%) 0%, rgb(0 0 0 / 70%) 100%); -} - -.peer:hover > .video-controls, .peer:hover > .top-video-controls { - opacity: 1; -} - -.video-controls span, .top-video-controls span { - margin-right: 20px; - transition: opacity .7s ease-out; - opacity: 1; - cursor: pointer; -} - -.video-controls span:last-child { - margin-right: 0; -} - -.video-controls span:hover, .top-video-controls span:hover { - opacity: .8; - transition: opacity .5s ease-out; -} - -.video-controls .volume { - display: inline-block; - text-align: center; -} - -.video-controls .video-play { - font-size: 0.85em; -} - -.video-controls span.disabled, .video-controls span.disabled:hover, .top-video-controls span.disabled:hover{ - opacity: .2; - color: #c8c8c8 -} - -.volume-mute { - vertical-align: middle; - width: 25px; - display: var(--dv, inline); -} - -.volume-slider { - height: 4px; - width: 60px; - cursor: pointer; - margin: 5px 5px; - vertical-align: middle; - opacity: var(--ov, 0); - transition: opacity .5s ease-out; -} - -.video-controls .volume:hover { - --ov: 1; - --dv: inline; -} - -.mobile-container { - display: block !important; -} - -.login-container { - height: calc(var(--vh, 1vh) * 100 - 56px); - position: relative; - display: flex; - justify-content: center; -} - -.login-box { - width: 20em; - padding: 1em; - margin: 5em auto; - height: 23em; - background: #fcfcfc; -} - -.login-box .connect { - width: 100%; - text-align: center; -} - -.login-box h2 { - text-align: center; - margin-bottom: 40px; -} - -.label-fallback { - opacity: 0.5; -} - -.label { - left: 0; - position: absolute; - bottom: 0; - width: 100%; - z-index: 1; - text-align: center; - line-height: 24px; - color: #ffffff; -} - -.nav-link { - padding: 0; - color: #dbd9d9; - min-width: 30px; - display: block; - text-align: center; - margin: 0 10px; - position: relative; - line-height: 1.1; -} - -.nav-link span { - display: block; -} - -.nav-link label { - display: block; - cursor: pointer; - color: #fff; - font-size: 55%; -} - -.nav-link:hover { - color: #c2a4e0; -} -.nav-link label:hover { - color: #c2a4e0; -} - -.nav-cancel, .muted, .nav-cancel label, .muted label { - color: #d03e3e -} - -.nav-cancel:hover, .muted:hover, .nav-cancel label:hover, .muted label:hover { - color: #d03e3e -} - -.nav-button { - cursor: pointer; - font-size: 25px; -} - -.nav-more { - padding-top: 5px; - margin-left: 0; -} - -.header-title { - float: left; - margin: 0; - font-size: 1rem; - font-weight: 700; - color: #ebebeb; - line-height: 2em; -} - -#title { - text-align: center; -} - -h1 { - white-space: nowrap; -} - -#statdiv { - white-space: nowrap; - margin-bottom: 16px; -} - -#errspan { - margin-left: 1em; -} - -.connected { - color: green; -} - -.disconnected { - color: red; - font-weight: bold; -} - -.userform { - display: inline -} - -.userform label { - min-width: 3em; - display: inline-block; - padding-top: 10px; -} - -.userform input[type="text"], .userform input[type="password"] { - width: 100%; -} - -.switch-radio { - margin: 0; -} - -.invisible { - display: none; -} - -.error { - color: red; - font-weight: bold; -} - -.noerror { - display: none; -} - -.clear { - clear: both; - content: ""; -} - -#optionsdiv { - margin-bottom: 4px; -} - -#optionsdiv input[type="checkbox"] { - vertical-align: middle; -} - -#presentbutton, #unpresentbutton { - white-space: nowrap; - margin-right: 0.4em; - margin-top: .1em; - font-size: 1.1em; - text-align: left; - width: 5.5em; -} - -#videoselect { - text-align-last: center; - margin-right: 0.4em; -} - -#audioselect { - text-align-last: center; -} - -#sharebutton, #unsharebutton { - white-space: nowrap; -} - -#unsharebutton { - margin-right: 0.4em; -} - -#sendselect { - width: 8em; - text-align-last: center; - margin-right: 0.4em; -} - -#requestselect { - width: 8em; - text-align-last: center; -} - -#chatbox { - height: 100%; - position: relative; -} - -#chat { - padding: 0; - margin: 0; - background-color: #f8f8f8; - background-size: cover; - overflow-y: scroll; - border: none; - border-right: 4px solid #e6e6e6; - /* force to fill height */ - height: 100% !important; - width: 100%; - min-width: 300px; - overflow: hidden; -} - -#inputform { - display: flex; -} - -#box { - overflow: auto; - height: calc(100% - 53px); - padding: 10px; -} - -.close-chat { - position: absolute; - top: 2px; - right: 14px; - width: 25px; - font-size: 1em; - text-align: center; - font-weight: 700; - color: #8f8f8f; - cursor: pointer; - border: 1px solid transparent; -} - -.close-chat:hover, .close-chat:active { - border: 1px solid #dfdfdf; - border-radius: 4px; -} - -#connectbutton { - margin-top: 1em; - padding: 0.37rem 1.5rem; -} - -#input { - width: 100%; - border: none; - resize: none; - overflow-y: hidden; -} - -#input:focus { - outline: none; -} - -#inputbutton:focus { - outline: none; -} - -#resizer { - width: 4px; - margin-left: -4px; - z-index: 1000; -} - -#resizer:hover { - cursor: ew-resize; -} - -#peers { - padding: 10px; - display: grid; - grid-template-columns: repeat(1, 1fr); - grid-template-rows: repeat(1, auto); - row-gap: 5px; - column-gap: 10px; - position: absolute; - top: 0; - right: 0; - bottom: 0; - min-width: 100%; - min-height: 100%; - width: auto; - height: auto; - z-index: 1000; - background-size: cover; - overflow: hidden; - vertical-align: top!important; -} - -.peer { - margin-top: auto; - margin-bottom: auto; - position: relative; - border: 2px solid rgba(0,0,0,0); - background: #80808014; -} - -.peer-active { - border: 2px solid #610a86; -} - -.media { - width: 100%; - max-height: calc(var(--vh, 1vh) * 100 - 76px); - padding-bottom: 20px; - object-fit: contain; -} - -.media-failed { - opacity: 0.7; -} - -.mirror { - transform: scaleX(-1); -} - -#inputform { - width: 100%; -} - -.sidenav { - background-color: #4d076b; - box-shadow: 0 0 24px 0 rgba(71,77,86,.1), 0 1px 0 0 rgba(71,77,86,.08); - display: block; - position: fixed; - -webkit-transition: all .2s ease-out; - transition: all .2s ease-out; - width: 0px; - /* on top of everything */ - z-index: 2999; - top: 0; - right: 0; - height: calc(var(--vh, 1vh) * 100); - overflow-x: hidden; - overflow-y: hidden; -} - -.sidenav a { - padding: 10px 20px; - text-decoration: none; - font-size: 30px; - color: #dbd9d9; - display: block; - transition: 0.3s; - line-height: 1.0; -} - -.sidenav a:hover { - color: #c2a4e0; -} - -.sidenav .closebtn { - cursor: pointer; - position: absolute; - top: 0; - right: 0; - height: 56px; -} - -.sidenav-label { - display: block; - margin-top: 15px; -} - -.sidenav-label-first { - display: block; - margin-top: 0; -} - -.sidenav form{ - margin-top: 15px; -} - -.sidenav-header { - height: 56px; -} - -.sidenav-header h2{ - color: #fff; - padding: 10px; - margin: 0; - max-width: 70%; - line-height: 36px; -} - -.sidenav-content { - padding: 10px; - background: #fff; - height: 100%; -} - -.sidenav-content h2 { - margin: 0; -} - -fieldset { - margin: 0; - margin-top: 20px; - border: 1px solid #e9e8e8; - padding: 8px; - border-radius: 4px; -} -legend { - padding: 2px; - color: #4d4f51; -} - -.nav-menu { - margin: 0; - padding: 0; -} - -.nav-menu li { - float: left; - max-height: 70px; - list-style: none; -} - -.show-video { - position: absolute; - display: none; - right: 30px; - bottom: 120px; - color: white; - width: 50px; - height: 50px; - text-align: center; - line-height: 50px; - font-size: 150%; - border-radius: 30px; - background: #600aa0; - box-shadow: 4px 4px 7px 1px rgba(0,0,0,0.16); -} - -.blink { - -ms-animation: blink 1.0s linear infinite; - -o-animation: blink 1.0s linear infinite; - animation: blink 1.0s linear infinite; -} - -@keyframes blink { - 0% { box-shadow: 0 0 15px #600aa0; } - 50% { box-shadow: none; } - 100% { box-shadow: 0 0 15px #600aa0; } -} - -@-webkit-keyframes blink { - 0% { box-shadow: 0 0 15px #600aa0; } - 50% { box-shadow: 0 0 0; } - 100% { box-shadow: 0 0 15px #600aa0; } -} - -/* Dropdown Menu */ -.dropbtn { - cursor: pointer; -} - -.dropdown { - position: relative; - display: inline-block; -} - -.dropdown-content { - display: none; - position: absolute; - background-color: #fff; - max-width: 300px; - min-width: 200px; - margin-top: 7px; - overflow: auto; - right: 7px; - box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2); - z-index: 1; - padding: 15px; -} - -.dropdown-content a { - color: black; - padding: 12px 16px; - text-decoration: none; - display: block; -} - -.dropdown a:hover {background-color: #ddd;} - -.show {display: block;} - -.dropdown-content label{ - display: block; - margin-top: 15px; -} - -/* END Dropdown Menu */ - -/* Sidebar left */ - -.svg-inline--fa { - display: inline-block; - font-size: inherit; - height: 1.2em; - overflow: visible; - vertical-align: -.125em; - color: #dbd9d9; -} - -.svg-inline--fa:hover { - color: #c2a4e0; -} - -#left-sidebar { - min-width: 200px; - max-width: 200px; - transition: all 0.3s; - background: #ffffff; - border-right: 1px solid #dcdcdc; - z-index: 1039; -} - -#left-sidebar .galene-header { - display: inline-block; -} - -header .collapse { - float: left; - text-align: center; - cursor: pointer; - padding-top: 5px; - margin-right: 20px; - margin-left: 5px; -} - -.galene-header { - font-size: 1.3rem; - font-weight: 900; - color: #dbd9d9; - line-height: 34px; -} - -.header-sep { - height: 20px; -} - -/* Shrinking the sidebar from 200px to 60px and center aligining its content*/ -#left-sidebar.active { - min-width: 60px; - max-width: 60px; - text-align: center; - margin-left: -60px !important; -} - -#left-sidebar .sidebar-header strong { - display: none; -} -#left-sidebar.active .sidebar-header h3 { - display: none; -} -#left-sidebar.active .sidebar-header strong { - display: block; -} - -#users { - padding: 0; - margin: 0; - height: calc(100% - 84px); - width: 100%; - z-index: 1; - position: relative; - display: block; - background-color: #fff; - overflow-y: auto; - border: 1px solid #f7f7f7; -} - -#users .user-p { - position: relative; - padding: 10px !important; - border-bottom: 1px solid #f0f0f0; - height: 40px; - line-height: 18px; - margin: 0 !important; - cursor: pointer; - overflow: hidden; - white-space: pre; -} - -#left-sidebar.active #users > div { - padding: 10px 5px !important; -} - -#users > div:hover { - background-color: #f2f2f2; -} - -#users > div::before { - content: "\f111"; - font-family: 'Font Awesome 5 Free'; - color: #20b91e; - margin-right: 5px; - font-weight: 900; -} - -.close-icon { - font: normal 1em/1 Arial, sans-serif; - display: inline-block; -} - -.close-icon:before{ content: "\2715"; } - -/* END Sidebar Left */ - -@media only screen and (max-device-width: 1024px) { - #presentbutton, #unpresentbutton { - width: auto; - } - .nav-link { - margin: 0 4px; - line-height: 1.5; - } - - .nav-link label { - display: none; - } - - .nav-text { - display: none; - } - - .nav-more { - padding-top: 0; - margin-left: inherit; - } - - .full-width { - height: calc(var(--vh, 1vh) * 100 - 56px); - } - - .collapse-video { - left: inherit; - right: 60px; - } - - .close-chat { - display: none; - } - - .video-container { - position: fixed; - height: calc(var(--vh, 1vh) * 100 - 56px); - top: 56px; - right: 0; - left: 0; - margin-bottom: 60px; - } - - .login-container { - position: fixed; - height: calc(var(--vh, 1vh) * 100 - 56px); - top: 56px; - right: 0; - left: 0; - background: #eff3f9; - } - - .login-box { - background: transparent; - } - - .coln-left { - flex: 100%; - width: 100vw; - } - - .coln-right { - flex: none; - position: relative; - } - - .full-width { - width: 100vw; - } - - #left-sidebar.active { - min-width: 200px; - max-width: 200px; - } - - #left-sidebar { - min-width: 60px; - max-width: 60px; - text-align: center; - margin-left: -60px !important; - } - - /* Reappearing the sidebar on toggle button click */ - #left-sidebar { - margin-left: 0; - } - - #left-sidebar .sidebar-header strong { - display: none; - } - - #left-sidebar.active .sidebar-header h3 { - display: none; - } - - #left-sidebar.active .sidebar-header strong { - display: block; - } - - .sidenav a {padding: 10px 10px;} - - .sidenav-header h2 { - line-height: 36px; - } - - #peers { - padding: 3px; - } - - #resizer { - display: none; - } - - #chat { - border-right: none; - } - - .dropdown-content { - margin-top: 10px; - } - -} diff --git a/sources/static/galene.html b/sources/static/galene.html deleted file mode 100755 index 73f2d0b..0000000 --- a/sources/static/galene.html +++ /dev/null @@ -1,255 +0,0 @@ - - - - Galène - - - - - - - - - - - - - -
-
- -
-
- -
-
-
-
-
-
- -
-
-
-
- - -
-
-
-
-
-
- -
-
-
-
- -
-
-

Settings

- -
-
- - - -
- Other Settings - -
- - -
- -
- - -
- -
- - -
- -
- -
- - -
-
-
- - - - - - - - diff --git a/sources/static/galene.js b/sources/static/galene.js deleted file mode 100755 index a4b595a..0000000 --- a/sources/static/galene.js +++ /dev/null @@ -1,2338 +0,0 @@ -// Copyright (c) 2020 by Juliusz Chroboczek. - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -'use strict'; - -/** @type {string} */ -let group; - -/** @type {ServerConnection} */ -let serverConnection; - -/** - * @typedef {Object} userpass - * @property {string} username - * @property {string} password - */ - -/* Some browsers disable session storage when cookies are disabled, - we fall back to a global variable. */ -/** - * @type {userpass} - */ -let fallbackUserPass = null; - - -/** - * @param {string} username - * @param {string} password - */ -function storeUserPass(username, password) { - let userpass = {username: username, password: password}; - try { - window.sessionStorage.setItem('userpass', JSON.stringify(userpass)); - fallbackUserPass = null; - } catch(e) { - console.warn("Couldn't store password:", e); - fallbackUserPass = userpass; - } -} - -/** - * Returns null if the user hasn't logged in yet. - * - * @returns {userpass} - */ -function getUserPass() { - /** @type{userpass} */ - let userpass; - try { - let json = window.sessionStorage.getItem('userpass'); - userpass = JSON.parse(json); - } catch(e) { - console.warn("Couldn't retrieve password:", e); - userpass = fallbackUserPass; - } - return userpass || null; -} - -/** - * Return null if the user hasn't logged in yet. - * - * @returns {string} - */ -function getUsername() { - let userpass = getUserPass(); - if(!userpass) - return null; - return userpass.username; -} - -/** - * @typedef {Object} settings - * @property {boolean} [localMute] - * @property {string} [video] - * @property {string} [audio] - * @property {string} [send] - * @property {string} [request] - * @property {boolean} [activityDetection] - * @property {Array.} [resolution] - * @property {boolean} [blackboardMode] - */ - -/** @type{settings} */ -let fallbackSettings = null; - -/** - * @param {settings} settings - */ -function storeSettings(settings) { - try { - window.sessionStorage.setItem('settings', JSON.stringify(settings)); - fallbackSettings = null; - } catch(e) { - console.warn("Couldn't store password:", e); - fallbackSettings = settings; - } -} - -/** - * This always returns a dictionary. - * - * @returns {settings} - */ -function getSettings() { - /** @type {settings} */ - let settings; - try { - let json = window.sessionStorage.getItem('settings'); - settings = JSON.parse(json); - } catch(e) { - console.warn("Couldn't retrieve password:", e); - settings = fallbackSettings; - } - return settings || {}; -} - -/** - * @param {settings} settings - */ -function updateSettings(settings) { - let s = getSettings(); - for(let key in settings) - s[key] = settings[key]; - storeSettings(s); -} - -/** - * @param {string} key - * @param {any} value - */ -function updateSetting(key, value) { - let s = {}; - s[key] = value; - updateSettings(s); -} - -/** - * @param {string} key - */ -function delSetting(key) { - let s = getSettings(); - if(!(key in s)) - return; - delete(s[key]); - storeSettings(s) -} - -/** - * @param {string} id - */ -function getSelectElement(id) { - let elt = document.getElementById(id); - if(!elt || !(elt instanceof HTMLSelectElement)) - throw new Error(`Couldn't find ${id}`); - return elt; -} - -/** - * @param {string} id - */ -function getInputElement(id) { - let elt = document.getElementById(id); - if(!elt || !(elt instanceof HTMLInputElement)) - throw new Error(`Couldn't find ${id}`); - return elt; -} - -/** - * @param {string} id - */ -function getButtonElement(id) { - let elt = document.getElementById(id); - if(!elt || !(elt instanceof HTMLButtonElement)) - throw new Error(`Couldn't find ${id}`); - return elt; -} - -function reflectSettings() { - let settings = getSettings(); - let store = false; - - setLocalMute(settings.localMute); - - let videoselect = getSelectElement('videoselect'); - if(!settings.hasOwnProperty('video') || - !selectOptionAvailable(videoselect, settings.video)) { - settings.video = selectOptionDefault(videoselect); - store = true; - } - videoselect.value = settings.video; - - let audioselect = getSelectElement('audioselect'); - if(!settings.hasOwnProperty('audio') || - !selectOptionAvailable(audioselect, settings.audio)) { - settings.audio = selectOptionDefault(audioselect); - store = true; - } - audioselect.value = settings.audio; - - if(settings.hasOwnProperty('request')) { - getSelectElement('requestselect').value = settings.request; - } else { - settings.request = getSelectElement('requestselect').value; - store = true; - } - - if(settings.hasOwnProperty('send')) { - getSelectElement('sendselect').value = settings.send; - } else { - settings.send = getSelectElement('sendselect').value; - store = true; - } - - getInputElement('activitybox').checked = settings.activityDetection; - - getInputElement('blackboardbox').checked = settings.blackboardMode; - - if(store) - storeSettings(settings); -} - -function showVideo() { - let width = window.innerWidth; - let video_container = document.getElementById('video-container'); - video_container.classList.remove('no-video'); - if (width <= 768) - document.getElementById('collapse-video').style.display = "block"; -} - -/** - * @param {boolean} [force] - */ -function hideVideo(force) { - let mediadiv = document.getElementById('peers'); - if(mediadiv.childElementCount > 0 && !force) - return; - let video_container = document.getElementById('video-container'); - video_container.classList.add('no-video'); - let left = document.getElementById("left"); - if (left.style.display !== "none") { - // hide all video buttons used to switch video on mobile layout - closeVideoControls(); - } -} - -function closeVideoControls() { - // hide all video buttons used to switch video on mobile layout - document.getElementById('switch-video').style.display = ""; - document.getElementById('collapse-video').style.display = ""; -} - -function fillLogin() { - let userpass = getUserPass(); - getInputElement('username').value = - userpass ? userpass.username : ''; - getInputElement('password').value = - userpass ? userpass.password : ''; -} - -/** - * @param{boolean} connected - */ -function setConnected(connected) { - let userbox = document.getElementById('profile'); - let connectionbox = document.getElementById('login-container'); - if(connected) { - resetUsers(); - clearChat(); - userbox.classList.remove('invisible'); - connectionbox.classList.add('invisible'); - displayUsername(); - } else { - resetUsers(); - fillLogin(); - userbox.classList.add('invisible'); - connectionbox.classList.remove('invisible'); - displayError('Disconnected', 'error'); - hideVideo(); - closeVideoControls(); - } -} - -/** @this {ServerConnection} */ -function gotConnected() { - setConnected(true); - let up = getUserPass(); - this.join(group, up.username, up.password); -} - -/** - * @this {ServerConnection} - * @param {number} code - * @param {string} reason - */ -function gotClose(code, reason) { - delUpMediaKind(null); - setConnected(false); - if(code != 1000) { - console.warn('Socket close', code, reason); - } -} - -/** - * @this {ServerConnection} - * @param {Stream} c - */ -function gotDownStream(c) { - c.onclose = function() { - delMedia(c.id); - }; - c.onerror = function(e) { - console.error(e); - displayError(e); - } - c.ondowntrack = function(track, transceiver, label, stream) { - setMedia(c, false); - } - c.onlabel = function(label) { - setLabel(c); - } - c.onstatus = function(status) { - setMediaStatus(c); - } - c.onstats = gotDownStats; - if(getSettings().activityDetection) - c.setStatsInterval(activityDetectionInterval); -} - -// Store current browser viewport height in css variable -function setViewportHeight() { - document.documentElement.style.setProperty( - '--vh', `${window.innerHeight/100}px`, - ); - // Ajust video component size - resizePeers(); -} -setViewportHeight(); - -// On resize and orientation change, we update viewport height -addEventListener('resize', setViewportHeight); -addEventListener('orientationchange', setViewportHeight); - -getButtonElement('presentbutton').onclick = async function(e) { - e.preventDefault(); - let button = this; - if(!(button instanceof HTMLButtonElement)) - throw new Error('Unexpected type for this.'); - // there's a potential race condition here: the user might click the - // button a second time before the stream is set up and the button hidden. - button.disabled = true; - try { - let id = findUpMedia('local'); - if(!id) - await addLocalMedia(); - } finally { - button.disabled = false; - } -}; - -getButtonElement('unpresentbutton').onclick = function(e) { - e.preventDefault(); - delUpMediaKind('local'); - resizePeers(); -}; - -function changePresentation() { - let id = findUpMedia('local'); - if(id) { - addLocalMedia(id); - } -} - -/** - * @param {string} id - * @param {boolean} visible - */ -function setVisibility(id, visible) { - let elt = document.getElementById(id); - if(visible) - elt.classList.remove('invisible'); - else - elt.classList.add('invisible'); -} - -function setButtonsVisibility() { - let permissions = serverConnection.permissions; - let local = !!findUpMedia('local'); - let share = !!findUpMedia('screenshare'); - let video = !!findUpMedia('video'); - - // don't allow multiple presentations - setVisibility('presentbutton', permissions.present && !local); - setVisibility('unpresentbutton', local); - - setVisibility('mutebutton', permissions.present); - - // allow multiple shared documents - setVisibility('sharebutton', permissions.present && - ('getDisplayMedia' in navigator.mediaDevices)); - setVisibility('unsharebutton', share); - - setVisibility('stopvideobutton', video); - - setVisibility('mediaoptions', permissions.present); - setVisibility('sendform', permissions.present); - setVisibility('fileform', permissions.present); -} - -/** - * @param {boolean} mute - * @param {boolean} [reflect] - */ -function setLocalMute(mute, reflect) { - muteLocalTracks(mute); - let button = document.getElementById('mutebutton'); - let icon = button.querySelector("span .fas"); - if(mute){ - icon.classList.add('fa-microphone-slash'); - icon.classList.remove('fa-microphone'); - button.classList.add('muted'); - } else { - icon.classList.remove('fa-microphone-slash'); - icon.classList.add('fa-microphone'); - button.classList.remove('muted'); - } - if(reflect) - updateSettings({localMute: mute}); -} - -getSelectElement('videoselect').onchange = function(e) { - e.preventDefault(); - if(!(this instanceof HTMLSelectElement)) - throw new Error('Unexpected type for this'); - updateSettings({video: this.value}); - changePresentation(); -}; - -getSelectElement('audioselect').onchange = function(e) { - e.preventDefault(); - if(!(this instanceof HTMLSelectElement)) - throw new Error('Unexpected type for this'); - updateSettings({audio: this.value}); - changePresentation(); -}; - -getInputElement('blackboardbox').onchange = function(e) { - e.preventDefault(); - if(!(this instanceof HTMLInputElement)) - throw new Error('Unexpected type for this'); - updateSettings({blackboardMode: this.checked}); - changePresentation(); -} - -document.getElementById('mutebutton').onclick = function(e) { - e.preventDefault(); - let localMute = getSettings().localMute; - localMute = !localMute; - setLocalMute(localMute, true); -} - -document.getElementById('sharebutton').onclick = function(e) { - e.preventDefault(); - addShareMedia(); -}; - -document.getElementById('unsharebutton').onclick = function(e) { - e.preventDefault(); - delUpMediaKind('screenshare'); - resizePeers(); -} - -document.getElementById('stopvideobutton').onclick = function(e) { - e.preventDefault(); - delUpMediaKind('video'); - resizePeers(); -} - -/** @returns {number} */ -function getMaxVideoThroughput() { - let v = getSettings().send; - switch(v) { - case 'lowest': - return 150000; - case 'low': - return 300000; - case 'normal': - return 700000; - case 'unlimited': - return null; - default: - console.error('Unknown video quality', v); - return 700000; - } -} - -getSelectElement('sendselect').onchange = async function(e) { - if(!(this instanceof HTMLSelectElement)) - throw new Error('Unexpected type for this'); - updateSettings({send: this.value}); - let t = getMaxVideoThroughput(); - for(let id in serverConnection.up) { - let c = serverConnection.up[id]; - await setMaxVideoThroughput(c, t); - } -} - -getSelectElement('requestselect').onchange = function(e) { - e.preventDefault(); - if(!(this instanceof HTMLSelectElement)) - throw new Error('Unexpected type for this'); - updateSettings({request: this.value}); - serverConnection.request(this.value); -}; - -const activityDetectionInterval = 200; -const activityDetectionPeriod = 700; -const activityDetectionThreshold = 0.2; - -getInputElement('activitybox').onchange = function(e) { - if(!(this instanceof HTMLInputElement)) - throw new Error('Unexpected type for this'); - updateSettings({activityDetection: this.checked}); - for(let id in serverConnection.down) { - let c = serverConnection.down[id]; - if(this.checked) - c.setStatsInterval(activityDetectionInterval); - else { - c.setStatsInterval(0); - setActive(c, false); - } - } -} - -getInputElement('fileinput').onchange = function(e) { - if(!(this instanceof HTMLInputElement)) - throw new Error('Unexpected type for this'); - let input = this; - let files = input.files; - for(let i = 0; i < files.length; i++) - addFileMedia(files[i]); - input.value = ''; - closeNav(); -} - -/** - * @this {Stream} - * @param {Object} stats - */ -function gotUpStats(stats) { - let c = this; - - let text = ''; - - c.pc.getSenders().forEach(s => { - let tid = s.track && s.track.id; - let stats = tid && c.stats[tid]; - let rate = stats && stats['outbound-rtp'] && stats['outbound-rtp'].rate; - if(typeof rate === 'number') { - if(text) - text = text + ' + '; - text = text + Math.round(rate / 1000) + 'kbps'; - } - }); - - setLabel(c, text); -} - -/** - * @param {Stream} c - * @param {boolean} value - */ -function setActive(c, value) { - let peer = document.getElementById('peer-' + c.id); - if(value) - peer.classList.add('peer-active'); - else - peer.classList.remove('peer-active'); -} - -/** - * @this {Stream} - * @param {Object} stats - */ -function gotDownStats(stats) { - if(!getInputElement('activitybox').checked) - return; - - let c = this; - - let maxEnergy = 0; - - c.pc.getReceivers().forEach(r => { - let tid = r.track && r.track.id; - let s = tid && stats[tid]; - let energy = s && s['track'] && s['track'].audioEnergy; - if(typeof energy === 'number') - maxEnergy = Math.max(maxEnergy, energy); - }); - - // totalAudioEnergy is defined as the integral of the square of the - // volume, so square the threshold. - if(maxEnergy > activityDetectionThreshold * activityDetectionThreshold) { - c.userdata.lastVoiceActivity = Date.now(); - setActive(c, true); - } else { - let last = c.userdata.lastVoiceActivity; - if(!last || Date.now() - last > activityDetectionPeriod) - setActive(c, false); - } -} - -/** - * @param {HTMLSelectElement} select - * @param {string} label - * @param {string} [value] - */ -function addSelectOption(select, label, value) { - if(!value) - value = label; - for(let i = 0; i < select.children.length; i++) { - let child = select.children[i]; - if(!(child instanceof HTMLOptionElement)) { - console.warn('Unexpected select child'); - continue; - } - if(child.value === value) { - if(child.label !== label) { - child.label = label; - } - return; - } - } - - let option = document.createElement('option'); - option.value = value; - option.textContent = label; - select.appendChild(option); -} - -/** - * @param {HTMLSelectElement} select - * @param {string} value - */ -function selectOptionAvailable(select, value) { - let children = select.children; - for(let i = 0; i < children.length; i++) { - let child = select.children[i]; - if(!(child instanceof HTMLOptionElement)) { - console.warn('Unexpected select child'); - continue; - } - if(child.value === value) - return true; - } - return false; -} - -/** - * @param {HTMLSelectElement} select - * @returns {string} - */ -function selectOptionDefault(select) { - /* First non-empty option. */ - for(let i = 0; i < select.children.length; i++) { - let child = select.children[i]; - if(!(child instanceof HTMLOptionElement)) { - console.warn('Unexpected select child'); - continue; - } - if(child.value) - return child.value; - } - /* The empty option is always available. */ - return ''; -} - -/* media names might not be available before we call getDisplayMedia. So - we call this twice, the second time to update the menu with user-readable - labels. */ -/** @type {boolean} */ -let mediaChoicesDone = false; - -/** - * @param{boolean} done - */ -async function setMediaChoices(done) { - if(mediaChoicesDone) - return; - - let devices = []; - try { - devices = await navigator.mediaDevices.enumerateDevices(); - } catch(e) { - console.error(e); - return; - } - - let cn = 1, mn = 1; - - devices.forEach(d => { - let label = d.label; - if(d.kind === 'videoinput') { - if(!label) - label = `Camera ${cn}`; - addSelectOption(getSelectElement('videoselect'), - label, d.deviceId); - cn++; - } else if(d.kind === 'audioinput') { - if(!label) - label = `Microphone ${mn}`; - addSelectOption(getSelectElement('audioselect'), - label, d.deviceId); - mn++; - } - }); - - mediaChoicesDone = done; -} - - -/** - * @param {string} [id] - */ -function newUpStream(id) { - let c = serverConnection.newUpStream(id); - c.onstatus = function(status) { - setMediaStatus(c); - } - c.onerror = function(e) { - console.error(e); - displayError(e); - delUpMedia(c); - } - c.onabort = function() { - delUpMedia(c); - } - c.onnegotiationcompleted = function() { - setMaxVideoThroughput(c, getMaxVideoThroughput()) - } - return c; -} - -/** - * @param {Stream} c - * @param {number} [bps] - */ -async function setMaxVideoThroughput(c, bps) { - let senders = c.pc.getSenders(); - for(let i = 0; i < senders.length; i++) { - let s = senders[i]; - if(!s.track || s.track.kind !== 'video') - continue; - let p = s.getParameters(); - if(!p.encodings) - p.encodings = [{}]; - p.encodings.forEach(e => { - if(bps > 0) - e.maxBitrate = bps; - else - delete e.maxBitrate; - }); - try { - await s.setParameters(p); - } catch(e) { - console.error(e); - } - } -} - -function isSafari() { - let ua = navigator.userAgent.toLowerCase(); - return ua.indexOf('safari') >= 0 && ua.indexOf('chrome') < 0; -} - -/** - * @param {string} [id] - */ -async function addLocalMedia(id) { - let settings = getSettings(); - - let audio = settings.audio ? {deviceId: settings.audio} : false; - let video = settings.video ? {deviceId: settings.video} : false; - - if(video) { - let resolution = settings.resolution; - if(resolution) { - video.width = { ideal: resolution[0] }; - video.height = { ideal: resolution[1] }; - } else if(settings.blackboardMode) { - video.width = { min: 640, ideal: 1920 }; - video.height = { min: 400, ideal: 1080 }; - } - } - - let old = id && serverConnection.up[id]; - - if(!audio && !video) { - if(old) - delUpMedia(old); - return; - } - - if(old) - stopUpMedia(old); - - let constraints = {audio: audio, video: video}; - /** @type {MediaStream} */ - let stream = null; - try { - stream = await navigator.mediaDevices.getUserMedia(constraints); - } catch(e) { - displayError(e); - if(old) - delUpMedia(old); - return; - } - - setMediaChoices(true); - - let c = newUpStream(id); - - c.kind = 'local'; - c.stream = stream; - let mute = getSettings().localMute; - stream.getTracks().forEach(t => { - c.labels[t.id] = t.kind - if(t.kind == 'audio') { - if(mute) - t.enabled = false; - } else if(t.kind == 'video') { - if(settings.blackboardMode) { - /** @ts-ignore */ - t.contentHint = 'detail'; - } - } - c.pc.addTrack(t, stream); - }); - - c.onstats = gotUpStats; - c.setStatsInterval(2000); - await setMedia(c, true, true); - setButtonsVisibility(); -} - -let safariScreenshareDone = false; - -async function addShareMedia() { - /** @type {MediaStream} */ - let stream = null; - try { - if(!('getDisplayMedia' in navigator.mediaDevices)) - throw new Error('Your browser does not support screen sharing'); - /** @ts-ignore */ - stream = await navigator.mediaDevices.getDisplayMedia({video: true}); - } catch(e) { - console.error(e); - displayError(e); - return; - } - - if(!safariScreenshareDone) { - if(isSafari()) - displayWarning('Screen sharing under Safari is experimental. ' + - 'Please use a different browser if possible.'); - safariScreenshareDone = true; - } - - let c = newUpStream(); - c.kind = 'screenshare'; - c.stream = stream; - stream.getTracks().forEach(t => { - c.pc.addTrack(t, stream); - t.onended = e => { - delUpMedia(c); - }; - c.labels[t.id] = 'screenshare'; - }); - c.onstats = gotUpStats; - c.setStatsInterval(2000); - await setMedia(c, true); - setButtonsVisibility() -} - -/** - * @param {File} file - */ -async function addFileMedia(file) { - let url = URL.createObjectURL(file); - let video = document.createElement('video'); - video.src = url; - video.controls = true; - /** @ts-ignore */ - let stream = video.captureStream(); - - let c = newUpStream(); - c.kind = 'video'; - c.stream = stream; - stream.onaddtrack = function(e) { - let t = e.track; - if(t.kind === 'audio') { - let presenting = !!findUpMedia('local'); - let muted = getSettings().localMute; - if(presenting && !muted) { - setLocalMute(true, true); - displayWarning('You have been muted'); - } - } - c.pc.addTrack(t, stream); - c.labels[t.id] = t.kind; - c.onstats = gotUpStats; - c.setStatsInterval(2000); - }; - stream.onremovetrack = function(e) { - let t = e.track; - delete(c.labels[t.id]); - - /** @type {RTCRtpSender} */ - let sender; - c.pc.getSenders().forEach(s => { - if(s.track === t) - sender = s; - }); - if(sender) { - c.pc.removeTrack(sender) - } else { - console.warn('Removing unknown track'); - } - - if(Object.keys(c.labels).length === 0) { - stream.onaddtrack = null; - stream.onremovetrack == null; - delUpMedia(c); - } - }; - await setMedia(c, true, false, video); - c.userdata.play = true; - setButtonsVisibility() -} - -/** - * @param {Stream} c - */ -function stopUpMedia(c) { - if(!c.stream) - return; - c.stream.getTracks().forEach(t => { - try { - t.stop(); - } catch(e) { - } - }); -} - -/** - * @param {Stream} c - */ -function delUpMedia(c) { - stopUpMedia(c); - try { - delMedia(c.id); - } catch(e) { - console.warn(e); - } - c.close(); - delete(serverConnection.up[c.id]); - setButtonsVisibility() -} - -/** - * delUpMediaKind reoves all up media of the given kind. If kind is - * falseish, it removes all up media. - * @param {string} kind -*/ -function delUpMediaKind(kind) { - for(let id in serverConnection.up) { - let c = serverConnection.up[id]; - if(kind && c.kind != kind) - continue - c.close(); - delMedia(id); - delete(serverConnection.up[id]); - } - - setButtonsVisibility(); - hideVideo(); -} - -/** - * @param {string} kind - */ -function findUpMedia(kind) { - for(let id in serverConnection.up) { - if(serverConnection.up[id].kind === kind) - return id; - } - return null; -} - -/** - * @param {boolean} mute - */ -function muteLocalTracks(mute) { - if(!serverConnection) - return; - for(let id in serverConnection.up) { - let c = serverConnection.up[id]; - if(c.kind === 'local') { - let stream = c.stream; - stream.getTracks().forEach(t => { - if(t.kind === 'audio') { - t.enabled = !mute; - } - }); - } - } -} - -/** - * setMedia adds a new media element corresponding to stream c. - * - * @param {Stream} c - * @param {boolean} isUp - * - indicates whether the stream goes in the up direction - * @param {boolean} [mirror] - * - whether to mirror the video - * @param {HTMLVideoElement} [video] - * - the video element to add. If null, a new element with custom - * controls will be created. - */ -async function setMedia(c, isUp, mirror, video) { - let peersdiv = document.getElementById('peers'); - - let div = document.getElementById('peer-' + c.id); - if(!div) { - div = document.createElement('div'); - div.id = 'peer-' + c.id; - div.classList.add('peer'); - peersdiv.appendChild(div); - } - - let media = /** @type {HTMLVideoElement} */ - (document.getElementById('media-' + c.id)); - if(media) { - if(video) { - throw new Error("Duplicate video"); - } - } else { - if(video) { - media = video; - } else { - media = document.createElement('video'); - if(isUp) - media.muted = true; - } - - media.classList.add('media'); - media.autoplay = true; - /** @ts-ignore */ - media.playsinline = true; - media.id = 'media-' + c.id; - div.appendChild(media); - if(!video) - addCustomControls(media, div, c); - if(mirror) - media.classList.add('mirror'); - } - - if(!video) - media.srcObject = c.stream; - - let label = document.getElementById('label-' + c.id); - if(!label) { - label = document.createElement('div'); - label.id = 'label-' + c.id; - label.classList.add('label'); - div.appendChild(label); - } - - setLabel(c); - setMediaStatus(c); - - showVideo(); - resizePeers(); - - if(!isUp && isSafari() && !findUpMedia('local')) { - // Safari doesn't allow autoplay unless the user has granted media access - try { - let stream = await navigator.mediaDevices.getUserMedia({audio: true}); - stream.getTracks().forEach(t => t.stop()); - } catch(e) { - } - } -} - -/** - * @param {Element} elt - */ -function cloneHTMLElement(elt) { - if(!(elt instanceof HTMLElement)) - throw new Error('Unexpected element type'); - return /** @type{HTMLElement} */(elt.cloneNode(true)); -} - -/** - * @param {HTMLVideoElement} media - * @param {HTMLElement} container - * @param {Stream} c - */ -function addCustomControls(media, container, c) { - media.controls = false; - let controls = document.getElementById('controls-' + c.id); - if(controls) { - console.warn('Attempted to add duplicate controls'); - return; - } - - let template = - document.getElementById('videocontrols-template').firstElementChild; - controls = cloneHTMLElement(template); - controls.id = 'controls-' + c.id; - - let volume = getVideoButton(controls, 'volume'); - if(c.kind === 'local') { - volume.remove(); - } else { - setVolumeButton(media.muted, - getVideoButton(controls, "volume-mute"), - getVideoButton(controls, "volume-slider")); - } - - container.appendChild(controls); - registerControlHandlers(media, container); -} - -/** - * @param {HTMLElement} container - * @param {string} name - */ -function getVideoButton(container, name) { - return /** @type {HTMLElement} */(container.getElementsByClassName(name)[0]); -} - -/** - * @param {boolean} muted - * @param {HTMLElement} button - * @param {HTMLElement} slider - */ -function setVolumeButton(muted, button, slider) { - if(!muted) { - button.classList.remove("fa-volume-mute"); - button.classList.add("fa-volume-up"); - } else { - button.classList.remove("fa-volume-up"); - button.classList.add("fa-volume-mute"); - } - - if(!(slider instanceof HTMLInputElement)) - throw new Error("Couldn't find volume slider"); - slider.disabled = muted; -} - -/** - * @param {HTMLVideoElement} media - * @param {HTMLElement} container - */ -function registerControlHandlers(media, container) { - let play = getVideoButton(container, 'video-play'); - if(play) { - play.onclick = function(event) { - event.preventDefault(); - media.play(); - }; - } - - let volume = getVideoButton(container, 'volume'); - if (volume) { - volume.onclick = function(event) { - let target = /** @type{HTMLElement} */(event.target); - if(!target.classList.contains('volume-mute')) - // if click on volume slider, do nothing - return; - event.preventDefault(); - media.muted = !media.muted; - setVolumeButton(media.muted, target, - getVideoButton(volume, "volume-slider")); - }; - volume.oninput = function() { - let slider = /** @type{HTMLInputElement} */ - (getVideoButton(volume, "volume-slider")); - media.volume = parseInt(slider.value, 10)/100; - }; - } - - let pip = getVideoButton(container, 'pip'); - if(pip) { - /** @ts-ignore */ - if(HTMLVideoElement.prototype.requestPictureInPicture) { - pip.onclick = function(e) { - e.preventDefault(); - /** @ts-ignore */ - if(media.requestPictureInPicture) { - /** @ts-ignore */ - media.requestPictureInPicture(); - } else { - displayWarning('Picture in Picture not supported.'); - } - }; - } else { - pip.style.display = 'none'; - } - } - - let fs = getVideoButton(container, 'fullscreen'); - if(fs) { - if(HTMLVideoElement.prototype.requestFullscreen || - /** @ts-ignore */ - HTMLVideoElement.prototype.webkitRequestFullscreen) { - fs.onclick = function(e) { - e.preventDefault(); - if(media.requestFullscreen) { - media.requestFullscreen(); - /** @ts-ignore */ - } else if(media.webkitRequestFullscreen) { - /** @ts-ignore */ - media.webkitRequestFullscreen(); - } else { - displayWarning('Full screen not supported!'); - } - }; - } else { - fs.style.display = 'none'; - } - } -} - -/** - * @param {string} id - */ -function delMedia(id) { - let mediadiv = document.getElementById('peers'); - let peer = document.getElementById('peer-' + id); - if(!peer) - throw new Error('Removing unknown media'); - - let media = /** @type{HTMLVideoElement} */ - (document.getElementById('media-' + id)); - - if(media.src) { - URL.revokeObjectURL(media.src); - media.src = null; - } - - media.srcObject = null; - mediadiv.removeChild(peer); - - resizePeers(); - hideVideo(); -} - -/** - * @param {Stream} c - */ -function setMediaStatus(c) { - let state = c && c.pc && c.pc.iceConnectionState; - let good = state === 'connected' || state === 'completed'; - - let media = document.getElementById('media-' + c.id); - if(!media) { - console.warn('Setting status of unknown media.'); - return; - } - if(good) { - media.classList.remove('media-failed'); - if(c.userdata.play) { - if(media instanceof HTMLMediaElement) - media.play().catch(e => { - console.error(e); - displayError(e); - }); - delete(c.userdata.play); - } - } else { - media.classList.add('media-failed'); - } -} - - -/** - * @param {Stream} c - * @param {string} [fallback] - */ -function setLabel(c, fallback) { - let label = document.getElementById('label-' + c.id); - if(!label) - return; - let l = c.label; - if(l) { - label.textContent = l; - label.classList.remove('label-fallback'); - } else if(fallback) { - label.textContent = fallback; - label.classList.add('label-fallback'); - } else { - label.textContent = ''; - label.classList.remove('label-fallback'); - } -} - -function resizePeers() { - // Window resize can call this method too early - if (!serverConnection) - return; - let count = - Object.keys(serverConnection.up).length + - Object.keys(serverConnection.down).length; - let peers = document.getElementById('peers'); - let columns = Math.ceil(Math.sqrt(count)); - if (!count) - // No video, nothing to resize. - return; - let container = document.getElementById("video-container"); - // Peers div has total padding of 40px, we remove 40 on offsetHeight - // Grid has row-gap of 5px - let rows = Math.ceil(count / columns); - let margins = (rows - 1) * 5 + 40; - - if (count <= 2 && container.offsetHeight > container.offsetWidth) { - peers.style['grid-template-columns'] = "repeat(1, 1fr)"; - rows = count; - } else { - peers.style['grid-template-columns'] = `repeat(${columns}, 1fr)`; - } - if (count === 1) - return; - let max_video_height = (peers.offsetHeight - margins) / rows; - let media_list = peers.querySelectorAll(".media"); - for(let i = 0; i < media_list.length; i++) { - let media = media_list[i]; - if(!(media instanceof HTMLMediaElement)) { - console.warn('Unexpected media'); - continue; - } - media.style['max-height'] = max_video_height + "px"; - } -} - -/** @type{Object} */ -let users = {}; - -/** - * Lexicographic order, with case differences secondary. - * @param{string} a - * @param{string} b - */ -function stringCompare(a, b) { - let la = a.toLowerCase() - let lb = b.toLowerCase() - if(la < lb) - return -1; - else if(la > lb) - return +1; - else if(a < b) - return -1; - else if(a > b) - return +1; - return 0 -} - -/** - * @param {string} id - * @param {string} name - */ -function addUser(id, name) { - if(!name) - name = null; - if(id in users) - throw new Error('Duplicate user id'); - users[id] = name; - - let div = document.getElementById('users'); - let user = document.createElement('div'); - user.id = 'user-' + id; - user.classList.add("user-p"); - user.textContent = name ? name : '(anon)'; - - if(name) { - let us = div.children; - for(let i = 0; i < us.length; i++) { - let child = us[i]; - let childname = users[child.id.slice('user-'.length)] || null; - if(!childname || stringCompare(childname, name) > 0) { - div.insertBefore(user, child); - return; - } - } - } - div.appendChild(user); -} - -/** - * @param {string} id - * @param {string} name - */ -function delUser(id, name) { - if(!name) - name = null; - if(!(id in users)) - throw new Error('Unknown user id'); - if(users[id] !== name) - throw new Error('Inconsistent user name'); - delete(users[id]); - let div = document.getElementById('users'); - let user = document.getElementById('user-' + id); - div.removeChild(user); -} - -function resetUsers() { - for(let id in users) - delUser(id, users[id]); -} - -/** - * @param {string} id - * @param {string} kind - * @param {string} name - */ -function gotUser(id, kind, name) { - switch(kind) { - case 'add': - addUser(id, name); - break; - case 'delete': - delUser(id, name); - break; - default: - console.warn('Unknown user kind', kind); - break; - } -} - -function displayUsername() { - let userpass = getUserPass(); - let text = ''; - if(userpass && userpass.username) - document.getElementById('userspan').textContent = userpass.username; - if(serverConnection.permissions.op && serverConnection.permissions.present) - text = '(op, presenter)'; - else if(serverConnection.permissions.op) - text = 'operator'; - else if(serverConnection.permissions.present) - text = 'presenter'; - document.getElementById('permspan').textContent = text; -} - -let presentRequested = null; - -/** - * @this {ServerConnection} - * @param {string} group - * @param {Object} perms - */ -async function gotJoined(kind, group, perms, message) { - let present = presentRequested; - presentRequested = null; - - switch(kind) { - case 'fail': - displayError('The server said: ' + message); - this.close(); - return; - case 'redirect': - this.close(); - document.location = message; - return; - case 'leave': - this.close(); - return; - case 'join': - case 'change': - displayUsername(); - setButtonsVisibility(); - if(kind === 'change') - return; - break; - default: - displayError('Unknown join message'); - this.close(); - return; - } - - let input = /** @type{HTMLTextAreaElement} */ - (document.getElementById('input')); - input.placeholder = 'Type /help for help'; - setTimeout(() => {input.placeholder = '';}, 8000); - - this.request(getSettings().request); - - if(serverConnection.permissions.present && !findUpMedia('local')) { - if(present) { - if(present === 'mike') - updateSettings({video: ''}); - else if(present === 'both') - delSetting('video'); - reflectSettings(); - - let button = getButtonElement('presentbutton'); - button.disabled = true; - try { - await addLocalMedia(); - } finally { - button.disabled = false; - } - } else { - displayMessage( - "Press Ready to enable your camera or microphone" - ); - } - } -} - -const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\\+~#&()=?]+[-a-zA-Z0-9@:%/_\\+~#&()=]/g; - -/** - * @param {string} line - * @returns {Array.} - */ -function formatLine(line) { - let r = new RegExp(urlRegexp); - let result = []; - let pos = 0; - while(true) { - let m = r.exec(line); - if(!m) - break; - result.push(document.createTextNode(line.slice(pos, m.index))); - let a = document.createElement('a'); - a.href = m[0]; - a.textContent = m[0]; - a.target = '_blank'; - a.rel = 'noreferrer noopener'; - result.push(a); - pos = m.index + m[0].length; - } - result.push(document.createTextNode(line.slice(pos))); - return result; -} - -/** - * @param {string[]} lines - * @returns {HTMLElement} - */ -function formatLines(lines) { - let elts = []; - if(lines.length > 0) - elts = formatLine(lines[0]); - for(let i = 1; i < lines.length; i++) { - elts.push(document.createElement('br')); - elts = elts.concat(formatLine(lines[i])); - } - let elt = document.createElement('p'); - elts.forEach(e => elt.appendChild(e)); - return elt; -} - -/** - * @param {number} time - * @returns {string} - */ -function formatTime(time) { - let delta = Date.now() - time; - let date = new Date(time); - let m = date.getMinutes(); - if(delta > -30000) - return date.getHours() + ':' + ((m < 10) ? '0' : '') + m; - return date.toLocaleString(); -} - -/** - * @typedef {Object} lastMessage - * @property {string} [nick] - * @property {string} [peerId] - * @property {string} [dest] - * @property {number} [time] - */ - -/** @type {lastMessage} */ -let lastMessage = {}; - -/** - * @param {string} peerId - * @param {string} nick - * @param {number} time - * @param {string} kind - * @param {string} message - */ -function addToChatbox(peerId, dest, nick, time, privileged, kind, message) { - let userpass = getUserPass(); - let row = document.createElement('div'); - row.classList.add('message-row'); - let container = document.createElement('div'); - container.classList.add('message'); - row.appendChild(container); - let footer = document.createElement('p'); - footer.classList.add('message-footer'); - if(!peerId) - container.classList.add('message-system'); - if(userpass.username === nick) - container.classList.add('message-sender'); - if(dest) - container.classList.add('message-private'); - - if(kind !== 'me') { - let p = formatLines(message.split('\n')); - let doHeader = true; - if(!peerId && !dest && !nick) { - doHeader = false; - } else if(lastMessage.nick !== (nick || null) || - lastMessage.peerId !== peerId || - lastMessage.dest !== (dest || null) || - !time || !lastMessage.time) { - doHeader = true; - } else { - let delta = time - lastMessage.time; - doHeader = delta < 0 || delta > 60000; - } - - if(doHeader) { - let header = document.createElement('p'); - if(peerId || nick || dest) { - let user = document.createElement('span'); - user.textContent = dest ? - `${nick||'(anon)'} \u2192 ${users[dest]||'(anon)'}` : - (nick || '(anon)'); - user.classList.add('message-user'); - header.appendChild(user); - } - header.classList.add('message-header'); - container.appendChild(header); - if(time) { - let tm = document.createElement('span'); - tm.textContent = formatTime(time); - tm.classList.add('message-time'); - header.appendChild(tm); - } - } - - p.classList.add('message-content'); - container.appendChild(p); - lastMessage.nick = (nick || null); - lastMessage.peerId = peerId; - lastMessage.dest = (dest || null); - lastMessage.time = (time || null); - container.appendChild(footer); - } else { - let asterisk = document.createElement('span'); - asterisk.textContent = '*'; - asterisk.classList.add('message-me-asterisk'); - let user = document.createElement('span'); - user.textContent = nick || '(anon)'; - user.classList.add('message-me-user'); - let content = document.createElement('span'); - formatLine(message).forEach(elt => { - content.appendChild(elt); - }); - content.classList.add('message-me-content'); - container.appendChild(asterisk); - container.appendChild(user); - container.appendChild(content); - container.classList.add('message-me'); - lastMessage = {}; - } - - let box = document.getElementById('box'); - box.appendChild(row); - if(box.scrollHeight > box.clientHeight) { - box.scrollTop = box.scrollHeight - box.clientHeight; - } - - return message; -} - -function clearChat() { - lastMessage = {}; - document.getElementById('box').textContent = ''; -} - -/** - * A command known to the command-line parser. - * - * @typedef {Object} command - * @property {string} [parameters] - * - A user-readable list of parameters. - * @property {string} [description] - * - A user-readable description, null if undocumented. - * @property {() => string} [predicate] - * - Returns null if the command is available. - * @property {(c: string, r: string) => void} f - */ - -/** - * The set of commands known to the command-line parser. - * - * @type {Object.} - */ -let commands = {} - -function operatorPredicate() { - if(serverConnection && serverConnection.permissions && - serverConnection.permissions.op) - return null; - return 'You are not an operator'; -} - -function recordingPredicate() { - if(serverConnection && serverConnection.permissions && - serverConnection.permissions.record) - return null; - return 'You are not allowed to record'; -} - -commands.help = { - description: 'display this help', - f: (c, r) => { - /** @type {string[]} */ - let cs = []; - for(let cmd in commands) { - let c = commands[cmd]; - if(!c.description) - continue; - if(c.predicate && c.predicate()) - continue; - cs.push(`/${cmd}${c.parameters?' ' + c.parameters:''}: ${c.description}`); - } - cs.sort(); - let s = ''; - for(let i = 0; i < cs.length; i++) - s = s + cs[i] + '\n'; - addToChatbox(null, null, null, Date.now(), false, null, s); - } -}; - -commands.me = { - f: (c, r) => { - // handled as a special case - throw new Error("this shouldn't happen"); - } -}; - -commands.set = { - f: (c, r) => { - if(!r) { - let settings = getSettings(); - let s = ""; - for(let key in settings) - s = s + `${key}: ${JSON.stringify(settings[key])}\n`; - addToChatbox(null, null, null, Date.now(), false, null, s); - return; - } - let p = parseCommand(r); - let value; - if(p[1]) { - value = JSON.parse(p[1]) - } else { - value = true; - } - updateSetting(p[0], value); - reflectSettings(); - } -}; - -commands.unset = { - f: (c, r) => { - delSetting(r.trim()); - return; - } -}; - -commands.leave = { - description: "leave group", - f: (c, r) => { - if(!serverConnection) - throw new Error('Not connected'); - serverConnection.close(); - } -}; - -commands.clear = { - predicate: operatorPredicate, - description: 'clear the chat history', - f: (c, r) => { - serverConnection.groupAction(getUsername(), 'clearchat'); - } -}; - -commands.lock = { - predicate: operatorPredicate, - description: 'lock this group', - parameters: '[message]', - f: (c, r) => { - serverConnection.groupAction(getUsername(), 'lock', r); - } -}; - -commands.unlock = { - predicate: operatorPredicate, - description: 'unlock this group, revert the effect of /lock', - f: (c, r) => { - serverConnection.groupAction(getUsername(), 'unlock'); - } -}; - -commands.record = { - predicate: recordingPredicate, - description: 'start recording', - f: (c, r) => { - serverConnection.groupAction(getUsername(), 'record'); - } -}; - -commands.unrecord = { - predicate: recordingPredicate, - description: 'stop recording', - f: (c, r) => { - serverConnection.groupAction(getUsername(), 'unrecord'); - } -}; - -commands.subgroups = { - predicate: operatorPredicate, - description: 'list subgroups', - f: (c, r) => { - serverConnection.groupAction(getUsername(), 'subgroups'); - } -}; - -commands.renegotiate = { - description: 'renegotiate media streams', - f: (c, r) => { - for(let id in serverConnection.up) - serverConnection.up[id].restartIce(); - for(let id in serverConnection.down) - serverConnection.down[id].restartIce(); - } -}; - -/** - * parseCommand splits a string into two space-separated parts. The first - * part may be quoted and may include backslash escapes. - * - * @param {string} line - * @returns {string[]} - */ -function parseCommand(line) { - let i = 0; - while(i < line.length && line[i] === ' ') - i++; - let start = ' '; - if(i < line.length && line[i] === '"' || line[i] === "'") { - start = line[i]; - i++; - } - let first = ""; - while(i < line.length) { - if(line[i] === start) { - if(start !== ' ') - i++; - break; - } - if(line[i] === '\\' && i < line.length - 1) - i++; - first = first + line[i]; - i++; - } - - while(i < line.length && line[i] === ' ') - i++; - return [first, line.slice(i)]; -} - -/** - * @param {string} user - */ -function findUserId(user) { - if(user in users) - return user; - - for(let id in users) { - if(users[id] === user) - return id; - } - return null; -} - -commands.msg = { - parameters: 'user message', - description: 'send a private message', - f: (c, r) => { - let p = parseCommand(r); - if(!p[0]) - throw new Error('/msg requires parameters'); - let id = findUserId(p[0]); - if(!id) - throw new Error(`Unknown user ${p[0]}`); - let username = getUsername(); - serverConnection.chat(username, '', id, p[1]); - addToChatbox(serverConnection.id, id, username, - Date.now(), false, '', p[1]); - } -}; - -/** - @param {string} c - @param {string} r -*/ -function userCommand(c, r) { - let p = parseCommand(r); - if(!p[0]) - throw new Error(`/${c} requires parameters`); - let id = findUserId(p[0]); - if(!id) - throw new Error(`Unknown user ${p[0]}`); - serverConnection.userAction(getUsername(), c, id, p[1]); -} - -function userMessage(c, r) { - let p = parseCommand(r); - if(!p[0]) - throw new Error(`/${c} requires parameters`); - let id = findUserId(p[0]); - if(!id) - throw new Error(`Unknown user ${p[0]}`); - serverConnection.userMessage(getUsername(), c, id, p[1]); -} - -commands.kick = { - parameters: 'user [message]', - description: 'kick out a user', - predicate: operatorPredicate, - f: userCommand, -}; - -commands.op = { - parameters: 'user', - description: 'give operator status', - predicate: operatorPredicate, - f: userCommand, -}; - -commands.unop = { - parameters: 'user', - description: 'revoke operator status', - predicate: operatorPredicate, - f: userCommand, -}; - -commands.present = { - parameters: 'user', - description: 'give user the right to present', - predicate: operatorPredicate, - f: userCommand, -}; - -commands.unpresent = { - parameters: 'user', - description: 'revoke the right to present', - predicate: operatorPredicate, - f: userCommand, -}; - -commands.mute = { - parameters: 'user', - description: 'mute a remote user', - predicate: operatorPredicate, - f: userMessage, -}; - -commands.warn = { - parameters: 'user message', - description: 'send a warning to a user', - predicate: operatorPredicate, - f: (c, r) => { - userMessage('warning', r); - }, -}; - -commands.wall = { - parameters: 'message', - description: 'send a warning to all users', - predicate: operatorPredicate, - f: (c, r) => { - if(!r) - throw new Error('empty message'); - serverConnection.userMessage(getUsername(), 'warning', '', r); - }, -}; - -function handleInput() { - let input = /** @type {HTMLTextAreaElement} */ - (document.getElementById('input')); - let data = input.value; - input.value = ''; - - let message, me; - - if(data === '') - return; - - if(data[0] === '/') { - if(data.length > 1 && data[1] === '/') { - message = data.slice(1); - me = false; - } else { - let cmd, rest; - let space = data.indexOf(' '); - if(space < 0) { - cmd = data.slice(1); - rest = ''; - } else { - cmd = data.slice(1, space); - rest = data.slice(space + 1); - } - - if(cmd === 'me') { - message = rest; - me = true; - } else { - let c = commands[cmd]; - if(!c) { - displayError(`Uknown command /${cmd}, type /help for help`); - return; - } - if(c.predicate) { - let s = c.predicate(); - if(s) { - displayError(s); - return; - } - } - try { - c.f(cmd, rest); - } catch(e) { - displayError(e); - } - return; - } - } - } else { - message = data; - me = false; - } - - if(!serverConnection || !serverConnection.socket) { - displayError("Not connected."); - return; - } - - let username = getUsername(); - try { - serverConnection.chat(username, me ? 'me' : '', '', message); - } catch(e) { - console.error(e); - displayError(e); - } -} - -document.getElementById('inputform').onsubmit = function(e) { - e.preventDefault(); - handleInput(); -}; - -document.getElementById('input').onkeypress = function(e) { - if(e.key === 'Enter' && !e.ctrlKey && !e.shiftKey && !e.metaKey) { - e.preventDefault(); - handleInput(); - } -}; - -function chatResizer(e) { - e.preventDefault(); - let full_width = document.getElementById("mainrow").offsetWidth; - let left = document.getElementById("left"); - let right = document.getElementById("right"); - - let start_x = e.clientX; - let start_width = left.offsetWidth; - - function start_drag(e) { - let left_width = (start_width + e.clientX - start_x) * 100 / full_width; - // set min chat width to 300px - let min_left_width = 300 * 100 / full_width; - if (left_width < min_left_width) { - return; - } - left.style.flex = left_width.toString(); - right.style.flex = (100 - left_width).toString(); - } - function stop_drag(e) { - document.documentElement.removeEventListener( - 'mousemove', start_drag, false, - ); - document.documentElement.removeEventListener( - 'mouseup', stop_drag, false, - ); - } - - document.documentElement.addEventListener( - 'mousemove', start_drag, false, - ); - document.documentElement.addEventListener( - 'mouseup', stop_drag, false, - ); -} - -document.getElementById('resizer').addEventListener('mousedown', chatResizer, false); - -/** - * @param {unknown} message - * @param {string} [level] - */ -function displayError(message, level) { - if(!level) - level = "error"; - - var background = 'linear-gradient(to right, #e20a0a, #df2d2d)'; - var position = 'center'; - var gravity = 'top'; - - switch(level) { - case "info": - background = 'linear-gradient(to right, #529518, #96c93d)'; - position = 'right'; - gravity = 'bottom'; - break; - case "warning": - background = "linear-gradient(to right, #bdc511, #c2cf01)"; - break; - } - - /** @ts-ignore */ - Toastify({ - text: message, - duration: 4000, - close: true, - position: position, - gravity: gravity, - backgroundColor: background, - className: level, - }).showToast(); -} - -/** - * @param {unknown} message - */ -function displayWarning(message) { - return displayError(message, "warning"); -} - -/** - * @param {unknown} message - */ -function displayMessage(message) { - return displayError(message, "info"); -} - -let connecting = false; - -document.getElementById('userform').onsubmit = async function(e) { - e.preventDefault(); - if(connecting) - return; - connecting = true; - try { - let username = getInputElement('username').value.trim(); - let password = getInputElement('password').value; - storeUserPass(username, password); - serverConnect(); - } finally { - connecting = false; - } - - if(getInputElement('presentboth').checked) - presentRequested = 'both'; - else if(getInputElement('presentmike').checked) - presentRequested = 'mike'; - else - presentRequested = null; - - getInputElement('presentoff').checked = true; -}; - -document.getElementById('disconnectbutton').onclick = function(e) { - serverConnection.close(); - closeNav(); -}; - -function openNav() { - document.getElementById("sidebarnav").style.width = "250px"; -} - -function closeNav() { - document.getElementById("sidebarnav").style.width = "0"; -} - -document.getElementById('sidebarCollapse').onclick = function(e) { - document.getElementById("left-sidebar").classList.toggle("active"); - document.getElementById("mainrow").classList.toggle("full-width-active"); -}; - -document.getElementById('openside').onclick = function(e) { - e.preventDefault(); - let sidewidth = document.getElementById("sidebarnav").style.width; - if (sidewidth !== "0px" && sidewidth !== "") { - closeNav(); - return; - } else { - openNav(); - } -}; - - -document.getElementById('clodeside').onclick = function(e) { - e.preventDefault(); - closeNav(); -}; - -document.getElementById('collapse-video').onclick = function(e) { - e.preventDefault(); - if(!(this instanceof HTMLElement)) - throw new Error('Unexpected type for this'); - let width = window.innerWidth; - let left = document.getElementById("left"); - if (left.style.display === "" || left.style.display === "none") { - //left chat is hidden, we show the chat and hide collapse button - left.style.display = "block"; - this.style.display = ""; - } - if (width <= 768) { - // fixed div for small screen - this.style.display = ""; - hideVideo(true); - document.getElementById('switch-video').style.display = "block"; - } -}; - -document.getElementById('switch-video').onclick = function(e) { - e.preventDefault(); - if(!(this instanceof HTMLElement)) - throw new Error('Unexpected type for this'); - showVideo(); - this.style.display = ""; - document.getElementById('collapse-video').style.display = "block"; -}; - -document.getElementById('close-chat').onclick = function(e) { - e.preventDefault(); - let left = document.getElementById("left"); - left.style.display = "none"; - document.getElementById('collapse-video').style.display = "block"; -}; - -async function serverConnect() { - if(serverConnection && serverConnection.socket) - serverConnection.close(); - serverConnection = new ServerConnection(); - serverConnection.onconnected = gotConnected; - serverConnection.onclose = gotClose; - serverConnection.ondownstream = gotDownStream; - serverConnection.onuser = gotUser; - serverConnection.onjoined = gotJoined; - serverConnection.onchat = addToChatbox; - serverConnection.onclearchat = clearChat; - serverConnection.onusermessage = function(id, dest, username, time, privileged, kind, message) { - switch(kind) { - case 'error': - case 'warning': - case 'info': - let from = id ? (username || 'Anonymous') : 'The Server'; - if(privileged) - displayError(`${from} said: ${message}`, kind); - else - console.error(`Got unprivileged message of kind ${kind}`); - break; - case 'mute': - console.log(id, dest, username); - if(privileged) { - setLocalMute(true, true); - let by = username ? ' by ' + username : ''; - displayWarning(`You have been muted${by}`); - } else { - console.error(`Got unprivileged message of kind ${kind}`); - } - break; - default: - console.warn(`Got unknown user message ${kind}`); - break; - } - }; - let url = `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`; - try { - await serverConnection.connect(url); - } catch(e) { - console.error(e); - displayError(e.message ? e.message : "Couldn't connect to " + url); - } -} - -function start() { - group = decodeURIComponent(location.pathname.replace(/^\/[a-z]*\//, '')); - let title = group.charAt(0).toUpperCase() + group.slice(1); - if(group !== '') { - document.title = title; - document.getElementById('title').textContent = title; - } - - setMediaChoices(false).then(e => reflectSettings()); - - fillLogin(); - document.getElementById("login-container").classList.remove('invisible'); -} - -start(); diff --git a/sources/static/index.html b/sources/static/index.html deleted file mode 100755 index 83e32ee..0000000 --- a/sources/static/index.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - Galène - - - - - - - - - - - - - -
-

Galène

- -
- - -
-
- -
-

Public groups

- -
-
-
- - - - - - - - diff --git a/sources/static/mainpage.css b/sources/static/mainpage.css deleted file mode 100755 index 14b891c..0000000 --- a/sources/static/mainpage.css +++ /dev/null @@ -1,40 +0,0 @@ -.groups { -} - -.nogroups { - display: none; -} - -.navbar-brand { - margin-bottom: 5rem; -} - -.home { - height: calc(100vh - 50px); - padding: 1.875rem; -} - -#public-groups-table tr a{ - margin-left: 0.9375rem; - font-weight: 700; -} - -a { - text-decoration: none; - color: #0058e4; -} - -a:hover { - color: #0a429c; -} - -label { - display: block; -} - -@media only screen and (max-device-width: 768px) { - .home { - padding: 0.625rem; - } - -} diff --git a/sources/static/mainpage.js b/sources/static/mainpage.js deleted file mode 100755 index 1f80c40..0000000 --- a/sources/static/mainpage.js +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) 2020 by Juliusz Chroboczek. - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -'use strict'; - -document.getElementById('groupform').onsubmit = function(e) { - e.preventDefault(); - let group = document.getElementById('group').value.trim(); - if(group !== '') - location.href = '/group/' + group; -}; - -async function listPublicGroups() { - let div = document.getElementById('public-groups'); - let table = document.getElementById('public-groups-table'); - - let l; - try { - l = await (await fetch('/public-groups.json')).json(); - } catch(e) { - console.error(e); - l = []; - } - - if (l.length === 0) { - table.textContent = '(No groups found.)'; - div.classList.remove('groups'); - div.classList.add('nogroups'); - return; - } - - div.classList.remove('nogroups'); - div.classList.add('groups'); - - for(let i = 0; i < l.length; i++) { - let group = l[i]; - let tr = document.createElement('tr'); - let td = document.createElement('td'); - let a = document.createElement('a'); - a.textContent = group.name; - a.href = '/group/' + encodeURIComponent(group.name); - td.appendChild(a); - tr.appendChild(td); - let td2 = document.createElement('td'); - if(group.description) - td2.textContent = group.description; - tr.appendChild(td2); - let td3 = document.createElement('td'); - td3.textContent = `(${group.clientCount} clients)`; - tr.appendChild(td3); - table.appendChild(tr); - } -} - - -listPublicGroups(); diff --git a/sources/static/protocol.js b/sources/static/protocol.js deleted file mode 100755 index b93667e..0000000 --- a/sources/static/protocol.js +++ /dev/null @@ -1,1173 +0,0 @@ -// Copyright (c) 2020 by Juliusz Chroboczek. - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -'use strict'; - -/** - * toHex formats an array as a hexadecimal string. - * @param {number[]|Uint8Array} array - the array to format - * @returns {string} - the hexadecimal representation of array - */ -function toHex(array) { - let a = new Uint8Array(array); - function hex(x) { - let h = x.toString(16); - if(h.length < 2) - h = '0' + h; - return h; - } - return a.reduce((x, y) => x + hex(y), ''); -} - -/** randomid returns a random string of 32 hex digits (16 bytes). - * @returns {string} - */ -function randomid() { - let a = new Uint8Array(16); - crypto.getRandomValues(a); - return toHex(a); -} - -/** - * ServerConnection encapsulates a websocket connection to the server and - * all the associated streams. - * @constructor - */ -function ServerConnection() { - /** - * The id of this connection. - * - * @type {string} - * @const - */ - this.id = randomid(); - /** - * The group that we have joined, or nil if we haven't joined yet. - * - * @type {string} - */ - this.group = null; - /** - * The underlying websocket. - * - * @type {WebSocket} - */ - this.socket = null; - /** - * The set of all up streams, indexed by their id. - * - * @type {Object} - */ - this.up = {}; - /** - * The set of all down streams, indexed by their id. - * - * @type {Object} - */ - this.down = {}; - /** - * The ICE configuration used by all associated streams. - * - * @type {RTCIceServer[]} - */ - this.iceServers = null; - /** - * The permissions granted to this connection. - * - * @type {Object} - */ - this.permissions = {}; - /** - * userdata is a convenient place to attach data to a ServerConnection. - * It is not used by the library. - * - * @type{Object} - */ - this.userdata = {}; - - /* Callbacks */ - - /** - * onconnected is called when the connection has been established - * - * @type{(this: ServerConnection) => void} - */ - this.onconnected = null; - /** - * onclose is called when the connection is closed - * - * @type{(this: ServerConnection, code: number, reason: string) => void} - */ - this.onclose = null; - /** - * onuser is called whenever a user is added or removed from the group - * - * @type{(this: ServerConnection, id: string, kind: string, username: string) => void} - */ - this.onuser = null; - /** - * onjoined is called whenever we join or leave a group or whenever the - * permissions we have in a group change. - * - * kind is one of 'join', 'fail', 'change' or 'leave'. - * - * @type{(this: ServerConnection, kind: string, group: string, permissions: Object, message: string) => void} - */ - this.onjoined = null; - /** - * ondownstream is called whenever a new down stream is added. It - * should set up the stream's callbacks; actually setting up the UI - * should be done in the stream's ondowntrack callback. - * - * @type{(this: ServerConnection, stream: Stream) => void} - */ - this.ondownstream = null; - /** - * onchat is called whenever a new chat message is received. - * - * @type {(this: ServerConnection, id: string, dest: string, username: string, time: number, privileged: boolean, kind: string, message: string) => void} - */ - this.onchat = null; - /** - * onusermessage is called when an application-specific message is - * received. Id is null when the message originated at the server, - * a user-id otherwise. - * - * 'kind' is typically one of 'error', 'warning', 'info' or 'mute'. If - * 'id' is non-null, 'privileged' indicates whether the message was - * sent by an operator. - * - * @type {(this: ServerConnection, id: string, dest: string, username: string, time: number, privileged: boolean, kind: string, message: string) => void} - */ - this.onusermessage = null; - /** - * onclearchat is called whenever the server requests that the chat - * be cleared. - * - * @type{(this: ServerConnection) => void} - */ - this.onclearchat = null; -} - -/** - * @typedef {Object} message - * @property {string} type - * @property {string} [kind] - * @property {string} [id] - * @property {string} [dest] - * @property {string} [username] - * @property {string} [password] - * @property {boolean} [privileged] - * @property {Object} [permissions] - * @property {string} [group] - * @property {string} [value] - * @property {RTCSessionDescriptionInit} [offer] - * @property {RTCSessionDescriptionInit} [answer] - * @property {RTCIceCandidate} [candidate] - * @property {Object} [labels] - * @property {Object} [request] - */ - -/** - * close forcibly closes a server connection. The onclose callback will - * be called when the connection is effectively closed. - */ -ServerConnection.prototype.close = function() { - this.socket && this.socket.close(1000, 'Close requested by client'); - this.socket = null; -} - -/** - * send sends a message to the server. - * @param {message} m - the message to send. - */ -ServerConnection.prototype.send = function(m) { - if(!this.socket || this.socket.readyState !== this.socket.OPEN) { - // send on a closed socket doesn't throw - throw(new Error('Connection is not open')); - } - return this.socket.send(JSON.stringify(m)); -} - -/** - * getIceServers fetches an ICE configuration from the server and - * populates the iceServers field of a ServerConnection. It is called - * lazily by connect. - * - * @returns {Promise} - * @function - */ -ServerConnection.prototype.getIceServers = async function() { - let r = await fetch('/ice-servers.json'); - if(!r.ok) - throw new Error("Couldn't fetch ICE servers: " + - r.status + ' ' + r.statusText); - let servers = await r.json(); - if(!(servers instanceof Array)) - throw new Error("couldn't parse ICE servers"); - this.iceServers = servers; - return servers; -} - -/** - * connect connects to the server. - * - * @param {string} url - The URL to connect to. - * @returns {Promise} - * @function - */ -ServerConnection.prototype.connect = async function(url) { - let sc = this; - if(sc.socket) { - sc.socket.close(1000, 'Reconnecting'); - sc.socket = null; - } - - if(!sc.iceServers) { - try { - await sc.getIceServers(); - } catch(e) { - console.warn(e); - } - } - - sc.socket = new WebSocket(url); - - return await new Promise((resolve, reject) => { - this.socket.onerror = function(e) { - reject(e); - }; - this.socket.onopen = function(e) { - sc.send({ - type: 'handshake', - id: sc.id, - }); - if(sc.onconnected) - sc.onconnected.call(sc); - resolve(sc); - }; - this.socket.onclose = function(e) { - sc.permissions = {}; - for(let id in sc.down) { - let c = sc.down[id]; - delete(sc.down[id]); - c.close(); - if(c.onclose) - c.onclose.call(c); - } - if(sc.group && sc.onjoined) - sc.onjoined.call(sc, 'leave', sc.group, {}, ''); - sc.group = null; - if(sc.onclose) - sc.onclose.call(sc, e.code, e.reason); - reject(new Error('websocket close ' + e.code + ' ' + e.reason)); - }; - this.socket.onmessage = function(e) { - let m = JSON.parse(e.data); - switch(m.type) { - case 'offer': - sc.gotOffer(m.id, m.labels, m.offer, m.kind === 'renegotiate'); - break; - case 'answer': - sc.gotAnswer(m.id, m.answer); - break; - case 'renegotiate': - sc.gotRenegotiate(m.id) - break; - case 'close': - sc.gotClose(m.id); - break; - case 'abort': - sc.gotAbort(m.id); - break; - case 'ice': - sc.gotRemoteIce(m.id, m.candidate); - break; - case 'label': - sc.gotLabel(m.id, m.value); - break; - case 'joined': - if(sc.group) { - if(m.group !== sc.group) { - throw new Error('Joined multiple groups'); - } - } else { - sc.group = m.group; - } - sc.permissions = m.permissions; - if(sc.onjoined) - sc.onjoined.call(sc, m.kind, m.group, - m.permissions || {}, - m.value || null); - break; - case 'user': - if(sc.onuser) - sc.onuser.call(sc, m.id, m.kind, m.username); - break; - case 'chat': - if(sc.onchat) - sc.onchat.call( - sc, m.id, m.dest, m.username, m.time, - m.privileged, m.kind, m.value, - ); - break; - case 'usermessage': - if(sc.onusermessage) - sc.onusermessage.call( - sc, m.id, m.dest, m.username, m.time, - m.privileged, m.kind, m.value, - ); - break; - case 'clearchat': - if(sc.onclearchat) - sc.onclearchat.call(sc); - break; - case 'ping': - sc.send({ - type: 'pong', - }); - break; - case 'pong': - /* nothing */ - break; - default: - console.warn('Unexpected server message', m.type); - return; - } - }; - }); -} - -/** - * join requests to join a group. The onjoined callback will be called - * when we've effectively joined. - * - * @param {string} group - The name of the group to join. - * @param {string} username - the username to join as. - * @param {string} password - the password. - */ -ServerConnection.prototype.join = function(group, username, password) { - this.send({ - type: 'join', - kind: 'join', - group: group, - username: username, - password: password, - }); -} - -/** - * leave leaves a group. The onjoined callback will be called when we've - * effectively left. - * - * @param {string} group - The name of the group to join. - */ -ServerConnection.prototype.leave = function(group) { - this.send({ - type: 'join', - kind: 'leave', - group: group, - }); -} - -/** - * request sets the list of requested media types. - * - * @param {string} what - One of '', 'audio', 'screenshare' or 'everything'. - */ -ServerConnection.prototype.request = function(what) { - /** @type {Object} */ - let request = {}; - switch(what) { - case '': - request = {}; - break; - case 'audio': - request = {audio: true}; - break; - case 'screenshare': - request = {audio: true, screenshare: true}; - break; - case 'everything': - request = {audio: true, screenshare: true, video: true}; - break; - default: - console.error(`Unknown value ${what} in request`); - break; - } - - this.send({ - type: 'request', - request: request, - }); -}; - -/** - * newUpStream requests the creation of a new up stream. - * - * @param {string} [id] - The id of the stream to create. - * @returns {Stream} - */ -ServerConnection.prototype.newUpStream = function(id) { - let sc = this; - if(!id) { - id = randomid(); - if(sc.up[id]) - throw new Error('Eek!'); - } - let pc = new RTCPeerConnection({ - iceServers: sc.iceServers || [], - }); - if(!pc) - throw new Error("Couldn't create peer connection"); - if(sc.up[id]) { - sc.up[id].close(); - } - let c = new Stream(this, id, pc, true); - sc.up[id] = c; - - pc.onnegotiationneeded = async e => { - await c.negotiate(); - }; - - pc.onicecandidate = e => { - if(!e.candidate) - return; - c.gotLocalIce(e.candidate); - }; - - pc.oniceconnectionstatechange = e => { - if(c.onstatus) - c.onstatus.call(c, pc.iceConnectionState); - if(pc.iceConnectionState === 'failed') - c.restartIce(); - }; - - pc.ontrack = console.error; - - return c; -} - -/** - * chat sends a chat message to the server. The server will normally echo - * the message back to the client. - * - * @param {string} username - The sender's username. - * @param {string} kind - * - The kind of message, either '', 'me' or an application-specific type. - * @param {string} dest - The id to send the message to, empty for broadcast. - * @param {string} value - The text of the message. - */ -ServerConnection.prototype.chat = function(username, kind, dest, value) { - this.send({ - type: 'chat', - id: this.id, - dest: dest, - username: username, - kind: kind, - value: value, - }); -}; - -/** - * userAction sends a request to act on a user. - * - * @param {string} username - The sender's username. - * @param {string} kind - One of "op", "unop", "kick", "present", "unpresent". - * @param {string} dest - The id of the user to act upon. - * @param {string} [value] - An optional user-readable message. - */ -ServerConnection.prototype.userAction = function(username, kind, dest, value) { - this.send({ - type: 'useraction', - id: this.id, - dest: dest, - username: username, - kind: kind, - value: value, - }); -}; - -/** - * userMessage sends an application-specific message to a user. - * This is similar to a chat message, but is not saved in the chat history. - * - * @param {string} username - The sender's username. - * @param {string} kind - The kind of application-specific message. - * @param {string} dest - The id to send the message to, empty for broadcast. - * @param {string} [value] - An optional parameter. - */ -ServerConnection.prototype.userMessage = function(username, kind, dest, value) { - this.send({ - type: 'usermessage', - id: this.id, - dest: dest, - username: username, - kind: kind, - value: value, - }); -}; - -/** - * groupAction sends a request to act on the current group. - * - * @param {string} username - The sender's username. - * @param {string} kind - * - One of 'clearchat', 'lock', 'unlock', 'record' or 'unrecord'. - * @param {string} [message] - An optional user-readable message. - */ -ServerConnection.prototype.groupAction = function(username, kind, message) { - this.send({ - type: 'groupaction', - id: this.id, - kind: kind, - username: username, - value: message, - }); -}; - -/** - * Called when we receive an offer from the server. Don't call this. - * - * @param {string} id - * @param {Object} labels - * @param {RTCSessionDescriptionInit} offer - * @param {boolean} renegotiate - * @function - */ -ServerConnection.prototype.gotOffer = async function(id, labels, offer, renegotiate) { - let sc = this; - let c = sc.down[id]; - if(c && !renegotiate) { - // SDP is rather inflexible as to what can be renegotiated. - // Unless the server indicates that this is a renegotiation with - // all parameters unchanged, tear down the existing connection. - delete(sc.down[id]); - c.close(); - c = null; - } - - if(sc.up[id]) - throw new Error('Duplicate connection id'); - - if(!c) { - let pc = new RTCPeerConnection({ - iceServers: this.iceServers, - }); - c = new Stream(this, id, pc, false); - sc.down[id] = c; - - c.pc.onicecandidate = function(e) { - if(!e.candidate) - return; - c.gotLocalIce(e.candidate); - }; - - pc.oniceconnectionstatechange = e => { - if(c.onstatus) - c.onstatus.call(c, pc.iceConnectionState); - if(pc.iceConnectionState === 'failed') { - sc.send({ - type: 'renegotiate', - id: id, - }); - } - }; - - c.pc.ontrack = function(e) { - let label = e.transceiver && c.labelsByMid[e.transceiver.mid]; - if(label) { - c.labels[e.track.id] = label; - } else { - console.warn("Couldn't find label for track"); - } - if(c.stream !== e.streams[0]) { - c.stream = e.streams[0]; - let label = - e.transceiver && c.labelsByMid[e.transceiver.mid]; - c.labels[e.track.id] = label; - if(c.ondowntrack) { - c.ondowntrack.call( - c, e.track, e.transceiver, label, e.streams[0], - ); - } - if(c.onlabel) { - c.onlabel.call(c, label); - } - } - }; - } - - c.labelsByMid = labels; - - if(sc.ondownstream) - sc.ondownstream.call(sc, c); - - await c.pc.setRemoteDescription(offer); - await c.flushRemoteIceCandidates() - let answer = await c.pc.createAnswer(); - if(!answer) - throw new Error("Didn't create answer"); - await c.pc.setLocalDescription(answer); - this.send({ - type: 'answer', - id: id, - answer: answer, - }); - c.localDescriptionSent = true; - c.flushLocalIceCandidates(); - if(c.onnegotiationcompleted) - c.onnegotiationcompleted.call(c); -}; - -/** - * Called when we receive a stream label from the server. Don't call this. - * - * @param {string} id - * @param {string} label - */ -ServerConnection.prototype.gotLabel = function(id, label) { - let c = this.down[id]; - if(!c) - throw new Error('Got label for unknown id'); - - c.label = label; - if(c.onlabel) - c.onlabel.call(c, label); -}; - -/** - * Called when we receive an answer from the server. Don't call this. - * - * @param {string} id - * @param {RTCSessionDescriptionInit} answer - * @function - */ -ServerConnection.prototype.gotAnswer = async function(id, answer) { - let c = this.up[id]; - if(!c) - throw new Error('unknown up stream'); - try { - await c.pc.setRemoteDescription(answer); - } catch(e) { - if(c.onerror) - c.onerror.call(c, e); - return; - } - await c.flushRemoteIceCandidates(); - if(c.onnegotiationcompleted) - c.onnegotiationcompleted.call(c); -}; - -/** - * Called when we receive a renegotiation request from the server. Don't - * call this. - * - * @param {string} id - * @function - */ -ServerConnection.prototype.gotRenegotiate = async function(id) { - let c = this.up[id]; - if(!c) - throw new Error('unknown up stream'); - c.restartIce(); -}; - -/** - * Called when we receive a close request from the server. Don't call this. - * - * @param {string} id - */ -ServerConnection.prototype.gotClose = function(id) { - let c = this.down[id]; - if(!c) - throw new Error('unknown down stream'); - delete(this.down[id]); - c.close(); - if(c.onclose) - c.onclose.call(c); -}; - -/** - * Called when we receive an abort message from the server. Don't call this. - * - * @param {string} id - */ -ServerConnection.prototype.gotAbort = function(id) { - let c = this.up[id]; - if(!c) - throw new Error('unknown up stream'); - if(c.onabort) - c.onabort.call(c); -}; - -/** - * Called when we receive an ICE candidate from the server. Don't call this. - * - * @param {string} id - * @param {RTCIceCandidate} candidate - * @function - */ -ServerConnection.prototype.gotRemoteIce = async function(id, candidate) { - let c = this.up[id]; - if(!c) - c = this.down[id]; - if(!c) - throw new Error('unknown stream'); - if(c.pc.remoteDescription) - await c.pc.addIceCandidate(candidate).catch(console.warn); - else - c.remoteIceCandidates.push(candidate); -}; - -/** - * Stream encapsulates a MediaStream, a set of tracks. - * - * A stream is said to go "up" if it is from the client to the server, and - * "down" otherwise. - * - * @param {ServerConnection} sc - * @param {string} id - * @param {RTCPeerConnection} pc - * - * @constructor - */ -function Stream(sc, id, pc, up) { - /** - * The associated ServerConnection. - * - * @type {ServerConnection} - * @const - */ - this.sc = sc; - /** - * The id of this stream. - * - * @type {string} - * @const - */ - this.id = id; - /** - * Indicates whether the stream is in the client->server direction. - * - * @type {boolean} - * @const - */ - this.up = up - /** - * For up streams, one of "local" or "screenshare". - * - * @type {string} - */ - this.kind = null; - /** - * For down streams, a user-readable label. - * - * @type {string} - */ - this.label = null; - /** - * The associated RTCPeerConnectoin. This is null before the stream - * is connected, and may change over time. - * - * @type {RTCPeerConnection} - */ - this.pc = pc; - /** - * The associated MediaStream. This is null before the stream is - * connected, and may change over time. - * - * @type {MediaStream} - */ - this.stream = null; - /** - * Track labels, indexed by track id. - * - * @type {Object} - */ - this.labels = {}; - /** - * Track labels, indexed by mid. - * - * @type {Object} - */ - this.labelsByMid = {}; - /** - * Indicates whether we have already sent a local description. - * - * @type {boolean} - */ - this.localDescriptionSent = false; - /** - * Buffered local ICE candidates. This will be flushed by - * flushLocalIceCandidates after we send a local description. - * - * @type {RTCIceCandidate[]} - */ - this.localIceCandidates = []; - /** - * Buffered remote ICE candidates. This will be flushed by - * flushRemoteIceCandidates when we get a remote SDP description. - * - * @type {RTCIceCandidate[]} - */ - this.remoteIceCandidates = []; - /** - * The statistics last computed by the stats handler. This is - * a dictionary indexed by track id, with each value a dictionary of - * statistics. - * - * @type {Object} - */ - this.stats = {}; - /** - * The id of the periodic handler that computes statistics, as - * returned by setInterval. - * - * @type {number} - */ - this.statsHandler = null; - /** - * userdata is a convenient place to attach data to a Stream. - * It is not used by the library. - * - * @type{Object} - */ - this.userdata = {}; - - /* Callbacks */ - - /** - * onclose is called when the stream is closed. - * - * @type{(this: Stream) => void} - */ - this.onclose = null; - /** - * onerror is called whenever an error occurs. If the error is - * fatal, then onclose will be called afterwards. - * - * @type{(this: Stream, error: unknown) => void} - */ - this.onerror = null; - /** - * onnegotiationcompleted is called whenever negotiation or - * renegotiation has completed. - * - * @type{(this: Stream) => void} - */ - this.onnegotiationcompleted = null; - /** - * ondowntrack is called whenever a new track is added to a stream. - * If the stream parameter differs from its previous value, then it - * indicates that the old stream has been discarded. - * - * @type{(this: Stream, track: MediaStreamTrack, transceiver: RTCRtpTransceiver, label: string, stream: MediaStream) => void} - */ - this.ondowntrack = null; - /** - * onlabel is called whenever the server sets a new label for the stream. - * - * @type{(this: Stream, label: string) => void} - */ - this.onlabel = null; - /** - * onstatus is called whenever the status of the stream changes. - * - * @type{(this: Stream, status: string) => void} - */ - this.onstatus = null; - /** - * onabort is called when the server requested that an up stream be - * closed. It is the resposibility of the client to close the stream. - * - * @type{(this: Stream) => void} - */ - this.onabort = null; - /** - * onstats is called when we have new statistics about the connection - * - * @type{(this: Stream, stats: Object) => void} - */ - this.onstats = null; -} - -/** - * close closes a stream. - */ -Stream.prototype.close = function() { - let c = this; - if(c.statsHandler) { - clearInterval(c.statsHandler); - c.statsHandler = null; - } - - if(c.stream) { - c.stream.getTracks().forEach(t => { - try { - t.stop(); - } catch(e) { - } - }); - } - c.pc.close(); - - if(c.up && c.localDescriptionSent) { - try { - c.sc.send({ - type: 'close', - id: c.id, - }); - } catch(e) { - } - } - c.sc = null; -}; - -/** - * Called when we get a local ICE candidate. Don't call this. - * - * @param {RTCIceCandidate} candidate - * @function - */ -Stream.prototype.gotLocalIce = function(candidate) { - let c = this; - if(c.localDescriptionSent) - c.sc.send({type: 'ice', - id: c.id, - candidate: candidate, - }); - else - c.localIceCandidates.push(candidate); -} - -/** - * flushLocalIceCandidates flushes any buffered local ICE candidates. - * It is called when we send an offer. - * @function - */ -Stream.prototype.flushLocalIceCandidates = function () { - let c = this; - let candidates = c.localIceCandidates; - c.localIceCandidates = []; - candidates.forEach(candidate => { - try { - c.sc.send({type: 'ice', - id: c.id, - candidate: candidate, - }); - } catch(e) { - console.warn(e); - } - }); - c.localIceCandidates = []; -} - -/** - * flushRemoteIceCandidates flushes any buffered remote ICE candidates. It is - * called automatically when we get a remote description. - * @function - */ -Stream.prototype.flushRemoteIceCandidates = async function () { - let c = this; - let candidates = c.remoteIceCandidates; - c.remoteIceCandidates = []; - /** @type {Array.>} */ - let promises = []; - candidates.forEach(candidate => { - promises.push(c.pc.addIceCandidate(candidate).catch(console.warn)); - }); - return await Promise.all(promises); -}; - -/** - * negotiate negotiates or renegotiates an up stream. It is called - * automatically when required. If the client requires renegotiation, it - * is probably better to call restartIce which will cause negotiate to be - * called asynchronously. - * - * @function - * @param {boolean} [restartIce] - Whether to restart ICE. - */ -Stream.prototype.negotiate = async function (restartIce) { - let c = this; - if(!c.up) - throw new Error('not an up stream'); - - let options = {}; - if(restartIce) - options = {iceRestart: true}; - let offer = await c.pc.createOffer(options); - if(!offer) - throw(new Error("Didn't create offer")); - await c.pc.setLocalDescription(offer); - - // mids are not known until this point - c.pc.getTransceivers().forEach(t => { - if(t.sender && t.sender.track) { - let label = c.labels[t.sender.track.id]; - if(label) - c.labelsByMid[t.mid] = label; - else - console.warn("Couldn't find label for track"); - } - }); - - c.sc.send({ - type: 'offer', - kind: this.localDescriptionSent ? 'renegotiate' : '', - id: c.id, - labels: c.labelsByMid, - offer: offer, - }); - this.localDescriptionSent = true; - c.flushLocalIceCandidates(); -}; - -/** - * restartIce causes an ICE restart on a stream. For up streams, it is - * called automatically when ICE signals that the connection has failed, - * but may also be called by the application. For down streams, it - * requests that the server perform an ICE restart. In either case, - * it returns immediately, negotiation will happen asynchronously. - */ - -Stream.prototype.restartIce = function () { - let c = this; - if(!c.up) { - c.sc.send({ - type: 'renegotiate', - id: c.id, - }); - return; - } - - if('restartIce' in c.pc) { - try { - /** @ts-ignore */ - c.pc.restartIce(); - return; - } catch(e) { - console.warn(e); - } - } - - // negotiate is async, but this returns immediately. - c.negotiate(true); -}; - -/** - * updateStats is called periodically, if requested by setStatsInterval, - * in order to recompute stream statistics and invoke the onstats handler. - * - * @function - */ -Stream.prototype.updateStats = async function() { - let c = this; - let old = c.stats; - /** @type{Object} */ - let stats = {}; - - let transceivers = c.pc.getTransceivers(); - for(let i = 0; i < transceivers.length; i++) { - let t = transceivers[i]; - let stid = t.sender.track && t.sender.track.id; - let rtid = t.receiver.track && t.receiver.track.id; - - let report = null; - if(stid) { - try { - report = await t.sender.getStats(); - } catch(e) { - } - } - - if(report) { - for(let r of report.values()) { - if(stid && r.type === 'outbound-rtp') { - if(!('bytesSent' in r)) - continue; - if(!stats[stid]) - stats[stid] = {}; - stats[stid][r.type] = {}; - stats[stid][r.type].timestamp = r.timestamp; - stats[stid][r.type].bytesSent = r.bytesSent; - if(old[stid] && old[stid][r.type]) - stats[stid][r.type].rate = - ((r.bytesSent - old[stid][r.type].bytesSent) * 1000 / - (r.timestamp - old[stid][r.type].timestamp)) * 8; - } - } - } - - report = null; - if(rtid) { - try { - report = await t.receiver.getStats(); - } catch(e) { - console.error(e); - } - } - - if(report) { - for(let r of report.values()) { - if(rtid && r.type === 'track') { - if(!('totalAudioEnergy' in r)) - continue; - if(!stats[rtid]) - stats[rtid] = {}; - stats[rtid][r.type] = {}; - stats[rtid][r.type].timestamp = r.timestamp; - stats[rtid][r.type].totalAudioEnergy = r.totalAudioEnergy; - if(old[rtid] && old[rtid][r.type]) - stats[rtid][r.type].audioEnergy = - (r.totalAudioEnergy - old[rtid][r.type].totalAudioEnergy) * 1000 / - (r.timestamp - old[rtid][r.type].timestamp); - } - } - } - } - - c.stats = stats; - - if(c.onstats) - c.onstats.call(c, c.stats); -}; - -/** - * setStatsInterval sets the interval in milliseconds at which the onstats - * handler will be called. This is only useful for up streams. - * - * @param {number} ms - The interval in milliseconds. - */ -Stream.prototype.setStatsInterval = function(ms) { - let c = this; - if(c.statsHandler) { - clearInterval(c.statsHandler); - c.statsHandler = null; - } - - if(ms <= 0) - return; - - c.statsHandler = setInterval(() => { - c.updateStats(); - }, ms); -}; diff --git a/sources/static/scripts/toastify.js b/sources/static/scripts/toastify.js deleted file mode 100755 index 2e3bdb5..0000000 --- a/sources/static/scripts/toastify.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Minified by jsDelivr using Terser v3.14.1. - * Original file: /npm/toastify-js@1.9.1/src/toastify.js - * - * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files - */ -!function(t,o){"object"==typeof module&&module.exports?module.exports=o():t.Toastify=o()}(this,function(t){var o=function(t){return new o.lib.init(t)};function i(t,o){return o.offset[t]?isNaN(o.offset[t])?o.offset[t]:o.offset[t]+"px":"0px"}function s(t,o){return!(!t||"string"!=typeof o)&&!!(t.className&&t.className.trim().split(/\s+/gi).indexOf(o)>-1)}return o.lib=o.prototype={toastify:"1.9.1",constructor:o,init:function(t){t||(t={}),this.options={},this.toastElement=null,this.options.text=t.text||"Hi there!",this.options.node=t.node,this.options.duration=0===t.duration?0:t.duration||3e3,this.options.selector=t.selector,this.options.callback=t.callback||function(){},this.options.destination=t.destination,this.options.newWindow=t.newWindow||!1,this.options.close=t.close||!1,this.options.gravity="bottom"===t.gravity?"toastify-bottom":"toastify-top",this.options.positionLeft=t.positionLeft||!1,this.options.position=t.position||"",this.options.backgroundColor=t.backgroundColor,this.options.avatar=t.avatar||"",this.options.className=t.className||"",this.options.stopOnFocus=void 0===t.stopOnFocus||t.stopOnFocus,this.options.onClick=t.onClick;return this.options.offset=t.offset||{x:0,y:0},this},buildToast:function(){if(!this.options)throw"Toastify is not initialized";var t=document.createElement("div");if(t.className="toastify on "+this.options.className,this.options.position?t.className+=" toastify-"+this.options.position:!0===this.options.positionLeft?(t.className+=" toastify-left",console.warn("Property `positionLeft` will be depreciated in further versions. Please use `position` instead.")):t.className+=" toastify-right",t.className+=" "+this.options.gravity,this.options.backgroundColor&&(t.style.background=this.options.backgroundColor),this.options.node&&this.options.node.nodeType===Node.ELEMENT_NODE)t.appendChild(this.options.node);else if(t.innerHTML=this.options.text,""!==this.options.avatar){var o=document.createElement("img");o.src=this.options.avatar,o.className="toastify-avatar","left"==this.options.position||!0===this.options.positionLeft?t.appendChild(o):t.insertAdjacentElement("afterbegin",o)}if(!0===this.options.close){var s=document.createElement("span");s.innerHTML="✖",s.className="toast-close",s.addEventListener("click",function(t){t.stopPropagation(),this.removeElement(this.toastElement),window.clearTimeout(this.toastElement.timeOutValue)}.bind(this));var n=window.innerWidth>0?window.innerWidth:screen.width;("left"==this.options.position||!0===this.options.positionLeft)&&n>360?t.insertAdjacentElement("afterbegin",s):t.appendChild(s)}if(this.options.stopOnFocus&&this.options.duration>0){const o=this;t.addEventListener("mouseover",function(o){window.clearTimeout(t.timeOutValue)}),t.addEventListener("mouseleave",function(){t.timeOutValue=window.setTimeout(function(){o.removeElement(t)},o.options.duration)})}if(void 0!==this.options.destination&&t.addEventListener("click",function(t){t.stopPropagation(),!0===this.options.newWindow?window.open(this.options.destination,"_blank"):window.location=this.options.destination}.bind(this)),"function"==typeof this.options.onClick&&void 0===this.options.destination&&t.addEventListener("click",function(t){t.stopPropagation(),this.options.onClick()}.bind(this)),"object"==typeof this.options.offset){var e=i("x",this.options),a=i("y",this.options);const o="left"==this.options.position?e:`-${e}`,s="toastify-top"==this.options.gravity?a:`-${a}`;t.style.transform=`translate(${o}, ${s})`}return t},showToast:function(){var t;if(this.toastElement=this.buildToast(),!(t=void 0===this.options.selector?document.body:document.getElementById(this.options.selector)))throw"Root element is not defined";return t.insertBefore(this.toastElement,t.firstChild),o.reposition(),this.options.duration>0&&(this.toastElement.timeOutValue=window.setTimeout(function(){this.removeElement(this.toastElement)}.bind(this),this.options.duration)),this},hideToast:function(){this.toastElement.timeOutValue&&clearTimeout(this.toastElement.timeOutValue),this.removeElement(this.toastElement)},removeElement:function(t){t.className=t.className.replace(" on",""),window.setTimeout(function(){this.options.node&&this.options.node.parentNode&&this.options.node.parentNode.removeChild(this.options.node),t.parentNode&&t.parentNode.removeChild(t),this.options.callback.call(t),o.reposition()}.bind(this),400)}},o.reposition=function(){for(var t,o={top:15,bottom:15},i={top:15,bottom:15},n={top:15,bottom:15},e=document.getElementsByClassName("toastify"),a=0;a0?window.innerWidth:screen.width)<=360?(e[a].style[t]=n[t]+"px",n[t]+=p+15):!0===s(e[a],"toastify-left")?(e[a].style[t]=o[t]+"px",o[t]+=p+15):(e[a].style[t]=i[t]+"px",i[t]+=p+15)}return this},o.lib.init.prototype=o.lib,o}); -//# sourceMappingURL=/sm/1df7b098cd6209fd67b5cc8f6f6518b79e5214ec3802d91f56f825883253df69.map \ No newline at end of file diff --git a/sources/static/tsconfig.json b/sources/static/tsconfig.json deleted file mode 100755 index 6081949..0000000 --- a/sources/static/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "ES6", - "allowJs": true, - "checkJs": true, - "declaration": true, - "noImplicitThis": true, - "emitDeclarationOnly": true, - "strictFunctionTypes": true, - "strictBindCallApply": true, - "noFallthroughCasesInSwitch": true, - "noImplicitReturns": true, - "noUnusedLocals": true - }, - "files": [ - "protocol.js", - "galene.js" - ] -} diff --git a/sources/static/webfonts/fa-regular-400.eot b/sources/static/webfonts/fa-regular-400.eot deleted file mode 100755 index bef9f72..0000000 Binary files a/sources/static/webfonts/fa-regular-400.eot and /dev/null differ diff --git a/sources/static/webfonts/fa-regular-400.ttf b/sources/static/webfonts/fa-regular-400.ttf deleted file mode 100755 index 659527a..0000000 Binary files a/sources/static/webfonts/fa-regular-400.ttf and /dev/null differ diff --git a/sources/static/webfonts/fa-regular-400.woff b/sources/static/webfonts/fa-regular-400.woff deleted file mode 100755 index 31f44b2..0000000 Binary files a/sources/static/webfonts/fa-regular-400.woff and /dev/null differ diff --git a/sources/static/webfonts/fa-regular-400.woff2 b/sources/static/webfonts/fa-regular-400.woff2 deleted file mode 100755 index 0332a9b..0000000 Binary files a/sources/static/webfonts/fa-regular-400.woff2 and /dev/null differ diff --git a/sources/static/webfonts/fa-solid-900.eot b/sources/static/webfonts/fa-solid-900.eot deleted file mode 100755 index 5da4fa0..0000000 Binary files a/sources/static/webfonts/fa-solid-900.eot and /dev/null differ diff --git a/sources/static/webfonts/fa-solid-900.ttf b/sources/static/webfonts/fa-solid-900.ttf deleted file mode 100755 index e074608..0000000 Binary files a/sources/static/webfonts/fa-solid-900.ttf and /dev/null differ diff --git a/sources/static/webfonts/fa-solid-900.woff b/sources/static/webfonts/fa-solid-900.woff deleted file mode 100755 index ef6b447..0000000 Binary files a/sources/static/webfonts/fa-solid-900.woff and /dev/null differ diff --git a/sources/static/webfonts/fa-solid-900.woff2 b/sources/static/webfonts/fa-solid-900.woff2 deleted file mode 100755 index 120b300..0000000 Binary files a/sources/static/webfonts/fa-solid-900.woff2 and /dev/null differ