diff --git a/sources_2/data/ice-servers.json b/sources_2/data/ice-servers.json
new file mode 100755
index 0000000..fe51488
--- /dev/null
+++ b/sources_2/data/ice-servers.json
@@ -0,0 +1 @@
+[]
diff --git a/sources_2/galene b/sources_2/galene
new file mode 100755
index 0000000..a727ebb
Binary files /dev/null and b/sources_2/galene differ
diff --git a/sources_2/group/client.go b/sources_2/group/client.go
new file mode 100755
index 0000000..67212a3
--- /dev/null
+++ b/sources_2/group/client.go
@@ -0,0 +1,107 @@
+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) error
+ PushClient(id, username string, add bool) error
+}
+
+type Kickable interface {
+ Kick(id, user, message string) error
+}
diff --git a/sources_2/group/client_test.go b/sources_2/group/client_test.go
new file mode 100755
index 0000000..9352fc6
--- /dev/null
+++ b/sources_2/group/client_test.go
@@ -0,0 +1,87 @@
+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_2/group/group.go b/sources_2/group/group.go
new file mode 100755
index 0000000..60f6026
--- /dev/null
+++ b/sources_2/group/group.go
@@ -0,0 +1,852 @@
+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)
+}
+
+type ChatHistoryEntry struct {
+ Id string
+ User string
+ Time int64
+ Kind string
+ Value interface{}
+}
+
+const (
+ MinBitrate = 200000
+)
+
+type Group struct {
+ name string
+ api *webrtc.API
+
+ 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
+}
+
+func (g *Group) API() *webrtc.API {
+ return g.api
+}
+
+func codecFromName(name string) (webrtc.RTPCodecCapability, error) {
+ switch name {
+ case "vp8":
+ return webrtc.RTPCodecCapability{
+ "video/VP8", 90000, 0,
+ "",
+ nil,
+ }, nil
+ case "vp9":
+ return webrtc.RTPCodecCapability{
+ "video/VP9", 90000, 0,
+ "profile-id=2",
+ nil,
+ }, nil
+ case "h264":
+ return webrtc.RTPCodecCapability{
+ "video/H264", 90000, 0,
+ "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f",
+ nil,
+ }, nil
+ case "opus":
+ return webrtc.RTPCodecCapability{
+ "audio/opus", 48000, 2,
+ "minptime=10;useinbandfec=1",
+ nil,
+ }, nil
+ case "g722":
+ return webrtc.RTPCodecCapability{
+ "audio/G722", 8000, 1,
+ "",
+ nil,
+ }, nil
+ case "pcmu":
+ return webrtc.RTPCodecCapability{
+ "audio/PCMU", 8000, 1,
+ "",
+ nil,
+ }, nil
+ case "pcma":
+ return webrtc.RTPCodecCapability{
+ "audio/PCMA", 8000, 1,
+ "",
+ nil,
+ }, nil
+ default:
+ return webrtc.RTPCodecCapability{}, errors.New("unknown codec")
+ }
+}
+
+func payloadType(codec webrtc.RTPCodecCapability) (webrtc.PayloadType, error) {
+ switch strings.ToLower(codec.MimeType) {
+ case "video/vp8":
+ return 96, nil
+ case "video/vp9":
+ return 98, nil
+ case "video/h264":
+ return 102, nil
+ case "audio/opus":
+ return 111, nil
+ case "audio/g722":
+ return 9, nil
+ case "audio/pcmu":
+ return 0, nil
+ case "audio/pcma":
+ return 8, nil
+ default:
+ return 0, errors.New("unknown codec")
+ }
+}
+
+func APIFromCodecs(codecs []webrtc.RTPCodecCapability) *webrtc.API {
+ s := webrtc.SettingEngine{}
+ s.SetSRTPReplayProtectionWindow(512)
+ if !UseMDNS {
+ s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
+ }
+ m := webrtc.MediaEngine{}
+
+ for _, codec := range codecs {
+ var tpe webrtc.RTPCodecType
+ var fb []webrtc.RTCPFeedback
+ if strings.HasPrefix(strings.ToLower(codec.MimeType), "video/") {
+ tpe = webrtc.RTPCodecTypeVideo
+ fb = []webrtc.RTCPFeedback{
+ {"goog-remb", ""},
+ {"nack", ""},
+ {"nack", "pli"},
+ {"ccm", "fir"},
+ }
+ } else if strings.HasPrefix(strings.ToLower(codec.MimeType), "audio/") {
+ tpe = webrtc.RTPCodecTypeAudio
+ fb = []webrtc.RTCPFeedback{}
+ } else {
+ continue
+ }
+
+ ptpe, err := payloadType(codec)
+ if err != nil {
+ log.Printf("%v", err)
+ continue
+ }
+ m.RegisterCodec(
+ webrtc.RTPCodecParameters{
+ RTPCodecCapability: webrtc.RTPCodecCapability{
+ MimeType: codec.MimeType,
+ ClockRate: codec.ClockRate,
+ Channels: codec.Channels,
+ SDPFmtpLine: codec.SDPFmtpLine,
+ RTCPFeedback: fb,
+ },
+ PayloadType: ptpe,
+ },
+ tpe,
+ )
+ }
+ return webrtc.NewAPI(
+ webrtc.WithSettingEngine(s),
+ webrtc.WithMediaEngine(&m),
+ )
+}
+
+func APIFromNames(names []string) *webrtc.API {
+ if len(names) == 0 {
+ names = []string{"vp8", "opus"}
+ }
+ codecs := make([]webrtc.RTPCodecCapability, 0, len(names))
+ for _, n := range names {
+ codec, err := codecFromName(n)
+ if err != nil {
+ log.Printf("Codec %v: %v", n, err)
+ continue
+ }
+ codecs = append(codecs, codec)
+ }
+
+ return APIFromCodecs(codecs)
+}
+
+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)
+ }
+
+ 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(),
+ api: APIFromNames(desc.Codecs),
+ }
+ groups.groups[name] = g
+ return g, nil
+ }
+
+ g.mu.Lock()
+ defer g.mu.Unlock()
+
+ if desc != nil {
+ g.description = desc
+ g.api = APIFromNames(desc.Codecs)
+ 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
+ g.api = APIFromNames(desc.Codecs)
+ } 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
+ })
+}
+
+type warner interface {
+ Warn(oponly bool, message string) error
+}
+
+func (g *Group) WallOps(message string) {
+ clients := g.GetClients(nil)
+ for _, c := range clients {
+ w, ok := c.(warner)
+ if !ok {
+ continue
+ }
+ err := w.Warn(true, message)
+ if err != nil {
+ log.Printf("WallOps: %v", err)
+ }
+ }
+}
+
+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 string, value interface{}) {
+ 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"`
+ Codecs []string `json:"codecs,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_2/group/group_test.go b/sources_2/group/group_test.go
new file mode 100755
index 0000000..fddd982
--- /dev/null
+++ b/sources_2/group/group_test.go
@@ -0,0 +1,58 @@
+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_2/groups/groupname.json b/sources_2/groups/groupname.json
new file mode 100644
index 0000000..9751310
--- /dev/null
+++ b/sources_2/groups/groupname.json
@@ -0,0 +1,4 @@
+{
+ "op": [{"username": "__ADMIN__", "password": "__PASSWORD__"}],
+ "presenter": [{}]
+}
diff --git a/sources_2/static/404.css b/sources_2/static/404.css
new file mode 100755
index 0000000..0dcc899
--- /dev/null
+++ b/sources_2/static/404.css
@@ -0,0 +1,69 @@
+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_2/static/404.html b/sources_2/static/404.html
new file mode 100755
index 0000000..fbf187a
--- /dev/null
+++ b/sources_2/static/404.html
@@ -0,0 +1,31 @@
+
+
+
+ Page not Found
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Page not found!
+
We can't find the page you're looking for.
+
Back to home
+
+
+
+
+
diff --git a/sources_2/static/common.css b/sources_2/static/common.css
new file mode 100755
index 0000000..9553cbf
--- /dev/null
+++ b/sources_2/static/common.css
@@ -0,0 +1,44 @@
+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_2/static/css/fontawesome.min.css b/sources_2/static/css/fontawesome.min.css
new file mode 100755
index 0000000..8e36e25
--- /dev/null
+++ b/sources_2/static/css/fontawesome.min.css
@@ -0,0 +1,5 @@
+/*!
+ * 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_2/static/css/regular.css b/sources_2/static/css/regular.css
new file mode 100755
index 0000000..8db06c6
--- /dev/null
+++ b/sources_2/static/css/regular.css
@@ -0,0 +1,15 @@
+/*!
+ * 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_2/static/css/solid.css b/sources_2/static/css/solid.css
new file mode 100755
index 0000000..62922cb
--- /dev/null
+++ b/sources_2/static/css/solid.css
@@ -0,0 +1,16 @@
+/*!
+ * 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_2/static/css/toastify.min.css b/sources_2/static/css/toastify.min.css
new file mode 100755
index 0000000..8041580
--- /dev/null
+++ b/sources_2/static/css/toastify.min.css
@@ -0,0 +1,15 @@
+/**
+ * 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_2/static/galene.css b/sources_2/static/galene.css
new file mode 100755
index 0000000..814b186
--- /dev/null
+++ b/sources_2/static/galene.css
@@ -0,0 +1,1307 @@
+.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_2/static/galene.html b/sources_2/static/galene.html
new file mode 100755
index 0000000..73f2d0b
--- /dev/null
+++ b/sources_2/static/galene.html
@@ -0,0 +1,255 @@
+
+
+
+ Galène
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sources_2/static/galene.js b/sources_2/static/galene.js
new file mode 100755
index 0000000..4ca1b3a
--- /dev/null
+++ b/sources_2/static/galene.js
@@ -0,0 +1,2353 @@
+// 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;
+}
+
+/**
+ * @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.onstatus = function(status) {
+ setMediaStatus(c);
+ };
+ c.onstats = gotDownStats;
+ if(getSettings().activityDetection)
+ c.setStatsInterval(activityDetectionInterval);
+
+ setMedia(c, false);
+}
+
+// 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');
+ /** @ts-ignore */
+ let canFile = !!HTMLVideoElement.prototype.captureStream;
+
+ // 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', canFile && 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]).catch(e => {
+ console.error(e);
+ displayError(e);
+ });
+ }
+ 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) {
+ /** @ts-ignore */
+ if(!HTMLVideoElement.prototype.captureStream) {
+ displayError("This browser doesn't support file playback");
+ return;
+ }
+
+ 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.username;
+ 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"
+ );
+ }
+ }
+}
+
+/**
+ * @param {string} id
+ * @param {string} dest
+ * @param {string} username
+ * @param {number} time
+ * @param {boolean} privileged
+ * @param {string} kind
+ * @param {unknown} message
+ */
+function gotUserMessage(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':
+ 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;
+ case 'clearchat':
+ if(privileged) {
+ clearChat();
+ } else {
+ console.error(`Got unprivileged message of kind ${kind}`);
+ }
+ break;
+ default:
+ console.warn(`Got unknown user message ${kind}`);
+ break;
+ }
+};
+
+
+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 {unknown} 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.toString().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);
+ } 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.toString()).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 = {};
+ }
+ container.appendChild(footer);
+
+ 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('clearchat');
+ }
+};
+
+commands.lock = {
+ predicate: operatorPredicate,
+ description: 'lock this group',
+ parameters: '[message]',
+ f: (c, r) => {
+ serverConnection.groupAction('lock', r);
+ }
+};
+
+commands.unlock = {
+ predicate: operatorPredicate,
+ description: 'unlock this group, revert the effect of /lock',
+ f: (c, r) => {
+ serverConnection.groupAction('unlock');
+ }
+};
+
+commands.record = {
+ predicate: recordingPredicate,
+ description: 'start recording',
+ f: (c, r) => {
+ serverConnection.groupAction('record');
+ }
+};
+
+commands.unrecord = {
+ predicate: recordingPredicate,
+ description: 'stop recording',
+ f: (c, r) => {
+ serverConnection.groupAction('unrecord');
+ }
+};
+
+commands.subgroups = {
+ predicate: operatorPredicate,
+ description: 'list subgroups',
+ f: (c, r) => {
+ serverConnection.groupAction('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]}`);
+ serverConnection.chat('', id, p[1]);
+ addToChatbox(serverConnection.id, id, serverConnection.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(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(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('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;
+ }
+
+ try {
+ serverConnection.chat(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.onusermessage = gotUserMessage;
+
+ 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_2/static/index.html b/sources_2/static/index.html
new file mode 100755
index 0000000..83e32ee
--- /dev/null
+++ b/sources_2/static/index.html
@@ -0,0 +1,42 @@
+
+
+
+ Galène
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sources_2/static/mainpage.css b/sources_2/static/mainpage.css
new file mode 100755
index 0000000..14b891c
--- /dev/null
+++ b/sources_2/static/mainpage.css
@@ -0,0 +1,40 @@
+.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_2/static/mainpage.js b/sources_2/static/mainpage.js
new file mode 100755
index 0000000..1f80c40
--- /dev/null
+++ b/sources_2/static/mainpage.js
@@ -0,0 +1,73 @@
+// 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_2/static/protocol.js b/sources_2/static/protocol.js
new file mode 100755
index 0000000..611d0da
--- /dev/null
+++ b/sources_2/static/protocol.js
@@ -0,0 +1,1156 @@
+// 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 null if we haven't joined yet.
+ *
+ * @type {string}
+ */
+ this.group = null;
+ /**
+ * The username we joined as.
+ */
+ this.username = 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 {RTCConfiguration}
+ */
+ this.rtcConfiguration = 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: unknown) => 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: unknown) => void}
+ */
+ this.onusermessage = null;
+}
+
+/**
+ * @typedef {Object} message
+ * @property {string} type
+ * @property {string} [kind]
+ * @property {string} [id]
+ * @property {string} [source]
+ * @property {string} [dest]
+ * @property {string} [username]
+ * @property {string} [password]
+ * @property {boolean} [privileged]
+ * @property {Object} [permissions]
+ * @property {string} [group]
+ * @property {unknown} [value]
+ * @property {string} [sdp]
+ * @property {RTCIceCandidate} [candidate]
+ * @property {Object} [labels]
+ * @property {Object} [request]
+ * @property {Object} [rtcConfiguration]
+ */
+
+/**
+ * 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));
+};
+
+/**
+ * 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;
+ }
+
+ 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;
+ sc.username = 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 'handshake':
+ break;
+ case 'offer':
+ sc.gotOffer(m.id, m.labels, m.source, m.username,
+ m.sdp, m.kind === 'renegotiate');
+ break;
+ case 'answer':
+ sc.gotAnswer(m.id, m.sdp);
+ 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 'joined':
+ if(sc.group) {
+ if(m.group !== sc.group) {
+ throw new Error('Joined multiple groups');
+ }
+ } else {
+ sc.group = m.group;
+ }
+ sc.username = m.username;
+ sc.permissions = m.permissions || [];
+ sc.rtcConfiguration = m.rtcConfiguration || null;
+ 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.source, m.dest, m.username, m.time,
+ m.privileged, m.kind, m.value,
+ );
+ break;
+ case 'usermessage':
+ if(sc.onusermessage)
+ sc.onusermessage.call(
+ sc, m.source, m.dest, m.username, m.time,
+ m.privileged, m.kind, m.value,
+ );
+ 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(sc.rtcConfiguration);
+ 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} 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(kind, dest, value) {
+ this.send({
+ type: 'chat',
+ source: this.id,
+ dest: dest,
+ username: this.username,
+ kind: kind,
+ value: value,
+ });
+};
+
+/**
+ * userAction sends a request to act on a user.
+ *
+ * @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(kind, dest, value) {
+ this.send({
+ type: 'useraction',
+ source: this.id,
+ dest: dest,
+ username: this.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} 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(kind, dest, value) {
+ this.send({
+ type: 'usermessage',
+ source: this.id,
+ dest: dest,
+ username: this.username,
+ kind: kind,
+ value: value,
+ });
+};
+
+/**
+ * groupAction sends a request to act on the current group.
+ *
+ * @param {string} kind
+ * - One of 'clearchat', 'lock', 'unlock', 'record' or 'unrecord'.
+ * @param {string} [message] - An optional user-readable message.
+ */
+ServerConnection.prototype.groupAction = function(kind, message) {
+ this.send({
+ type: 'groupaction',
+ source: this.id,
+ kind: kind,
+ username: this.username,
+ value: message,
+ });
+};
+
+/**
+ * Called when we receive an offer from the server. Don't call this.
+ *
+ * @param {string} id
+ * @param {Object} labels
+ * @param {string} source
+ * @param {string} username
+ * @param {string} sdp
+ * @param {boolean} renegotiate
+ * @function
+ */
+ServerConnection.prototype.gotOffer = async function(id, labels, source, username, sdp, 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(sc.rtcConfiguration);
+ 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");
+ }
+ c.stream = e.streams[0];
+ if(c.ondowntrack) {
+ c.ondowntrack.call(
+ c, e.track, e.transceiver, label, e.streams[0],
+ );
+ }
+ };
+ }
+
+ c.labelsByMid = labels;
+ c.source = source;
+ c.username = username;
+
+ if(sc.ondownstream)
+ sc.ondownstream.call(sc, c);
+
+ try {
+ await c.pc.setRemoteDescription({
+ type: 'offer',
+ sdp: sdp,
+ });
+
+ 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,
+ sdp: answer.sdp,
+ });
+ } catch(e) {
+ try {
+ if(c.onerror)
+ c.onerror.call(c, e);
+ } finally {
+ c.abort();
+ }
+ return;
+ }
+
+ c.localDescriptionSent = true;
+ c.flushLocalIceCandidates();
+ if(c.onnegotiationcompleted)
+ c.onnegotiationcompleted.call(c);
+};
+
+/**
+ * Called when we receive an answer from the server. Don't call this.
+ *
+ * @param {string} id
+ * @param {string} sdp
+ * @function
+ */
+ServerConnection.prototype.gotAnswer = async function(id, sdp) {
+ let c = this.up[id];
+ if(!c)
+ throw new Error('unknown up stream');
+ try {
+ await c.pc.setRemoteDescription({
+ type: 'answer',
+ sdp: sdp,
+ });
+ } catch(e) {
+ try {
+ if(c.onerror)
+ c.onerror.call(c, e);
+ } finally {
+ c.close();
+ }
+ 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, the id of the client that created the stream.
+ *
+ * @type {string}
+ */
+ this.source = null;
+ /**
+ * For down streams, the username of the client who created the stream.
+ *
+ * @type {string}
+ */
+ this.username = null;
+ /**
+ * The associated RTCPeerConnection. 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;
+ /**
+ * 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.
+ *
+ * For streams in the up direction, this may be called at any time. For
+ * streams in the down direction, this will be called automatically when
+ * the server signals that it is closing 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;
+};
+
+/**
+ * abort requests that the server close a down stream.
+ */
+Stream.prototype.abort = function() {
+ let c = this;
+ if(c.up)
+ throw new Error("Abort called on an up stream");
+ c.sc.send({
+ type: 'abort',
+ id: c.id,
+ });
+};
+
+/**
+ * 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',
+ source: c.sc.id,
+ username: c.sc.username,
+ kind: this.localDescriptionSent ? 'renegotiate' : '',
+ id: c.id,
+ labels: c.labelsByMid,
+ sdp: offer.sdp,
+ });
+ 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_2/static/scripts/toastify.js b/sources_2/static/scripts/toastify.js
new file mode 100755
index 0000000..2e3bdb5
--- /dev/null
+++ b/sources_2/static/scripts/toastify.js
@@ -0,0 +1,8 @@
+/**
+ * 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_2/static/tsconfig.json b/sources_2/static/tsconfig.json
new file mode 100755
index 0000000..6081949
--- /dev/null
+++ b/sources_2/static/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "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_2/static/webfonts/fa-regular-400.eot b/sources_2/static/webfonts/fa-regular-400.eot
new file mode 100755
index 0000000..bef9f72
Binary files /dev/null and b/sources_2/static/webfonts/fa-regular-400.eot differ
diff --git a/sources_2/static/webfonts/fa-regular-400.ttf b/sources_2/static/webfonts/fa-regular-400.ttf
new file mode 100755
index 0000000..659527a
Binary files /dev/null and b/sources_2/static/webfonts/fa-regular-400.ttf differ
diff --git a/sources_2/static/webfonts/fa-regular-400.woff b/sources_2/static/webfonts/fa-regular-400.woff
new file mode 100755
index 0000000..31f44b2
Binary files /dev/null and b/sources_2/static/webfonts/fa-regular-400.woff differ
diff --git a/sources_2/static/webfonts/fa-regular-400.woff2 b/sources_2/static/webfonts/fa-regular-400.woff2
new file mode 100755
index 0000000..0332a9b
Binary files /dev/null and b/sources_2/static/webfonts/fa-regular-400.woff2 differ
diff --git a/sources_2/static/webfonts/fa-solid-900.eot b/sources_2/static/webfonts/fa-solid-900.eot
new file mode 100755
index 0000000..5da4fa0
Binary files /dev/null and b/sources_2/static/webfonts/fa-solid-900.eot differ
diff --git a/sources_2/static/webfonts/fa-solid-900.ttf b/sources_2/static/webfonts/fa-solid-900.ttf
new file mode 100755
index 0000000..e074608
Binary files /dev/null and b/sources_2/static/webfonts/fa-solid-900.ttf differ
diff --git a/sources_2/static/webfonts/fa-solid-900.woff b/sources_2/static/webfonts/fa-solid-900.woff
new file mode 100755
index 0000000..ef6b447
Binary files /dev/null and b/sources_2/static/webfonts/fa-solid-900.woff differ
diff --git a/sources_2/static/webfonts/fa-solid-900.woff2 b/sources_2/static/webfonts/fa-solid-900.woff2
new file mode 100755
index 0000000..120b300
Binary files /dev/null and b/sources_2/static/webfonts/fa-solid-900.woff2 differ