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 + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+ +
+
+
+
+ +
+
+

Settings

+ +
+
+ + + +
+ Other Settings + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ + + + + + + + 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 + + + + + + + + + + + + + +
+

Galène

+ +
+ + +
+
+ +
+

Public groups

+ +
+
+
+ + + + + + + + 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